Compare commits

..
93 changed files with 2129 additions and 4603 deletions
@@ -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 library |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
| [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,8 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/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. |
| `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`. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
```jsx live
function App() {
+18 -3
View File
@@ -302,6 +302,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: false,
});
}
};
@@ -448,12 +449,14 @@ class Collab extends PureComponent<Props, CollabState> {
}
return element;
});
// remove deleted elements from elements array to ensure we don't
// remove deleted elements from elements array & history 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));
@@ -488,7 +491,9 @@ class Collab extends PureComponent<Props, CollabState> {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
// noop if already resolved via init from firebase
scenePromise.resolve({
elements: reconciledElements,
@@ -644,11 +649,21 @@ class Collab extends PureComponent<Props, CollabState> {
});
}, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = (elements: ReconciledElements) => {
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
) => {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: !!init,
});
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles();
};
+6 -1
View File
@@ -18,6 +18,7 @@ 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;
@@ -149,7 +150,11 @@ class Portal {
this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push(element);
acc.push({
...element,
// z-index info for the reconciler
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
});
}
return acc;
},
+105 -30
View File
@@ -1,13 +1,15 @@
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../src/element/types";
import { AppState } from "../../src/types";
import { arrayToMap } from "../../src/utils";
import { orderByFractionalIndex } from "../../src/fractionalIndex";
import { arrayToMapWithIndex } from "../../src/utils";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
export type BroadcastedExcalidrawElement = ExcalidrawElement;
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
[PRECEDING_ELEMENT_KEY]?: string;
};
const shouldDiscardRemoteElement = (
localAppState: AppState,
@@ -19,7 +21,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id || // Is this still valid? As draggingElement is selection element, which is never part of the elements array
local.id === localAppState.draggingElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
@@ -37,43 +39,116 @@ export const reconcileElements = (
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData = arrayToMap(localElements);
const reconciledElements: ExcalidrawElement[] = [];
const added = new Set<string>();
const localElementsData =
arrayToMapWithIndex<ExcalidrawElement>(localElements);
// process remote elements
const reconciledElements: ExcalidrawElement[] = localElements.slice();
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) {
if (localElementsData.has(remoteElement.id)) {
const localElement = localElementsData.get(remoteElement.id);
remoteElementIdx++;
if (
localElement &&
shouldDiscardRemoteElement(localAppState, localElement, remoteElement)
) {
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) {
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 {
if (!added.has(remoteElement.id)) {
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 {
reconciledElements.push(remoteElement);
added.add(remoteElement.id);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
// 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 {
if (!added.has(remoteElement.id)) {
reconciledElements.push(remoteElement);
added.add(remoteElement.id);
}
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
// process local elements
for (const localElement of localElements) {
if (!added.has(localElement.id)) {
reconciledElements.push(localElement);
added.add(localElement.id);
}
}
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
(element) => !duplicates.has(element),
);
return orderByFractionalIndex(
reconciledElements,
) as readonly ExcalidrawElement[] as ReconciledElements;
return ret as ReconciledElements;
};
+1 -1
View File
@@ -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,
commitToStore: false,
commitToHistory: false,
};
};
+1 -2
View File
@@ -409,7 +409,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToStore: true,
commitToHistory: true,
});
}
});
@@ -590,7 +590,6 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
skipSnapshotUpdate: true,
});
}
}
+4 -21
View File
@@ -64,7 +64,7 @@ vi.mock("socket.io-client", () => {
});
describe("collaboration", () => {
it("creating room should reset deleted elements while keeping store snapshot in sync", async () => {
it("creating room should reset deleted elements", async () => {
await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab
updateSceneData({
@@ -76,43 +76,26 @@ 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(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(API.getStateHistory().length).toBe(1);
});
window.collab.startCollaboration(null);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
// 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 }),
]);
expect(API.getStateHistory().length).toBe(1);
});
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(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(API.getStateHistory().length).toBe(1);
});
});
});
+16 -3
View File
@@ -1,4 +1,5 @@
import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../src/element/types";
import {
BroadcastedExcalidrawElement,
@@ -14,6 +15,7 @@ type ElementLike = {
id: string;
version: number;
versionNonce: number;
[PRECEDING_ELEMENT_KEY]?: string | null;
};
type Cache = Record<string, ExcalidrawElement | undefined>;
@@ -42,6 +44,7 @@ const createElement = (opts: { uid: string } | ElementLike) => {
id,
version,
versionNonce: versionNonce || randomInteger(),
[PRECEDING_ELEMENT_KEY]: parent || null,
};
};
@@ -50,15 +53,20 @@ const idsToElements = (
cache: Cache = {},
): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => {
const { uid, id, version, versionNonce } = createElement(
typeof _uid === "string" ? { uid: _uid } : _uid,
);
const {
uid,
id,
version,
[PRECEDING_ELEMENT_KEY]: parent,
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;
@@ -69,6 +77,7 @@ const idsToElements = (
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
return elements.map((el, idx, els) => {
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
return el;
});
};
@@ -380,11 +389,13 @@ describe("elements reconciliation", () => {
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
{
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
];
@@ -397,11 +408,13 @@ 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"]);
});
-1
View File
@@ -37,7 +37,6 @@
"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 -4
View File
@@ -3,7 +3,6 @@ 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",
@@ -18,7 +17,7 @@ export const actionAddToLibrary = register({
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`),
@@ -42,7 +41,7 @@ export const actionAddToLibrary = register({
})
.then(() => {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
toast: { message: t("toast.addedToLibrary") },
@@ -51,7 +50,7 @@ export const actionAddToLibrary = register({
})
.catch((error) => {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
+6 -7
View File
@@ -18,7 +18,6 @@ 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[],
@@ -64,7 +63,7 @@ export const actionAlignTop = register({
position: "start",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -95,7 +94,7 @@ export const actionAlignBottom = register({
position: "end",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -126,7 +125,7 @@ export const actionAlignLeft = register({
position: "start",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -157,7 +156,7 @@ export const actionAlignRight = register({
position: "end",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -188,7 +187,7 @@ export const actionAlignVerticallyCentered = register({
position: "center",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@@ -215,7 +214,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
+3 -4
View File
@@ -33,7 +33,6 @@ 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",
@@ -81,7 +80,7 @@ export const actionUnbindText = register({
return {
elements,
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
@@ -150,7 +149,7 @@ export const actionBindText = register({
return {
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
@@ -300,7 +299,7 @@ export const actionWrapTextInContainer = register({
...appState,
selectedElementIds: containerIds,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
+14 -25
View File
@@ -1,13 +1,7 @@
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import {
CURSOR_TYPE,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
} from "../constants";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
@@ -28,7 +22,6 @@ 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",
@@ -42,9 +35,7 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
commitToHistory: !!value.viewBackgroundColor,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => {
@@ -97,7 +88,7 @@ export const actionClearCanvas = register({
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
@@ -119,17 +110,16 @@ export const actionZoomIn = register({
appState,
),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
PanelComponent: ({ updateData }) => (
<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);
}}
@@ -157,17 +147,16 @@ export const actionZoomOut = register({
appState,
),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
PanelComponent: ({ updateData }) => (
<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);
}}
@@ -195,7 +184,7 @@ export const actionResetZoom = register({
appState,
),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
@@ -272,8 +261,8 @@ export const zoomToFit = ({
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
Math.max(newZoomValue, 0.1),
30.0,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
@@ -318,7 +307,7 @@ export const zoomToFit = ({
scrollY,
zoom: { value: newZoomValue },
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
};
@@ -388,7 +377,7 @@ export const actionToggleTheme = register({
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
@@ -425,7 +414,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null,
activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.E,
@@ -460,7 +449,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null,
activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
+13 -14
View File
@@ -13,7 +13,6 @@ 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",
@@ -29,7 +28,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
@@ -38,7 +37,7 @@ export const actionCopy = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.copy",
@@ -64,7 +63,7 @@ export const actionPaste = register({
if (isFirefox) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
@@ -73,7 +72,7 @@ export const actionPaste = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
@@ -86,7 +85,7 @@ export const actionPaste = register({
} catch (error: any) {
console.error(error);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
@@ -95,7 +94,7 @@ export const actionPaste = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.paste",
@@ -120,7 +119,7 @@ export const actionCopyAsSvg = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
@@ -142,7 +141,7 @@ export const actionCopyAsSvg = register({
},
);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
} catch (error: any) {
console.error(error);
@@ -151,7 +150,7 @@ export const actionCopyAsSvg = register({
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
@@ -167,7 +166,7 @@ export const actionCopyAsPng = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
@@ -200,7 +199,7 @@ export const actionCopyAsPng = register({
}),
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
} catch (error: any) {
console.error(error);
@@ -209,7 +208,7 @@ export const actionCopyAsPng = register({
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
@@ -239,7 +238,7 @@ export const copyText = register({
.join("\n\n");
copyTextToSystemClipboard(text);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
predicate: (elements, appState, _, app) => {
+4 -7
View File
@@ -13,7 +13,6 @@ 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[],
@@ -110,7 +109,7 @@ export const actionDeleteSelected = register({
...nextAppState,
editingLinearElement: null,
},
storeAction: StoreAction.UPDATE,
commitToHistory: false,
};
}
@@ -142,7 +141,7 @@ export const actionDeleteSelected = register({
: [0],
},
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
let { elements: nextElements, appState: nextAppState } =
@@ -162,12 +161,10 @@ export const actionDeleteSelected = register({
multiElement: null,
activeEmbeddable: null,
},
storeAction: isSomeElementSelected(
commitToHistory: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)
? StoreAction.CAPTURE
: StoreAction.NONE,
),
};
},
contextItemLabel: "labels.delete",
+2 -3
View File
@@ -14,7 +14,6 @@ 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);
@@ -54,7 +53,7 @@ export const distributeHorizontally = register({
space: "between",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -84,7 +83,7 @@ export const distributeVertically = register({
space: "between",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
+4 -10
View File
@@ -14,7 +14,7 @@ import {
} from "../groups";
import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult, StoreAction } from "./types";
import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
import {
bindTextToShapeAfterDuplication,
@@ -31,7 +31,6 @@ import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
import { fixFractionalIndices } from "../fractionalIndex";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
@@ -48,13 +47,13 @@ export const actionDuplicateSelection = register({
return {
elements,
appState: ret.appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
return {
...duplicateElements(elements, appState),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.duplicateSelection",
@@ -86,7 +85,6 @@ 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(
@@ -98,7 +96,6 @@ 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);
@@ -237,10 +234,7 @@ const duplicateElements = (
// step (3)
const finalElements = fixFractionalIndices(
finalElementsReversed.reverse(),
duplicatedElementsMap,
);
const finalElements = finalElementsReversed.reverse();
// ---------------------------------------------------------------------------
+2 -3
View File
@@ -4,7 +4,6 @@ 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);
@@ -45,7 +44,7 @@ export const actionToggleElementLock = register({
? null
: appState.selectedLinearElement,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: (elements, appState, app) => {
@@ -99,7 +98,7 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]),
),
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.elementLock.unlockAll",
+11 -15
View File
@@ -19,16 +19,12 @@ 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 },
storeAction: StoreAction.UPDATE,
};
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData, appProps, data }) => (
<ProjectName
@@ -49,7 +45,7 @@ export const actionChangeExportScale = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
@@ -98,7 +94,7 @@ export const actionChangeExportBackground = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
@@ -117,7 +113,7 @@ export const actionChangeExportEmbedScene = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
@@ -152,7 +148,7 @@ export const actionSaveToActiveFile = register({
: await saveAsJSON(elements, appState, app.files);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
fileHandle,
@@ -174,7 +170,7 @@ export const actionSaveToActiveFile = register({
} else {
console.warn(error);
}
return { storeAction: StoreAction.NONE };
return { commitToHistory: false };
}
},
keyTest: (event) =>
@@ -196,7 +192,7 @@ export const actionSaveFileToDisk = register({
app.files,
);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
openDialog: null,
@@ -210,7 +206,7 @@ export const actionSaveFileToDisk = register({
} else {
console.warn(error);
}
return { storeAction: StoreAction.NONE };
return { commitToHistory: false };
}
},
keyTest: (event) =>
@@ -248,7 +244,7 @@ export const actionLoadScene = register({
elements: loadedElements,
appState: loadedAppState,
files,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
} catch (error: any) {
if (error?.name === "AbortError") {
@@ -259,7 +255,7 @@ export const actionLoadScene = register({
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
@@ -272,7 +268,7 @@ export const actionExportWithDarkMode = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
+2 -6
View File
@@ -16,7 +16,6 @@ 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",
@@ -50,7 +49,7 @@ export const actionFinalize = register({
cursorButton: "up",
editingLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
}
@@ -191,10 +190,7 @@ export const actionFinalize = register({
: appState.selectedLinearElement,
pendingImageElementId: null,
},
storeAction:
appState.activeTool.type === "freedraw"
? StoreAction.CAPTURE
: StoreAction.UPDATE,
commitToHistory: appState.activeTool.type === "freedraw",
};
},
keyTest: (event, appState) =>
+2 -3
View File
@@ -13,7 +13,6 @@ import {
unbindLinearElements,
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { StoreAction } from "./types";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -26,7 +25,7 @@ export const actionFlipHorizontal = register({
app,
),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) => event.shiftKey && event.code === CODES.H,
@@ -44,7 +43,7 @@ export const actionFlipVertical = register({
app,
),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
+6 -7
View File
@@ -8,7 +8,6 @@ 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);
@@ -40,14 +39,14 @@ export const actionSelectAllElementsInFrame = register({
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
storeAction: StoreAction.CAPTURE,
commitToHistory: false,
};
}
return {
elements,
appState,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
@@ -75,14 +74,14 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true,
},
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
return {
elements,
appState,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
@@ -104,7 +103,7 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled,
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.updateFrameRendering",
@@ -132,7 +131,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
}),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) =>
+5 -6
View File
@@ -27,7 +27,6 @@ import {
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
import { StoreAction } from "./types";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@@ -70,7 +69,7 @@ export const actionGroup = register({
});
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, storeAction: StoreAction.NONE };
return { appState, elements, commitToHistory: false };
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
@@ -90,7 +89,7 @@ export const actionGroup = register({
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, storeAction: StoreAction.NONE };
return { appState, elements, commitToHistory: false };
}
}
@@ -156,7 +155,7 @@ export const actionGroup = register({
),
},
elements: nextElements,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.group",
@@ -183,7 +182,7 @@ export const actionUngroup = register({
perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {
return { appState, elements, storeAction: StoreAction.NONE, };
return { appState, elements, commitToHistory: false };
}
let nextElements = [...elements];
@@ -251,7 +250,7 @@ export const actionUngroup = register({
return {
appState: { ...appState, ...updateAppState },
elements: nextElements,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
+40 -29
View File
@@ -1,51 +1,62 @@
import { Action, ActionResult, StoreAction } from "./types";
import { Action, ActionResult } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { History } from "../history";
import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
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 = (
appState: Readonly<AppState>,
updater: () => [Map<string, ExcalidrawElement>, AppState] | void,
prevElements: readonly ExcalidrawElement[],
appState: AppState,
updater: () => HistoryEntry | null,
): ActionResult => {
const commitToHistory = false;
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.draggingElement
) {
const result = updater();
if (!result) {
return { storeAction: StoreAction.NONE };
const data = updater();
if (data === null) {
return { commitToHistory };
}
// 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 prevElementMap = arrayToMap(prevElements);
const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements);
// 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);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap.get(nextElement.id) || nextElement,
nextElement,
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
return {
appState: nextAppState,
elements: nextElements,
storeAction: StoreAction.UPDATE,
elements,
appState: { ...appState, ...data.appState },
commitToHistory,
syncHistory: true,
};
}
return { storeAction: StoreAction.NONE };
return { commitToHistory };
};
type ActionCreator = (history: History) => Action;
@@ -54,7 +65,7 @@ export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(appState, () => history.undo(arrayToMap(elements), appState)),
writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
@@ -66,16 +77,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(appState, () => history.redo(arrayToMap(elements), appState)),
writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
@@ -88,7 +99,7 @@ export const createRedoAction: ActionCreator = (history) => ({
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={history.isRedoStackEmpty}
/>
),
commitToHistory: () => false,
});
+1 -2
View File
@@ -2,7 +2,6 @@ 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",
@@ -31,7 +30,7 @@ export const actionToggleLinearEditor = register({
...appState,
editingLinearElement,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: false,
};
},
contextItemLabel: (elements, appState, app) => {
+10 -6
View File
@@ -4,7 +4,6 @@ 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",
@@ -14,7 +13,7 @@ export const actionToggleCanvasMenu = register({
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
storeAction: StoreAction.NONE,
commitToHistory: false,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
@@ -35,7 +34,7 @@ export const actionToggleEditMenu = register({
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
storeAction: StoreAction.NONE,
commitToHistory: false,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@@ -57,15 +56,20 @@ export const actionShortcuts = register({
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog === "help") {
if (appState.openDialog?.name === "help") {
focusContainer();
}
return {
appState: {
...appState,
openDialog: appState.openDialog === "help" ? null : "help",
openDialog:
appState.openDialog?.name === "help"
? null
: {
name: "help",
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
+2 -3
View File
@@ -3,7 +3,6 @@ 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",
@@ -12,7 +11,7 @@ export const actionGoToCollaborator = register({
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
if (!point) {
return { appState, storeAction: StoreAction.NONE };
return { appState, commitToHistory: false };
}
return {
@@ -29,7 +28,7 @@ export const actionGoToCollaborator = register({
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, data }) => {
+13 -18
View File
@@ -92,7 +92,6 @@ 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;
@@ -223,7 +222,7 @@ const changeFontSize = (
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
};
@@ -252,9 +251,7 @@ export const actionChangeStrokeColor = register({
...appState,
...value,
},
storeAction: !!value.currentItemStrokeColor
? StoreAction.CAPTURE
: StoreAction.NONE,
commitToHistory: !!value.currentItemStrokeColor,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@@ -297,9 +294,7 @@ export const actionChangeBackgroundColor = register({
...appState,
...value,
},
storeAction: !!value.currentItemBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
commitToHistory: !!value.currentItemBackgroundColor,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@@ -342,7 +337,7 @@ export const actionChangeFillStyle = register({
}),
),
appState: { ...appState, currentItemFillStyle: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -414,7 +409,7 @@ export const actionChangeStrokeWidth = register({
}),
),
appState: { ...appState, currentItemStrokeWidth: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -468,7 +463,7 @@ export const actionChangeSloppiness = register({
}),
),
appState: { ...appState, currentItemRoughness: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -518,7 +513,7 @@ export const actionChangeStrokeStyle = register({
}),
),
appState: { ...appState, currentItemStrokeStyle: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -572,7 +567,7 @@ export const actionChangeOpacity = register({
true,
),
appState: { ...appState, currentItemOpacity: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -730,7 +725,7 @@ export const actionChangeFontFamily = register({
...appState,
currentItemFontFamily: value,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -819,7 +814,7 @@ export const actionChangeTextAlign = register({
...appState,
currentItemTextAlign: value,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -899,7 +894,7 @@ export const actionChangeVerticalAlign = register({
appState: {
...appState,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -972,7 +967,7 @@ export const actionChangeRoundness = register({
...appState,
currentItemRoundness: value,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -1052,7 +1047,7 @@ export const actionChangeArrowhead = register({
? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
+1 -2
View File
@@ -6,7 +6,6 @@ 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",
@@ -47,7 +46,7 @@ export const actionSelectAll = register({
? new LinearElementEditor(elements[0], app.scene)
: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.selectAll",
+3 -4
View File
@@ -25,7 +25,6 @@ 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 = "{}";
@@ -49,7 +48,7 @@ export const actionCopyStyles = register({
...appState,
toast: { message: t("toast.copyStyles") },
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.copyStyles",
@@ -65,7 +64,7 @@ export const actionPasteStyles = register({
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) {
return { elements, storeAction: StoreAction.NONE };
return { elements, commitToHistory: false };
}
const selectedElements = getSelectedElements(elements, appState, {
@@ -150,7 +149,7 @@ export const actionPasteStyles = register({
}
return element;
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.pasteStyles",
+1 -2
View File
@@ -2,7 +2,6 @@ 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",
@@ -18,7 +17,7 @@ export const actionToggleGridMode = register({
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
+1 -2
View File
@@ -1,6 +1,5 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode",
@@ -16,7 +15,7 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,
+1 -2
View File
@@ -1,6 +1,5 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "./types";
export const actionToggleStats = register({
name: "stats",
@@ -12,7 +11,7 @@ export const actionToggleStats = register({
...appState,
showStats: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.showStats,
+1 -2
View File
@@ -1,6 +1,5 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleViewMode = register({
name: "viewMode",
@@ -15,7 +14,7 @@ export const actionToggleViewMode = register({
...appState,
viewModeEnabled: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.viewModeEnabled,
+1 -2
View File
@@ -1,6 +1,5 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleZenMode = register({
name: "zenMode",
@@ -15,7 +14,7 @@ export const actionToggleZenMode = register({
...appState,
zenModeEnabled: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.zenModeEnabled,
+5 -5
View File
@@ -1,3 +1,4 @@
import React from "react";
import {
moveOneLeft,
moveOneRight,
@@ -15,7 +16,6 @@ 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,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.sendBackward",
@@ -52,7 +52,7 @@ export const actionBringForward = register({
return {
elements: moveOneRight(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.bringForward",
@@ -80,7 +80,7 @@ export const actionSendToBack = register({
return {
elements: moveAllLeft(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.sendToBack",
@@ -116,7 +116,7 @@ export const actionBringToFront = register({
return {
elements: moveAllRight(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.bringToFront",
+2 -7
View File
@@ -10,12 +10,6 @@ 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 =
| {
@@ -25,7 +19,8 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
storeAction: StoreAction;
commitToHistory: boolean;
syncHistory?: boolean;
replaceFiles?: boolean;
}
| false;
+15 -7
View File
@@ -1,3 +1,7 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
export const trackEvent = (
category: string,
action: string,
@@ -5,13 +9,13 @@ export const trackEvent = (
value?: number,
) => {
try {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
// prettier-ignore
if (
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
) {
return;
}
@@ -19,6 +23,10 @@ export const trackEvent = (
return;
}
if (!import.meta.env.PROD) {
console.info("trackEvent", { category, action, label, value });
}
if (window.sa_event) {
window.sa_event(action, {
category,
-567
View File
@@ -1,567 +0,0 @@
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;
}
}
-1
View File
@@ -12,7 +12,6 @@
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;
+9 -3
View File
@@ -339,7 +339,7 @@ export const ShapesSwitcher = ({
Generate
</div>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")}
onSelect={() => app.setOpenDialog({ name: "mermaid" })}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
@@ -349,14 +349,20 @@ export const ShapesSwitcher = ({
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicButtonSelect()}
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("magicSettings")}
onSelect={() => {
trackEvent("ai", "d2c-settings", "settings");
app.setOpenDialog({
name: "magicSettings",
source: "settings",
});
}}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
+166 -193
View File
@@ -40,7 +40,7 @@ import {
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { Action, ActionResult, StoreAction } from "../actions/types";
import { Action, ActionResult } 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,8 +268,6 @@ import {
muteFSAbortError,
isTestEnv,
easeOut,
isShallowEqual,
arrayToMap,
} from "../utils";
import {
createSrcDoc,
@@ -400,8 +398,6 @@ 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!);
@@ -516,7 +512,6 @@ 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;
@@ -600,9 +595,6 @@ 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,
@@ -610,10 +602,6 @@ 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,
},
@@ -653,14 +641,8 @@ class App extends React.Component<AppProps, AppState> {
onSceneUpdated: this.onSceneUpdated,
});
this.history = new History();
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));
}
@@ -1056,6 +1038,10 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeEmbeddable?.element === el &&
this.state.activeEmbeddable?.state === "hover";
// Modify the scale based on el.scale property
const [xScale, yScale] = el.scale ?? [1, 1];
const scaledTransform = `scale(${scale * xScale}, ${scale * yScale})`;
return (
<div
key={el.id}
@@ -1066,14 +1052,13 @@ class App extends React.Component<AppProps, AppState> {
transform: isVisible
? `translate(${x - this.state.offsetLeft}px, ${
y - this.state.offsetTop
}px) scale(${scale})`
}px) ${scaledTransform}`
: "none",
display: isVisible ? "block" : "none",
opacity: el.opacity / 100,
["--embeddable-radius" as string]: `${getCornerRadius(
Math.min(el.width, el.height),
el,
)}px`,
["--embeddable-radius" as string]: `${
getCornerRadius(Math.min(el.width, el.height), el) / xScale
}px`,
}}
>
<div
@@ -1095,8 +1080,8 @@ class App extends React.Component<AppProps, AppState> {
}}*/
className="excalidraw__embeddable-container__inner"
style={{
width: isVisible ? `${el.width}px` : 0,
height: isVisible ? `${el.height}px` : 0,
width: isVisible ? `${el.width / xScale}px` : 0,
height: isVisible ? `${el.height / yScale}px` : 0,
transform: isVisible ? `rotate(${el.angle}rad)` : "none",
pointerEvents: isActive
? POINTER_EVENTS.enabled
@@ -1111,7 +1096,7 @@ class App extends React.Component<AppProps, AppState> {
<div
className="excalidraw__embeddable__outer"
style={{
padding: `${el.strokeWidth}px`,
padding: `${el.strokeWidth / el.scale[0]}px`,
}}
>
{(isEmbeddableElement(el)
@@ -1453,7 +1438,7 @@ class App extends React.Component<AppProps, AppState> {
onMagicSettingsConfirm={this.onMagicSettingsConfirm}
>
{this.props.children}
{this.state.openDialog === "mermaid" && (
{this.state.openDialog?.name === "mermaid" && (
<MermaidToExcalidraw />
)}
</LayerUI>
@@ -1485,6 +1470,7 @@ class App extends React.Component<AppProps, AppState> {
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
"button",
)
}
/>
@@ -1715,11 +1701,15 @@ class App extends React.Component<AppProps, AppState> {
return text;
}
private async onMagicFrameGenerate(magicFrame: ExcalidrawMagicFrameElement) {
private async onMagicFrameGenerate(
magicFrame: ExcalidrawMagicFrameElement,
source: "button" | "upstream",
) {
if (!this.OPENAI_KEY) {
this.setState({
openDialog: "magicSettings",
openDialog: { name: "magicSettings", source: "generation" },
});
trackEvent("ai", "d2c-generate", "missing-key");
return;
}
@@ -1730,7 +1720,12 @@ class App extends React.Component<AppProps, AppState> {
}).filter((el) => !isMagicFrameElement(el));
if (!magicFrameChildren.length) {
this.setState({ errorMessage: "Cannot generate from an empty frame" });
if (source === "button") {
this.setState({ errorMessage: "Cannot generate from an empty frame" });
trackEvent("ai", "d2c-generate", "no-children");
} else {
this.setActiveTool({ type: "magicframe" });
}
return;
}
@@ -1769,6 +1764,8 @@ class App extends React.Component<AppProps, AppState> {
const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
trackEvent("ai", "d2c-generate", "generating");
const result = await diagramToHTML({
image: dataURL,
apiKey: this.OPENAI_KEY,
@@ -1777,6 +1774,7 @@ class App extends React.Component<AppProps, AppState> {
});
if (!result.ok) {
trackEvent("ai", "d2c-generate", "generating-failed");
console.error(result.error);
this.updateMagicGeneration({
frameElement,
@@ -1788,6 +1786,7 @@ class App extends React.Component<AppProps, AppState> {
});
return;
}
trackEvent("ai", "d2c-generate", "generating-done");
if (result.choices[0].message.content == null) {
this.updateMagicGeneration({
@@ -1831,7 +1830,10 @@ class App extends React.Component<AppProps, AppState> {
private OPENAI_KEY_IS_PERSISTED: boolean =
EditorLocalStorage.has(EDITOR_LS_KEYS.OAI_API_KEY) || false;
private onOpenAIKeyChange = (openAIKey: string, shouldPersist: boolean) => {
private onOpenAIKeyChange = (
openAIKey: string | null,
shouldPersist: boolean,
) => {
this.OPENAI_KEY = openAIKey || null;
if (shouldPersist) {
const didPersist = EditorLocalStorage.set(
@@ -1844,26 +1846,41 @@ class App extends React.Component<AppProps, AppState> {
}
};
private onMagicSettingsConfirm = (apiKey: string, shouldPersist: boolean) => {
this.onOpenAIKeyChange(apiKey, shouldPersist);
private onMagicSettingsConfirm = (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => {
this.OPENAI_KEY = apiKey || null;
this.onOpenAIKeyChange(this.OPENAI_KEY, shouldPersist);
if (source === "settings") {
return;
}
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
});
if (apiKey) {
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
});
if (selectedElements.length) {
this.onMagicButtonSelect();
this.onMagicframeToolSelect();
} else {
this.setActiveTool({ type: "magicframe" });
}
} else {
this.OPENAI_KEY = null;
} else if (!isMagicFrameElement(selectedElements[0])) {
// even if user didn't end up setting api key, let's pick the tool
// so they can draw up a frame and move forward
this.setActiveTool({ type: "magicframe" });
}
};
public onMagicButtonSelect = () => {
public onMagicframeToolSelect = () => {
if (!this.OPENAI_KEY) {
this.setState({
openDialog: "magicSettings",
openDialog: { name: "magicSettings", source: "tool" },
});
trackEvent("ai", "d2c-tool", "missing-key");
return;
}
@@ -1873,19 +1890,33 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 0) {
this.setActiveTool({ type: TOOL_TYPE.magicframe });
trackEvent("ai", "d2c-tool", "empty-selection");
} else {
if (selectedElements.some((el) => isFrameLikeElement(el))) {
const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
selectedElements.length === 1 &&
isMagicFrameElement(selectedElements[0]) &&
selectedElements[0];
// case: user selected elements containing frame-like(s) or are frame
// members, we don't want to wrap into another magicframe
// (unless the only selected element is a magic frame which we reuse)
if (
!selectedMagicFrame &&
selectedElements.some((el) => isFrameLikeElement(el) || el.frameId)
) {
this.setActiveTool({ type: TOOL_TYPE.magicframe });
return;
}
let frame: ExcalidrawMagicFrameElement | null = null;
if (
selectedElements.length === 1 &&
isMagicFrameElement(selectedElements[0])
) {
frame = selectedElements[0];
trackEvent("ai", "d2c-tool", "existing-selection");
let frame: ExcalidrawMagicFrameElement;
if (selectedMagicFrame) {
// a single magicframe already selected -> use it
frame = selectedMagicFrame;
} else {
// selected elements aren't wrapped in magic frame yet -> wrap now
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
const padding = 50;
@@ -1898,19 +1929,19 @@ class App extends React.Component<AppProps, AppState> {
opacity: 100,
locked: false,
});
this.scene.addNewElement(frame);
for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id });
}
this.setState({
selectedElementIds: { [frame.id]: true },
});
}
this.scene.addNewElement(frame);
for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id });
}
this.setState({
selectedElementIds: { [frame.id]: true },
});
this.onMagicFrameGenerate(frame);
this.onMagicFrameGenerate(frame, "upstream");
}
};
@@ -1931,16 +1962,15 @@ class App extends React.Component<AppProps, AppState> {
if (shouldUpdateStrokeColor) {
this.syncActionResult({
appState: { ...this.state, currentItemStrokeColor: color },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
});
} else {
this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
});
}
} 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]) {
@@ -1976,11 +2006,8 @@ class App extends React.Component<AppProps, AppState> {
}
});
this.scene.replaceAllElements(actionResult.elements);
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.scheduleSnapshotUpdate();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.resumeCapturing();
if (actionResult.commitToHistory) {
this.history.resumeRecording();
}
}
@@ -1992,10 +2019,8 @@ class App extends React.Component<AppProps, AppState> {
}
if (actionResult.appState || editingElement || this.state.contextMenu) {
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.scheduleSnapshotUpdate();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.resumeCapturing();
if (actionResult.commitToHistory) {
this.history.resumeRecording();
}
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
@@ -2029,24 +2054,34 @@ 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,
});
});
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(),
);
}
},
);
}
},
);
@@ -2070,10 +2105,6 @@ 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 !
@@ -2086,7 +2117,6 @@ class App extends React.Component<AppProps, AppState> {
isLoading: opts?.resetLoadingState ? false : state.isLoading,
theme: this.state.theme,
}));
this.resetStore();
this.resetHistory();
},
);
@@ -2171,11 +2201,10 @@ 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,
storeAction: StoreAction.UPDATE, // TODO_UNDO: double-check for regression
commitToHistory: true,
});
};
@@ -2265,17 +2294,9 @@ 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();
@@ -2331,7 +2352,6 @@ 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);
@@ -2581,7 +2601,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the storeAction flag isn't reset via current update
// defer so that the commitToHistory 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
@@ -2625,11 +2645,7 @@ class App extends React.Component<AppProps, AppState> {
),
);
}
this.store.capture(
arrayToMap(this.scene.getElementsIncludingDeleted()),
this.state,
);
this.history.record(this.state, this.scene.getElementsIncludingDeleted());
// 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
@@ -2954,7 +2970,7 @@ class App extends React.Component<AppProps, AppState> {
...newElements,
];
this.scene.replaceAllElements(nextElements, arrayToMap(newElements));
this.scene.replaceAllElements(nextElements);
newElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
@@ -2967,7 +2983,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...opts.files };
}
this.store.resumeCapturing();
this.history.resumeRecording();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
@@ -3180,10 +3196,10 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementIndex(frameId),
);
} else {
this.scene.replaceAllElements(
[...this.scene.getElementsIncludingDeleted(), ...textElements],
arrayToMap(textElements),
);
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
...textElements,
]);
}
this.setState({
@@ -3208,7 +3224,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true;
}
this.store.resumeCapturing();
this.history.resumeRecording();
}
setAppState: React.Component<any, AppState>["setState"] = (
@@ -3469,33 +3485,10 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"];
commitToStore?: SceneData["commitToStore"];
skipSnapshotUpdate?: SceneData["skipSnapshotUpdate"];
commitToHistory?: SceneData["commitToHistory"];
}) => {
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.commitToHistory) {
this.history.resumeRecording();
}
if (sceneData.appState) {
@@ -3607,7 +3600,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.QUESTION_MARK) {
this.setState({
openDialog: "help",
openDialog: { name: "help" },
});
return;
} else if (
@@ -3616,7 +3609,7 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD]
) {
event.preventDefault();
this.setState({ openDialog: "imageExport" });
this.setState({ openDialog: { name: "imageExport" } });
return;
}
@@ -3704,7 +3697,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !==
selectedElements[0].id
) {
this.store.resumeCapturing();
this.history.resumeRecording();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElement,
@@ -4096,7 +4089,7 @@ class App extends React.Component<AppProps, AppState> {
]);
}
if (!isDeleted || isExistingElement) {
this.store.resumeCapturing();
this.history.resumeRecording();
}
this.setState({
@@ -4388,7 +4381,7 @@ class App extends React.Component<AppProps, AppState> {
(!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
) {
this.store.resumeCapturing();
this.history.resumeRecording();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElements[0],
@@ -5208,7 +5201,6 @@ class App extends React.Component<AppProps, AppState> {
this.state,
),
},
skipSnapshotUpdate: true, // TODO_UNDO: test if we didn't regress here
});
return;
}
@@ -5809,7 +5801,7 @@ class App extends React.Component<AppProps, AppState> {
const ret = LinearElementEditor.handlePointerDown(
event,
this.state,
this.store,
this.history,
pointerDownState.origin,
linearElementEditor,
);
@@ -6194,10 +6186,10 @@ class App extends React.Component<AppProps, AppState> {
height,
});
this.scene.replaceAllElements(
[...this.scene.getElementsIncludingDeleted(), element],
arrayToMap([element]),
);
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
element,
]);
return element;
};
@@ -6249,10 +6241,10 @@ class App extends React.Component<AppProps, AppState> {
validated: null,
});
this.scene.replaceAllElements(
[...this.scene.getElementsIncludingDeleted(), element],
arrayToMap([element]),
);
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
element,
]);
return element;
};
@@ -6530,10 +6522,10 @@ class App extends React.Component<AppProps, AppState> {
? newMagicFrameElement(constructorOpts)
: newFrameElement(constructorOpts);
this.scene.replaceAllElements(
[...this.scene.getElementsIncludingDeleted(), frame],
arrayToMap([frame]),
);
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
frame,
]);
this.setState({
multiElement: null,
@@ -6901,7 +6893,6 @@ class App extends React.Component<AppProps, AppState> {
})
.map((element) => element.id),
);
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const elements = this.scene.getElementsIncludingDeleted();
@@ -6918,10 +6909,6 @@ class App extends React.Component<AppProps, AppState> {
groupIdMap,
element,
);
duplicatedElementsMap.set(
duplicatedElement.id,
duplicatedElement,
);
const origElement = pointerDownState.originalElements.get(
element.id,
)!;
@@ -6944,10 +6931,7 @@ class App extends React.Component<AppProps, AppState> {
nextElements.push(element);
}
}
const nextSceneElements = fixFractionalIndices(
[...nextElements, ...elementsToAppend],
duplicatedElementsMap,
);
const nextSceneElements = [...nextElements, ...elementsToAppend];
bindTextToShapeAfterDuplication(
nextElements,
elementsToAppend,
@@ -7362,7 +7346,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(draggingElement)) {
if (draggingElement!.points.length > 1) {
this.store.resumeCapturing();
this.history.resumeRecording();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
@@ -7597,7 +7581,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (resizingElement) {
this.store.resumeCapturing();
this.history.resumeRecording();
}
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@@ -7898,13 +7882,9 @@ class App extends React.Component<AppProps, AppState> {
if (
activeTool.type !== "selection" ||
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
!isShallowEqual(
this.state.previousSelectedElementIds,
this.state.selectedElementIds,
)
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state)
) {
this.store.resumeCapturing();
this.history.resumeRecording();
}
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
@@ -8010,7 +7990,7 @@ class App extends React.Component<AppProps, AppState> {
return ele;
});
this.store.resumeCapturing();
this.history.resumeRecording();
this.scene.replaceAllElements(elements);
};
@@ -8558,7 +8538,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
});
return;
} catch (error: any) {
@@ -8644,21 +8624,15 @@ 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,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
});
} else if (ret.type === MIME_TYPES.excalidrawlib) {
await this.library
@@ -9274,7 +9248,6 @@ declare global {
setState: React.Component<any, AppState>["setState"];
app: InstanceType<typeof App>;
history: History;
store: Store;
};
}
}
+15
View File
@@ -0,0 +1,15 @@
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
return (
<span
style={{
width: "1em",
margin: "0 0.5ex 0 0.5ex",
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
}}
>
{icon}
</span>
);
};
+1 -1
View File
@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
return (
<>
{appState.openDialog === "jsonExport" && (
{appState.openDialog?.name === "jsonExport" && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}
+15 -6
View File
@@ -86,7 +86,11 @@ interface LayerUIProps {
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => void;
}
const DefaultMainMenu: React.FC<{
@@ -177,7 +181,7 @@ const LayerUI = ({
const renderImageExportDialog = () => {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog !== "imageExport"
appState.openDialog?.name !== "imageExport"
) {
return null;
}
@@ -448,21 +452,26 @@ const LayerUI = ({
}}
/>
)}
{appState.openDialog === "help" && (
{appState.openDialog?.name === "help" && (
<HelpDialog
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
{appState.openDialog === "magicSettings" && (
{appState.openDialog?.name === "magicSettings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
setAppState({ openDialog: null });
onMagicSettingsConfirm(apiKey, shouldPersist);
const source =
appState.openDialog?.name === "magicSettings"
? appState.openDialog?.source
: "settings";
setAppState({ openDialog: null }, () => {
onMagicSettingsConfirm(apiKey, shouldPersist, source);
});
}}
onClose={() => {
setAppState({ openDialog: null });
+17 -32
View File
@@ -2,28 +2,14 @@ import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import "./MagicSettings.scss";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph";
const InlineButton = ({ icon }: { icon: JSX.Element }) => {
return (
<span
style={{
width: "1em",
margin: "0 0.5ex 0 0.5ex",
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
}}
>
{icon}
</span>
);
};
import "./MagicSettings.scss";
export const MagicSettings = (props: {
openAIKey: string | null;
@@ -70,18 +56,17 @@ export const MagicSettings = (props: {
className="MagicSettings"
autofocus={false}
>
<p
<Paragraph
style={{
display: "inline-flex",
alignItems: "center",
marginBottom: 0,
}}
>
For the diagram-to-code feature we use{" "}
<InlineButton icon={OpenAIIcon} />
For the diagram-to-code feature we use <InlineIcon icon={OpenAIIcon} />
OpenAI.
</p>
<p>
</Paragraph>
<Paragraph>
While the OpenAI API is in beta, its use is strictly limited — as such
we require you use your own API key. You can create an{" "}
<a
@@ -100,13 +85,13 @@ export const MagicSettings = (props: {
generate your own API key
</a>
.
</p>
<p>
</Paragraph>
<Paragraph>
Your OpenAI key does not leave the browser, and you can also set your
own limit in your OpenAI account dashboard if needed.
</p>
</Paragraph>
<TextField
isPassword
isRedacted
value={keyInputValue}
placeholder="Paste your API key here"
label="OpenAI API key"
@@ -117,22 +102,22 @@ export const MagicSettings = (props: {
selectOnRender
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/>
<p>
<Paragraph>
By default, your API token is not persisted anywhere so you'll need to
insert it again after reload. But, you can persist locally in your
browser below.
</p>
</Paragraph>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<p>
Once API key is set, you can use the <InlineButton icon={MagicIcon} />{" "}
<Paragraph>
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to turn
it into code. This dialog can be accessed using the <b>AI Settings</b>{" "}
<InlineButton icon={OpenAIIcon} />.
</p>
<InlineIcon icon={OpenAIIcon} />.
</Paragraph>
<FilledButton
className="MagicSettings__confirm"
@@ -47,7 +47,7 @@ export const ExportToImage = () => {
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: "imageExport" });
setAppState({ openDialog: { name: "imageExport" } });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}
+10
View File
@@ -0,0 +1,10 @@
export const Paragraph = (props: {
children: React.ReactNode;
style?: React.CSSProperties;
}) => {
return (
<p className="excalidraw__paragraph" style={props.style}>
{props.children}
</p>
);
};
+13 -8
View File
@@ -25,7 +25,7 @@ type TextFieldProps = {
label?: string;
placeholder?: string;
isPassword?: boolean;
isRedacted?: boolean;
};
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -39,7 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
readonly,
selectOnRender,
onKeyDown,
isPassword = false,
isRedacted = false,
},
ref,
) => {
@@ -53,7 +53,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
}
}, [selectOnRender]);
const [isVisible, setIsVisible] = useState<boolean>(true);
const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
useState<boolean>(false);
return (
<div
@@ -71,7 +72,9 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
})}
>
<input
type={isPassword && isVisible ? "password" : undefined}
className={clsx({
"is-redacted": value && isRedacted && !isTemporarilyUnredacted,
})}
readOnly={readonly}
value={value}
placeholder={placeholder}
@@ -79,12 +82,14 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
/>
{isPassword && (
{isRedacted && (
<Button
onSelect={() => setIsVisible(!isVisible)}
style={{ border: 0 }}
onSelect={() =>
setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
}
style={{ border: 0, userSelect: "none" }}
>
{isVisible ? eyeIcon : eyeClosedIcon}
{isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
</Button>
)}
</div>
+2 -7
View File
@@ -24,7 +24,6 @@ type ToolButtonBaseProps = {
hidden?: boolean;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
@@ -124,14 +123,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
disabled={isLoading || props.isLoading}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
<div className="ToolIcon__icon" aria-hidden="true">
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
+2 -18
View File
@@ -77,8 +77,8 @@
}
.ToolIcon_type_button,
.Modal .ToolIcon_type_button
{
.Modal .ToolIcon_type_button,
.ToolIcon_type_button {
padding: 0;
border: none;
margin: 0;
@@ -101,22 +101,6 @@
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;
}
+2 -2
View File
@@ -107,7 +107,7 @@ export const SaveAsImage = () => {
<DropdownMenuItem
icon={ExportImageIcon}
data-testid="image-export-button"
onSelect={() => setAppState({ openDialog: "imageExport" })}
onSelect={() => setAppState({ openDialog: { name: "imageExport" } })}
shortcut={getShortcutFromShortcutName("imageExport")}
aria-label={t("buttons.exportImage")}
>
@@ -230,7 +230,7 @@ export const Export = () => {
<DropdownMenuItem
icon={ExportIcon}
onSelect={() => {
setAppState({ openDialog: "jsonExport" });
setAppState({ openDialog: { name: "jsonExport" } });
}}
data-testid="json-export-button"
aria-label={t("buttons.export")}
+4 -1
View File
@@ -196,7 +196,6 @@ 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
@@ -303,6 +302,10 @@ 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,
+10
View File
@@ -533,6 +533,12 @@
}
}
input.is-redacted {
// we don't use type=password because browsers (chrome?) prompt
// you to save it which is annoying
-webkit-text-security: disc;
}
input[type="text"],
textarea:not(.excalidraw-wysiwyg) {
color: var(--text-primary-color);
@@ -728,4 +734,8 @@
letter-spacing: 0.6px;
font-family: "Assistant";
}
.excalidraw__paragraph {
margin: 1rem 0;
}
}
-2
View File
@@ -97,8 +97,6 @@
--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;
-9
View File
@@ -50,15 +50,6 @@
color: var(--color-on-primary-container);
}
}
&[aria-disabled="true"] {
background: initial;
border: none;
svg {
color: var(--color-disabled);
}
}
}
}
@@ -15,7 +15,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 300,
@@ -51,7 +50,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -88,7 +86,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"gap": 1,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 35,
@@ -142,7 +139,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"gap": 3.834326468444573,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -195,7 +191,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 300,
@@ -235,7 +230,6 @@ 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,
@@ -280,7 +274,6 @@ 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,
@@ -327,7 +320,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"gap": 205,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -379,7 +371,6 @@ 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,
@@ -426,7 +417,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"gap": 1,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -478,7 +468,6 @@ 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,
@@ -519,7 +508,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -555,7 +543,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -597,7 +584,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"gap": 1,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -649,7 +635,6 @@ 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,
@@ -694,7 +679,6 @@ 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,
@@ -739,7 +723,6 @@ 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,
@@ -775,7 +758,6 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 200,
@@ -808,7 +790,6 @@ exports[`Test Transform > should transform linear elements 1`] = `
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -854,7 +835,6 @@ exports[`Test Transform > should transform linear elements 2`] = `
"endArrowhead": "triangle",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -900,7 +880,6 @@ exports[`Test Transform > should transform linear elements 3`] = `
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -946,7 +925,6 @@ exports[`Test Transform > should transform linear elements 4`] = `
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -990,7 +968,6 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1021,7 +998,6 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1052,7 +1028,6 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1083,7 +1058,6 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"backgroundColor": "#c0eb75",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1114,7 +1088,6 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"backgroundColor": "#ffc9c9",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1145,7 +1118,6 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"backgroundColor": "#a5d8ff",
"boundElements": null,
"fillStyle": "cross-hatch",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1180,7 +1152,6 @@ exports[`Test Transform > should transform text element 1`] = `
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1220,7 +1191,6 @@ exports[`Test Transform > should transform text element 2`] = `
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1263,7 +1233,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -1314,7 +1283,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -1365,7 +1333,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -1416,7 +1383,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -1464,7 +1430,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1504,7 +1469,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1544,7 +1508,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 50,
@@ -1585,7 +1548,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 50,
@@ -1627,7 +1589,6 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 35,
@@ -1663,7 +1624,6 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 85,
@@ -1699,7 +1659,6 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 170,
@@ -1735,7 +1694,6 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 120,
@@ -1771,7 +1729,6 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 85,
@@ -1807,7 +1764,6 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 120,
@@ -1842,7 +1798,6 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1882,7 +1837,6 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 50,
@@ -1923,7 +1877,6 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 75,
@@ -1966,7 +1919,6 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 50,
@@ -2007,7 +1959,6 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 75,
@@ -2049,7 +2000,6 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 75,
+16 -6
View File
@@ -26,6 +26,7 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
PRECEDING_ELEMENT_KEY,
FONT_FAMILY,
ROUNDNESS,
DEFAULT_SIDEBAR,
@@ -43,7 +44,6 @@ import {
measureBaseline,
} from "../element/textElement";
import { normalizeLink } from "./url";
import { restoreFractionalIndicies } from "../fractionalIndex";
type RestoredAppState = Omit<
AppState,
@@ -101,6 +101,8 @@ 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>>,
>(
@@ -113,14 +115,14 @@ const restoreElementWithProperties = <
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> = {
const base: Pick<T, keyof ExcalidrawElement> & {
[PRECEDING_ELEMENT_KEY]?: string;
} = {
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,
@@ -164,6 +166,10 @@ 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),
@@ -288,11 +294,15 @@ const restoreElement = (
case "ellipse":
case "rectangle":
case "diamond":
case "iframe":
return restoreElementWithProperties(element, {});
case "iframe":
return restoreElementWithProperties(element, {
scale: element.scale ?? [1, 1],
});
case "embeddable":
return restoreElementWithProperties(element, {
validated: null,
scale: element.scale ?? [1, 1],
});
case "magicframe":
case "frame":
@@ -461,7 +471,7 @@ export const restoreElements = (
}
}
return restoreFractionalIndicies(restoredElements) as ExcalidrawElement[];
return restoredElements;
};
const coalesceAppStateValue = <
+1 -2
View File
@@ -40,7 +40,6 @@ 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;
@@ -344,7 +343,7 @@ export const actionLink = register({
showHyperlinkPopup: "editor",
openMenu: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
trackEvent: { category: "hyperlink", action: "click" },
+12 -4
View File
@@ -22,6 +22,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
export type RectangleBox = {
x: number;
@@ -59,10 +60,17 @@ export class ElementBounds {
const bounds = ElementBounds.calculateBounds(element);
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
// hack to ensure that downstream checks could retrieve element Scene
// so as to have correctly calculated bounds
// FIXME remove when we get rid of all the id:Scene / element:Scene mapping
const shouldCache = Scene.getScene(element);
if (shouldCache) {
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
}
return bounds;
}
+2 -4
View File
@@ -17,12 +17,11 @@ import {
IframeData,
NonDeletedExcalidrawElement,
} from "./types";
import { StoreAction } from "../actions/types";
const embeddedLinkCache = new Map<string, IframeData>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|.*&t=|\?start=|.*&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@@ -287,8 +286,7 @@ export const actionSetEmbeddableAsActiveTool = register({
type: "embeddable",
}),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
});
+3 -3
View File
@@ -33,6 +33,7 @@ import {
InteractiveCanvasAppState,
} from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
import Scene from "../scene/Scene";
import {
@@ -47,7 +48,6 @@ 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,
store: Store,
history: History,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
): {
@@ -654,7 +654,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
store.resumeCapturing();
history.resumeRecording();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {
+14 -17
View File
@@ -106,27 +106,24 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
forceUpdate: boolean = false,
): TElement => {
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;
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;
}
}
if (!didChange) {
return element;
}
if (!didChange) {
return element;
}
return {
+2 -4
View File
@@ -55,7 +55,6 @@ export type ElementConstructorOpts = MarkOptional<
| "angle"
| "groupIds"
| "frameId"
| "fractionalIndex"
| "boundElements"
| "seed"
| "version"
@@ -89,8 +88,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
angle = 0,
groupIds = [],
frameId = null,
// TODO: think about this more
fractionalIndex = null,
roundness = null,
boundElements = null,
link = null,
@@ -116,7 +113,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity,
groupIds,
frameId,
fractionalIndex,
roundness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
@@ -146,6 +142,7 @@ export const newEmbeddableElement = (
return {
..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
validated: opts.validated,
scale: [1, 1],
};
};
@@ -156,6 +153,7 @@ export const newIframeElement = (
): NonDeleted<ExcalidrawIframeElement> => {
return {
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
scale: [1, 1],
};
};
+26 -9
View File
@@ -27,6 +27,7 @@ import {
import {
isArrowElement,
isBoundToContainer,
isIframeLikeElement,
isFrameLikeElement,
isFreeDrawElement,
isImageElement,
@@ -586,15 +587,31 @@ export const resizeSingleElement = (
};
if ("scale" in element && "scale" in stateAtResizeStart) {
mutateElement(element, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(newBoundsX2 - stateAtResizeStart.x) ||
stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0],
(Math.sign(newBoundsY2 - stateAtResizeStart.y) ||
stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1],
],
});
if (isIframeLikeElement(element)) {
if (shouldMaintainAspectRatio) {
const scale: [number, number] = [
Math.abs(
eleNewWidth /
(stateAtResizeStart.width / stateAtResizeStart.scale[0]),
),
Math.abs(
eleNewHeight /
(stateAtResizeStart.height / stateAtResizeStart.scale[1]),
),
];
mutateElement(element, { scale });
}
} else {
mutateElement(element, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(newBoundsX2 - stateAtResizeStart.x) ||
stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0],
(Math.sign(newBoundsY2 - stateAtResizeStart.y) ||
stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1],
],
});
}
}
if (
+2 -1
View File
@@ -50,7 +50,6 @@ 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. */
@@ -97,6 +96,7 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
* may not have access to host-app supplied url validator during restore.
*/
validated: boolean | null;
scale: [number, number];
}>;
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
@@ -104,6 +104,7 @@ export type ExcalidrawIframeElement = _ExcalidrawElementBase &
type: "iframe";
// TODO move later to AI-specific frame
customData?: { generationData?: MagicCacheData };
scale: [number, number];
}>;
export type ExcalidrawIframeLikeElement =
-227
View File
@@ -1,227 +0,0 @@
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;
};
+218 -126
View File
@@ -1,173 +1,265 @@
import { AppStateChange, ElementsChange } from "./change";
import { ExcalidrawElement } from "./element/types";
import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types";
import { isLinearElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { Mutable } from "./utility-types";
// TODO_UNDO: think about limiting the depth of stack
export class History {
private readonly undoStack: HistoryEntry[] = [];
private readonly redoStack: HistoryEntry[] = [];
export interface HistoryEntry {
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
elements: ExcalidrawElement[];
}
public get isUndoStackEmpty() {
return this.undoStack.length === 0;
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 isRedoStackEmpty() {
return this.redoStack.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 clear() {
this.undoStack.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;
this.redoStack.length = 0;
this.lastEntry = null;
this.elementCache.clear();
}
/**
* Record a local change which will go into the history
*/
public record(
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) {
const entry = HistoryEntry.create(appStateChange, elementsChange);
if (!entry.isEmpty()) {
this.undoStack.push(entry);
// 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>,
private generateEntry = (
appState: AppState,
): [Map<string, ExcalidrawElement>, AppState] | void {
let historyEntry = action(elements);
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;
}
// Nothing to undo / redo
if (historyEntry === null) {
return;
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;
}
let nextElements = elements;
let nextAppState = appState;
let containsVisibleChange = false;
if (nextEntry.elements.length !== lastEntry.elements.length) {
return true;
}
// Iterate through the history entries in case they result in no visible changes
while (historyEntry) {
[nextElements, nextAppState, containsVisibleChange] =
historyEntry.applyTo(nextElements, nextAppState);
// 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;
}
}
// TODO_UNDO: Be very carefuly here, as we could accidentaly iterate through the whole stack
if (containsVisibleChange) {
break;
// 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;
}
historyEntry = action(elements);
this.stateHistory.push(newEntryDehydrated);
this.lastEntry = newEntry;
// As a new entry was pushed, we invalidate the redo stack
this.clearRedoStack();
}
return [nextElements, nextAppState];
}
private undoOnce(
elements: Map<string, ExcalidrawElement>,
): HistoryEntry | null {
if (!this.undoStack.length) {
clearRedoStack() {
this.redoStack.splice(0, this.redoStack.length);
}
redoOnce(): HistoryEntry | null {
if (this.redoStack.length === 0) {
return null;
}
const undoEntry = this.undoStack.pop();
const entryToRestore = this.redoStack.pop();
if (undoEntry !== undefined) {
const redoEntry = undoEntry.applyLatestChanges(elements, "to");
this.redoStack.push(redoEntry);
return undoEntry.inverse();
if (entryToRestore !== undefined) {
this.stateHistory.push(entryToRestore);
return this.hydrateHistoryEntry(entryToRestore);
}
return null;
}
private redoOnce(
elements: Map<string, ExcalidrawElement>,
): HistoryEntry | null {
if (!this.redoStack.length) {
undoOnce(): HistoryEntry | null {
if (this.stateHistory.length === 1) {
return null;
}
const redoEntry = this.redoStack.pop();
const currentEntry = this.stateHistory.pop();
if (redoEntry !== undefined) {
const undoEntry = redoEntry.applyLatestChanges(elements, "from");
this.undoStack.push(undoEntry);
const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
return redoEntry;
if (currentEntry !== undefined) {
this.redoStack.push(currentEntry);
return this.hydrateHistoryEntry(entryToRestore);
}
return null;
}
}
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`.
* 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.
*/
public applyLatestChanges(
elements: Map<string, ExcalidrawElement>,
modifierOptions: "from" | "to",
): HistoryEntry {
const updatedElementsChange = this.elementsChange.applyLatestChanges(
elements,
modifierOptions,
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
this.lastEntry = this.hydrateHistoryEntry(
this.generateEntry(appState, elements),
);
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
}
public isEmpty(): boolean {
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
// Suspicious that this is called so many places. Seems error-prone.
resumeRecording() {
this.recording = true;
}
record(state: AppState, elements: readonly ExcalidrawElement[]) {
if (this.recording) {
this.pushEntry(state, elements);
this.recording = false;
}
}
}
export default History;
+1 -6
View File
@@ -13,14 +13,9 @@ 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.
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
## 0.17.0 (2023-11-14)
+8 -4
View File
@@ -13,6 +13,7 @@ import {
isInitializedImageElement,
isArrowElement,
hasBoundTextElement,
isIframeLikeElement,
isMagicFrameElement,
} from "../element/typeChecks";
import { getElementAbsoluteCoords } from "../element/bounds";
@@ -520,7 +521,8 @@ const drawElementFromCanvas = (
if (
"scale" in elementWithCanvas.element &&
!isPendingImageElement(element, renderConfig)
!isPendingImageElement(element, renderConfig) &&
!isIframeLikeElement(element)
) {
context.scale(
elementWithCanvas.element.scale[0],
@@ -996,6 +998,8 @@ export const renderElementToSvg = (
renderConfig,
);
const scaleX = element.scale?.[0] || 1;
const scaleY = element.scale?.[1] || 1;
// render embeddable element + iframe
const embeddableNode = roughSVGDrawWithPrecision(
rsvg,
@@ -1007,7 +1011,7 @@ export const renderElementToSvg = (
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
}) rotate(${degree} ${cx} ${cy}) scale(${scaleX}, ${scaleY})`,
);
while (embeddableNode.firstChild) {
embeddableNode.removeChild(embeddableNode.firstChild);
@@ -1038,8 +1042,8 @@ export const renderElementToSvg = (
SVG_NS,
"foreignObject",
);
foreignObject.style.width = `${element.width}px`;
foreignObject.style.height = `${element.height}px`;
foreignObject.style.width = `${element.width / scaleX}px`;
foreignObject.style.height = `${element.height / scaleY}px`;
foreignObject.style.border = "none";
const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
+6 -31
View File
@@ -11,11 +11,6 @@ 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;
@@ -127,10 +122,6 @@ class Scene {
return this.elements;
}
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] {
return this.nonDeletedElements;
}
@@ -238,25 +229,12 @@ class Scene {
replaceAllElements(
nextElements: readonly ExcalidrawElement[],
mapOfIndicesToFix?: Map<string, ExcalidrawElement>,
mapElementIds = true,
) {
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;
this.elements = nextElements;
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
this.elementsMap.clear();
_nextElements.forEach((element) => {
nextElements.forEach((element) => {
if (isFrameLikeElement(element)) {
nextFrameLikes.push(element);
}
@@ -325,7 +303,7 @@ class Scene {
element,
...this.elements.slice(index),
];
this.replaceAllElements(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
@@ -340,17 +318,14 @@ class Scene {
...this.elements.slice(index),
];
this.replaceAllElements(nextElements, arrayToMap(elements));
this.replaceAllElements(nextElements);
}
addNewElement = (element: ExcalidrawElement) => {
if (element.frameId) {
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
} else {
this.replaceAllElements(
[...this.elements, element],
arrayToMap([element]),
);
this.replaceAllElements([...this.elements, element]);
}
};
+1 -1
View File
@@ -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));
scene.replaceAllElements(cloneJSON(elements), false);
return scene;
};
-307
View File
@@ -1,307 +0,0 @@
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;
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
<Excalidraw
initialData={{
appState: {
openDialog: "mermaid",
openDialog: { name: "mermaid" },
},
}}
/>,
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,6 @@ 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,
@@ -42,8 +41,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 30,
"y": 20,
@@ -58,7 +57,6 @@ 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,
@@ -77,8 +75,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2,
"type": "diamond",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"version": 2,
"versionNonce": 453191,
"width": 30,
"x": 30,
"y": 20,
@@ -93,7 +91,6 @@ 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,
@@ -112,8 +109,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2,
"type": "ellipse",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"version": 2,
"versionNonce": 453191,
"width": 30,
"x": 30,
"y": 20,
@@ -128,7 +125,6 @@ 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,
@@ -160,8 +156,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2,
"type": "line",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 30,
"y": 20,
@@ -176,7 +172,6 @@ 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,
@@ -195,8 +190,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"version": 2,
"versionNonce": 453191,
"width": 30,
"x": 30,
"y": 20,
+44 -50
View File
@@ -6,7 +6,6 @@ exports[`duplicate element on move when ALT is clicked > rectangle 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": "Zz",
"frameId": null,
"groupIds": [],
"height": 50,
@@ -19,14 +18,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 1`] = `
"roundness": {
"type": 3,
},
"seed": 238820263,
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 6,
"versionNonce": 1604849351,
"version": 4,
"versionNonce": 238820263,
"width": 30,
"x": 30,
"y": 20,
@@ -39,40 +38,6 @@ 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,
@@ -92,7 +57,39 @@ exports[`move element > rectangle 1`] = `
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1116226695,
"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,
"width": 30,
"x": 0,
"y": 40,
@@ -110,7 +107,6 @@ exports[`move element > rectangles with binding arrow 1`] = `
},
],
"fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null,
"groupIds": [],
"height": 100,
@@ -129,8 +125,8 @@ exports[`move element > rectangles with binding arrow 1`] = `
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 760410951,
"version": 3,
"versionNonce": 81784553,
"width": 100,
"x": 0,
"y": 0,
@@ -148,7 +144,6 @@ exports[`move element > rectangles with binding arrow 2`] = `
},
],
"fillStyle": "solid",
"fractionalIndex": "a1",
"frameId": null,
"groupIds": [],
"height": 300,
@@ -161,14 +156,14 @@ exports[`move element > rectangles with binding arrow 2`] = `
"roundness": {
"type": 3,
},
"seed": 1150084233,
"seed": 2019559783,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 7,
"versionNonce": 745419401,
"version": 6,
"versionNonce": 927333447,
"width": 300,
"x": 201,
"y": 2,
@@ -187,7 +182,6 @@ exports[`move element > rectangles with binding arrow 3`] = `
"gap": 10,
},
"fillStyle": "solid",
"fractionalIndex": "a2",
"frameId": null,
"groupIds": [],
"height": 81.48231043525051,
@@ -211,7 +205,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
"roundness": {
"type": 2,
},
"seed": 1604849351,
"seed": 238820263,
"startArrowhead": null,
"startBinding": {
"elementId": "id0",
@@ -223,8 +217,8 @@ exports[`move element > rectangles with binding arrow 3`] = `
"strokeWidth": 2,
"type": "line",
"updated": 1,
"version": 12,
"versionNonce": 1984422985,
"version": 11,
"versionNonce": 1051383431,
"width": 81,
"x": 110,
"y": 49.981789081137734,
@@ -8,7 +8,6 @@ exports[`multi point mode in linear elements > arrow 1`] = `
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null,
"groupIds": [],
"height": 110,
@@ -47,8 +46,8 @@ exports[`multi point mode in linear elements > arrow 1`] = `
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 8,
"versionNonce": 23633383,
"version": 7,
"versionNonce": 1505387817,
"width": 70,
"x": 30,
"y": 30,
@@ -63,7 +62,6 @@ exports[`multi point mode in linear elements > line 1`] = `
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null,
"groupIds": [],
"height": 110,
@@ -102,8 +100,8 @@ exports[`multi point mode in linear elements > line 1`] = `
"strokeWidth": 2,
"type": "line",
"updated": 1,
"version": 8,
"versionNonce": 23633383,
"version": 7,
"versionNonce": 1505387817,
"width": 70,
"x": 30,
"y": 30,
File diff suppressed because it is too large Load Diff
+10 -15
View File
@@ -8,7 +8,6 @@ exports[`select single element on the scene > arrow 1`] = `
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null,
"groupIds": [],
"height": 50,
@@ -40,8 +39,8 @@ exports[`select single element on the scene > arrow 1`] = `
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@@ -56,7 +55,6 @@ exports[`select single element on the scene > arrow escape 1`] = `
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null,
"groupIds": [],
"height": 50,
@@ -88,8 +86,8 @@ exports[`select single element on the scene > arrow escape 1`] = `
"strokeWidth": 2,
"type": "line",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@@ -102,7 +100,6 @@ exports[`select single element on the scene > diamond 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null,
"groupIds": [],
"height": 50,
@@ -121,8 +118,8 @@ exports[`select single element on the scene > diamond 1`] = `
"strokeWidth": 2,
"type": "diamond",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"version": 2,
"versionNonce": 453191,
"width": 30,
"x": 10,
"y": 10,
@@ -135,7 +132,6 @@ exports[`select single element on the scene > ellipse 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null,
"groupIds": [],
"height": 50,
@@ -154,8 +150,8 @@ exports[`select single element on the scene > ellipse 1`] = `
"strokeWidth": 2,
"type": "ellipse",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"version": 2,
"versionNonce": 453191,
"width": 30,
"x": 10,
"y": 10,
@@ -168,7 +164,6 @@ exports[`select single element on the scene > rectangle 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null,
"groupIds": [],
"height": 50,
@@ -187,8 +182,8 @@ exports[`select single element on the scene > rectangle 1`] = `
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"version": 2,
"versionNonce": 453191,
"width": 30,
"x": 10,
"y": 10,
+3 -21
View File
@@ -27,7 +27,7 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
@@ -423,26 +423,8 @@ 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,
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];
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];
expect(rect1).toEqual(rect2);
});
@@ -8,7 +8,6 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -54,7 +53,6 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"backgroundColor": "blue",
"boundElements": [],
"fillStyle": "cross-hatch",
"fractionalIndex": null,
"frameId": null,
"groupIds": [
"1",
@@ -91,7 +89,6 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"backgroundColor": "blue",
"boundElements": [],
"fillStyle": "cross-hatch",
"fractionalIndex": null,
"frameId": null,
"groupIds": [
"1",
@@ -128,7 +125,6 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"backgroundColor": "blue",
"boundElements": [],
"fillStyle": "cross-hatch",
"fractionalIndex": null,
"frameId": null,
"groupIds": [
"1",
@@ -165,7 +161,6 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
"backgroundColor": "transparent",
"boundElements": [],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -204,7 +199,6 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -252,7 +246,6 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -302,7 +295,6 @@ exports[`restoreElements > should restore text element correctly passing value f
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 14,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -344,7 +336,6 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 10,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
+1 -1
View File
@@ -17,7 +17,6 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
groupIds: [],
frameId: null,
roundness: null,
fractionalIndex: null,
seed: 1041657908,
version: 120,
versionNonce: 1188004276,
@@ -36,6 +35,7 @@ export const embeddableFixture: ExcalidrawElement = {
...elementBase,
type: "embeddable",
validated: null,
scale: [1, 1],
};
export const ellipseFixture: ExcalidrawElement = {
...elementBase,
-329
View File
@@ -1,329 +0,0 @@
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);
});
});
+2 -9
View File
@@ -66,14 +66,9 @@ export class API {
return selectedElements[0];
};
static getUndoStack = () => {
static getStateHistory = () => {
// @ts-ignore
return h.history.undoStack;
};
static getRedoStack = () => {
// @ts-ignore
return h.history.redoStack;
return h.history.stateHistory;
};
static clearSelection = () => {
@@ -105,7 +100,6 @@ export class API {
id?: string;
isDeleted?: boolean;
frameId?: ExcalidrawElement["id"] | null;
fractionalIndex?: ExcalidrawElement["fractionalIndex"];
groupIds?: string[];
// generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"];
@@ -173,7 +167,6 @@ export class API {
x,
y,
frameId: rest.frameId ?? null,
fractionalIndex: rest.fractionalIndex || null,
angle: rest.angle ?? 0,
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
backgroundColor:
-12
View File
@@ -108,18 +108,6 @@ 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 => {
+10 -667
View File
@@ -1,4 +1,4 @@
import { assertSelectedElements, render, togglePopover } from "./test-utils";
import { assertSelectedElements, render } from "./test-utils";
import { Excalidraw } from "../packages/excalidraw/index";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { API } from "./helpers/api";
@@ -6,18 +6,13 @@ 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 no history entry", async () => {
it("initializing scene should end up with single history entry", async () => {
await render(
<Excalidraw
initialData={{
@@ -29,15 +24,12 @@ describe("history", () => {
/>,
);
await waitFor(() => {
expect(h.state.zenModeEnabled).toBe(true);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(h.history.isUndoStackEmpty).toBeTruthy();
});
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
await waitFor(() =>
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
);
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 }),
@@ -59,14 +51,14 @@ describe("history", () => {
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
]);
expect(API.getUndoStack().length).toBe(0);
expect(API.getStateHistory().length).toBe(1);
h.app.actionManager.executeAction(redoAction);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: rectangle.id, isDeleted: false }),
]);
expect(API.getUndoStack().length).toBe(1);
expect(API.getStateHistory().length).toBe(2);
});
it("scene import via drag&drop should create new history entry", async () => {
@@ -102,10 +94,9 @@ describe("history", () => {
),
);
await waitFor(() => expect(API.getUndoStack().length).toBe(1));
await waitFor(() => expect(API.getStateHistory().length).toBe(2));
expect(h.state.viewBackgroundColor).toBe("#000");
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: true }),
expect.objectContaining({ id: "B", isDeleted: false }),
]);
@@ -120,8 +111,8 @@ describe("history", () => {
h.app.actionManager.executeAction(redoAction);
expect(h.state.viewBackgroundColor).toBe("#000");
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: true }),
expect.objectContaining({ id: "B", isDeleted: false }),
expect.objectContaining({ id: "A", isDeleted: true }),
]);
});
@@ -198,652 +189,4 @@ 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
});
});
+9 -9
View File
@@ -35,7 +35,7 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.history.getSnapshotForTest()).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.getUndoStack().length).toBe(0);
expect(API.getStateHistory().length).toBe(1);
UI.clickTool("rectangle");
mouse.down(10, 10);
@@ -386,35 +386,35 @@ describe("regression tests", () => {
const secondElementEndPoint = mouse.getPosition();
expect(API.getUndoStack().length).toBe(2);
expect(API.getStateHistory().length).toBe(3);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(API.getUndoStack().length).toBe(1);
expect(API.getStateHistory().length).toBe(2);
// clicking an element shouldn't add to history
mouse.restorePosition(...firstElementEndPoint);
mouse.click();
expect(API.getUndoStack().length).toBe(1);
expect(API.getStateHistory().length).toBe(2);
Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getStateHistory().length).toBe(3);
// clicking an element should add to history
// clicking an element shouldn't add to history
mouse.click();
expect(API.getUndoStack().length).toBe(3);
expect(API.getStateHistory().length).toBe(3);
const firstSelectedElementId = API.getSelectedElement().id;
// same for clicking the element just redo-ed
mouse.restorePosition(...secondElementEndPoint);
mouse.click();
expect(API.getUndoStack().length).toBe(4);
expect(API.getStateHistory().length).toBe(3);
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 -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1Sy27CMFx1MDAxMLzzXHUwMDE1UXpFwoFAKDdaqlxuqWpcdTAwMGZcdTAwMWOQWvVg4lxyseLYwXZ4XGLx77VccsQl4lx1MDAwM3qoXHUwMDBmljw7szvZzLFcdTAwMTNcdTAwMDShPlRcdTAwMTBOglx1MDAxMPYpZpRIvFx1MDAwYrtcdTAwMTbfglRUcFPqu7dcdTAwMTK1TFx1MDAxZDPXupr0ekxcdTAwMThBLpSeXGZcdTAwMTBCZ1x1MDAxMTAogWtlaF/mXHUwMDFkXHUwMDA0R3ebXG4lVrpdys3HNCsoJ/1NWiZvs+nrp5M60t5w4ihu3lx1MDAwNzt9kDTvXHUwMDFkJTo3WIRQg+VA17lugZivmfXqXHUwMDExpaUo4FkwIa2RXHUwMDA35I5cdTAwMWa9wmmxlqLmxHOiIcarzHMyythCXHUwMDFm2HlcdTAwMGI4zWtcdGFrwvJqsYU3OiXMlr3KjFxc51x1MDAxY5S60YhcbqdUXHUwMDFmWl9l/VVz4rb77V1JXFzC3K6X14z9bszJpfFNwfBTbf4sZnNOYN8uK1x1MDAwMOLmxtFomDyicVPxiYj6qI2+XHUwMDBi7tJcdTAwMTFF4zFCcT9cdTAwMTk1XGaqZiZcdTAwMTfatc0wU+CXbj2++MzcXHUwMDE4qSuCz1wiv1x1MDAxN0Z50eaZXHUwMDFjXHUwMDE2d3pfc00oLlx1MDAwNSehw0/d/1T+p/JcdTAwMGakXHUwMDEyXHUwMDE4o5WCSyrN7TZcdTAwMTfiqlpo099Qzlx1MDAxOVxyt1x1MDAxNHZPd1KQuWP1LtM2XHUwMDA1YM1cdTAwMWVPndNcdTAwMGaLVICkIn0=<!-- payload-end -->
<!-- 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 -->
<defs>
<style class=\\"style-fonts\\">
@font-face {
-1
View File
@@ -46,7 +46,6 @@ const populateElements = (
height?: number;
containerId?: string;
frameId?: ExcalidrawFrameElement["id"];
fractionalIndex?: ExcalidrawElement["fractionalIndex"];
}[],
appState?: Partial<AppState>,
) => {
+12 -34
View File
@@ -38,7 +38,6 @@ 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>;
@@ -173,23 +172,7 @@ export type InteractiveCanvasAppState = Readonly<
}
>;
export type ObservedAppState = ObservedStandaloneAppState &
ObservedElementsAppState;
export type ObservedStandaloneAppState = {
name: AppState["name"];
viewBackgroundColor: AppState["viewBackgroundColor"];
};
export type ObservedElementsAppState = {
editingGroupId: AppState["editingGroupId"];
selectedElementIds: AppState["selectedElementIds"];
selectedGroupIds: AppState["selectedGroupIds"];
editingLinearElement: AppState["editingLinearElement"];
selectedLinearElement: AppState["selectedLinearElement"];
};
export type AppState = {
export interface AppState {
contextMenu: {
items: ContextMenuItems;
top: number;
@@ -262,12 +245,15 @@ export type AppState = {
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog:
| "imageExport"
| "help"
| "jsonExport"
| "mermaid"
| "magicSettings"
| null;
| null
| { name: "imageExport" | "help" | "jsonExport" | "mermaid" }
| {
name: "magicSettings";
source:
| "tool" // when magicframe tool is selected
| "generation" // when magicframe generate button is clicked
| "settings"; // when AI settings dialog is explicitly invoked
};
/**
* Reflects user preference for whether the default sidebar should be docked.
*
@@ -469,8 +455,7 @@ export type SceneData = {
elements?: ImportedDataState["elements"];
appState?: ImportedDataState["appState"];
collaborators?: Map<string, Collaborator>;
commitToStore?: boolean;
skipSnapshotUpdate?: boolean; // TODO_UNDO: this flag is weird & causing breaking change, think about inverse (might cause less isues)
commitToHistory?: boolean;
};
export enum UserIdleState {
@@ -567,7 +552,7 @@ export type AppClassProperties = {
setActiveTool: App["setActiveTool"];
setOpenDialog: App["setOpenDialog"];
insertEmbeddableElement: App["insertEmbeddableElement"];
onMagicButtonSelect: App["onMagicButtonSelect"];
onMagicframeToolSelect: App["onMagicframeToolSelect"];
};
export type PointerDownState = Readonly<{
@@ -648,13 +633,6 @@ 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"];
+25 -21
View File
@@ -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.set(element.id, element);
acc[element.id] = element;
return acc;
}, new Map<string, ExcalidrawElement>());
}, {} as Record<string, ExcalidrawElement>);
};
const shiftElementsByOne = (
@@ -312,7 +312,12 @@ const shiftElementsByOne = (
];
});
return fixFractionalIndices(elements, targetElementsMap);
return elements.map((element) => {
if (targetElementsMap[element.id]) {
return bumpVersion(element);
}
return element;
});
};
const shiftElementsToEnd = (
@@ -378,27 +383,26 @@ const shiftElementsToEnd = (
}
}
const targetElements = Array.from(targetElementsMap.values());
const targetElements = Object.values(targetElementsMap).map((element) => {
return bumpVersion(element);
});
const leadingElements = elements.slice(0, leadingIndex);
const trailingElements = elements.slice(trailingIndex + 1);
return fixFractionalIndices(
direction === "left"
? [
...leadingElements,
...targetElements,
...displacedElements,
...trailingElements,
]
: [
...leadingElements,
...displacedElements,
...targetElements,
...trailingElements,
],
targetElementsMap,
);
return direction === "left"
? [
...leadingElements,
...targetElements,
...displacedElements,
...trailingElements,
]
: [
...leadingElements,
...displacedElements,
...targetElements,
...trailingElements,
];
};
function shiftElementsAccountingForFrames(
-5
View File
@@ -4891,11 +4891,6 @@ 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"