Fix history initialization (#2115)
This commit is contained in:
parent
9cac7816cc
commit
aaddde5dd9
@ -125,7 +125,7 @@ export const actionLoadScene = register({
|
|||||||
...loadedAppState,
|
...loadedAppState,
|
||||||
errorMessage: error,
|
errorMessage: error,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
|
@ -556,7 +556,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.syncActionResult(scene);
|
history.clear();
|
||||||
|
this.syncActionResult({
|
||||||
|
...scene,
|
||||||
|
commitToHistory: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1163,11 +1167,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
const updateScene = (
|
const updateScene = (
|
||||||
decryptedData: SocketUpdateDataSource[SCENE.INIT | SCENE.UPDATE],
|
decryptedData: SocketUpdateDataSource[SCENE.INIT | SCENE.UPDATE],
|
||||||
{ scrollToContent = false }: { scrollToContent?: boolean } = {},
|
{ init = false }: { init?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
const { elements: remoteElements } = decryptedData.payload;
|
const { elements: remoteElements } = decryptedData.payload;
|
||||||
|
|
||||||
if (scrollToContent) {
|
if (init) {
|
||||||
|
history.resumeRecording();
|
||||||
this.setState({
|
this.setState({
|
||||||
...this.state,
|
...this.state,
|
||||||
...calculateScrollCenter(
|
...calculateScrollCenter(
|
||||||
@ -1292,7 +1297,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
case SCENE.INIT: {
|
case SCENE.INIT: {
|
||||||
if (!this.portal.socketInitialized) {
|
if (!this.portal.socketInitialized) {
|
||||||
updateScene(decryptedData, { scrollToContent: true });
|
updateScene(decryptedData, { init: true });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -3626,7 +3631,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
...(appState || this.state),
|
...(appState || this.state),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
import {
|
import { duplicateElement } from "./newElement";
|
||||||
newTextElement,
|
|
||||||
duplicateElement,
|
|
||||||
newLinearElement,
|
|
||||||
} from "./newElement";
|
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
|
import { API } from "../tests/helpers/api";
|
||||||
|
|
||||||
const isPrimitive = (val: any) => {
|
const isPrimitive = (val: any) => {
|
||||||
const type = typeof val;
|
const type = typeof val;
|
||||||
@ -22,7 +19,7 @@ const assertCloneObjects = (source: any, clone: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it("clones arrow element", () => {
|
it("clones arrow element", () => {
|
||||||
const element = newLinearElement({
|
const element = API.createElement({
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@ -68,7 +65,8 @@ it("clones arrow element", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clones text element", () => {
|
it("clones text element", () => {
|
||||||
const element = newTextElement({
|
const element = API.createElement({
|
||||||
|
type: "text",
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
strokeColor: "#000000",
|
strokeColor: "#000000",
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,12 @@
|
|||||||
import { ExcalidrawElement } from "../../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawGenericElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
} from "../../element/types";
|
||||||
|
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||||
|
import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
|
||||||
|
import { getDefaultAppState } from "../../appState";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@ -29,4 +37,102 @@ export class API {
|
|||||||
h.app.clearSelection(null);
|
h.app.clearSelection(null);
|
||||||
expect(API.getSelectedElements().length).toBe(0);
|
expect(API.getSelectedElements().length).toBe(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static createElement = <
|
||||||
|
T extends Exclude<ExcalidrawElement["type"], "selection">
|
||||||
|
>({
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
x = 0,
|
||||||
|
y = x,
|
||||||
|
width = 100,
|
||||||
|
height = width,
|
||||||
|
isDeleted = false,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
type: T;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
id?: string;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
// generic element props
|
||||||
|
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
||||||
|
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
||||||
|
fillStyle?: ExcalidrawGenericElement["fillStyle"];
|
||||||
|
strokeWidth?: ExcalidrawGenericElement["strokeWidth"];
|
||||||
|
strokeStyle?: ExcalidrawGenericElement["strokeStyle"];
|
||||||
|
strokeSharpness?: ExcalidrawGenericElement["strokeSharpness"];
|
||||||
|
roughness?: ExcalidrawGenericElement["roughness"];
|
||||||
|
opacity?: ExcalidrawGenericElement["opacity"];
|
||||||
|
// text props
|
||||||
|
text?: T extends "text" ? ExcalidrawTextElement["text"] : never;
|
||||||
|
fontSize?: T extends "text" ? ExcalidrawTextElement["fontSize"] : never;
|
||||||
|
fontFamily?: T extends "text" ? ExcalidrawTextElement["fontFamily"] : never;
|
||||||
|
textAlign?: T extends "text" ? ExcalidrawTextElement["textAlign"] : never;
|
||||||
|
verticalAlign?: T extends "text"
|
||||||
|
? ExcalidrawTextElement["verticalAlign"]
|
||||||
|
: never;
|
||||||
|
}): T extends "arrow" | "line" | "draw"
|
||||||
|
? ExcalidrawLinearElement
|
||||||
|
: T extends "text"
|
||||||
|
? ExcalidrawTextElement
|
||||||
|
: ExcalidrawGenericElement => {
|
||||||
|
let element: Mutable<ExcalidrawElement> = null!;
|
||||||
|
|
||||||
|
const appState = h?.state || getDefaultAppState();
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
|
||||||
|
backgroundColor:
|
||||||
|
rest.backgroundColor ?? appState.currentItemBackgroundColor,
|
||||||
|
fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
|
||||||
|
strokeWidth: rest.strokeWidth ?? appState.currentItemStrokeWidth,
|
||||||
|
strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
|
||||||
|
strokeSharpness:
|
||||||
|
rest.strokeSharpness ?? appState.currentItemStrokeSharpness,
|
||||||
|
roughness: rest.roughness ?? appState.currentItemRoughness,
|
||||||
|
opacity: rest.opacity ?? appState.currentItemOpacity,
|
||||||
|
};
|
||||||
|
switch (type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "diamond":
|
||||||
|
case "ellipse":
|
||||||
|
element = newElement({
|
||||||
|
type: type as "rectangle" | "diamond" | "ellipse",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
...base,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "text":
|
||||||
|
element = newTextElement({
|
||||||
|
...base,
|
||||||
|
text: rest.text || "test",
|
||||||
|
fontSize: rest.fontSize ?? appState.currentItemFontSize,
|
||||||
|
fontFamily: rest.fontFamily ?? appState.currentItemFontFamily,
|
||||||
|
textAlign: rest.textAlign ?? appState.currentItemTextAlign,
|
||||||
|
verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "arrow":
|
||||||
|
case "line":
|
||||||
|
case "draw":
|
||||||
|
element = newLinearElement({
|
||||||
|
type: type as "arrow" | "line" | "draw",
|
||||||
|
...base,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
element.id = id;
|
||||||
|
}
|
||||||
|
if (isDeleted) {
|
||||||
|
element.isDeleted = isDeleted;
|
||||||
|
}
|
||||||
|
return element as any;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -192,7 +192,7 @@ export class UI {
|
|||||||
size?: number;
|
size?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
},
|
} = {},
|
||||||
): T extends "arrow" | "line" | "draw"
|
): T extends "arrow" | "line" | "draw"
|
||||||
? ExcalidrawLinearElement
|
? ExcalidrawLinearElement
|
||||||
: T extends "text"
|
: T extends "text"
|
||||||
|
127
src/tests/history.test.tsx
Normal file
127
src/tests/history.test.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, GlobalTestState } from "./test-utils";
|
||||||
|
import App from "../components/App";
|
||||||
|
import { UI } from "./helpers/ui";
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import { waitFor, fireEvent, createEvent } from "@testing-library/react";
|
||||||
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe("history", () => {
|
||||||
|
it("initializing scene should end up with single history entry", async () => {
|
||||||
|
render(
|
||||||
|
<App
|
||||||
|
initialData={{
|
||||||
|
appState: {
|
||||||
|
...getDefaultAppState(),
|
||||||
|
zenModeEnabled: true,
|
||||||
|
},
|
||||||
|
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
h.app.actionManager.executeAction(undoAction);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
]);
|
||||||
|
const rectangle = UI.createElement("rectangle");
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "A" }),
|
||||||
|
expect.objectContaining({ id: rectangle.id }),
|
||||||
|
]);
|
||||||
|
h.app.actionManager.executeAction(undoAction);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// noop
|
||||||
|
h.app.actionManager.executeAction(undoAction);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
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.getStateHistory().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scene import via drag&drop should create new history entry", async () => {
|
||||||
|
render(
|
||||||
|
<App
|
||||||
|
initialData={{
|
||||||
|
appState: {
|
||||||
|
...getDefaultAppState(),
|
||||||
|
viewBackgroundColor: "#FFF",
|
||||||
|
},
|
||||||
|
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
||||||
|
);
|
||||||
|
const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
|
||||||
|
const file = new Blob(
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
type: "excalidraw",
|
||||||
|
appState: {
|
||||||
|
...getDefaultAppState(),
|
||||||
|
viewBackgroundColor: "#000",
|
||||||
|
},
|
||||||
|
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
type: "application/json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
||||||
|
value: {
|
||||||
|
files: [file],
|
||||||
|
getData: (_type: string) => {
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fireEvent(GlobalTestState.canvas, fileDropEvent);
|
||||||
|
|
||||||
|
await waitFor(() => expect(API.getStateHistory().length).toBe(2));
|
||||||
|
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const undoAction = createUndoAction(h.history);
|
||||||
|
const redoAction = createRedoAction(h.history);
|
||||||
|
h.app.actionManager.executeAction(undoAction);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.state.viewBackgroundColor).toBe("#FFF");
|
||||||
|
h.app.actionManager.executeAction(redoAction);
|
||||||
|
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: true }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -451,10 +451,7 @@ describe("regression tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("noop interaction after undo shouldn't create history entry", () => {
|
it("noop interaction after undo shouldn't create history entry", () => {
|
||||||
// NOTE: this will fail if this test case is run in isolation. There's
|
expect(API.getStateHistory().length).toBe(1);
|
||||||
// some leaking state or race conditions in initialization/teardown
|
|
||||||
// (couldn't figure out)
|
|
||||||
expect(API.getStateHistory().length).toBe(0);
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down(10, 10);
|
mouse.down(10, 10);
|
||||||
@ -468,35 +465,35 @@ describe("regression tests", () => {
|
|||||||
|
|
||||||
const secondElementEndPoint = mouse.getPosition();
|
const secondElementEndPoint = mouse.getPosition();
|
||||||
|
|
||||||
expect(API.getStateHistory().length).toBe(2);
|
expect(API.getStateHistory().length).toBe(3);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress("z");
|
Keyboard.keyPress("z");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
expect(API.getStateHistory().length).toBe(2);
|
||||||
|
|
||||||
// clicking an element shouldn't add to history
|
// clicking an element shouldn't add to history
|
||||||
mouse.restorePosition(...firstElementEndPoint);
|
mouse.restorePosition(...firstElementEndPoint);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
expect(API.getStateHistory().length).toBe(2);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => {
|
Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => {
|
||||||
Keyboard.keyPress("z");
|
Keyboard.keyPress("z");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getStateHistory().length).toBe(2);
|
expect(API.getStateHistory().length).toBe(3);
|
||||||
|
|
||||||
// clicking an element shouldn't add to history
|
// clicking an element shouldn't add to history
|
||||||
mouse.click();
|
mouse.click();
|
||||||
expect(API.getStateHistory().length).toBe(2);
|
expect(API.getStateHistory().length).toBe(3);
|
||||||
|
|
||||||
const firstSelectedElementId = API.getSelectedElement().id;
|
const firstSelectedElementId = API.getSelectedElement().id;
|
||||||
|
|
||||||
// same for clicking the element just redo-ed
|
// same for clicking the element just redo-ed
|
||||||
mouse.restorePosition(...secondElementEndPoint);
|
mouse.restorePosition(...secondElementEndPoint);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
expect(API.getStateHistory().length).toBe(2);
|
expect(API.getStateHistory().length).toBe(3);
|
||||||
|
|
||||||
expect(API.getSelectedElement().id).not.toEqual(firstSelectedElementId);
|
expect(API.getSelectedElement().id).not.toEqual(firstSelectedElementId);
|
||||||
});
|
});
|
||||||
|
@ -3,14 +3,13 @@ import ReactDOM from "react-dom";
|
|||||||
import { render } from "./test-utils";
|
import { render } from "./test-utils";
|
||||||
import App from "../components/App";
|
import App from "../components/App";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import { newElement } from "../element";
|
|
||||||
import {
|
import {
|
||||||
actionSendBackward,
|
actionSendBackward,
|
||||||
actionBringForward,
|
actionBringForward,
|
||||||
actionBringToFront,
|
actionBringToFront,
|
||||||
actionSendToBack,
|
actionSendToBack,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { API } from "./helpers/api";
|
||||||
|
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
@ -28,21 +27,7 @@ const populateElements = (
|
|||||||
const selectedElementIds: any = {};
|
const selectedElementIds: any = {};
|
||||||
|
|
||||||
h.elements = elements.map(({ id, isDeleted = false, isSelected = false }) => {
|
h.elements = elements.map(({ id, isDeleted = false, isSelected = false }) => {
|
||||||
const element: Mutable<ExcalidrawElement> = newElement({
|
const element = API.createElement({ type: "rectangle", id, isDeleted });
|
||||||
type: "rectangle",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
strokeColor: h.state.currentItemStrokeColor,
|
|
||||||
backgroundColor: h.state.currentItemBackgroundColor,
|
|
||||||
fillStyle: h.state.currentItemFillStyle,
|
|
||||||
strokeWidth: h.state.currentItemStrokeWidth,
|
|
||||||
strokeStyle: h.state.currentItemStrokeStyle,
|
|
||||||
strokeSharpness: h.state.currentItemStrokeSharpness,
|
|
||||||
roughness: h.state.currentItemRoughness,
|
|
||||||
opacity: h.state.currentItemOpacity,
|
|
||||||
});
|
|
||||||
element.id = id;
|
|
||||||
element.isDeleted = isDeleted;
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
selectedElementIds[element.id] = true;
|
selectedElementIds[element.id] = true;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user