Fix history initialization (#2115)

This commit is contained in:
David Luzar 2020-09-09 21:08:06 +02:00 committed by GitHub
parent 9cac7816cc
commit aaddde5dd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1052 additions and 48 deletions

View File

@ -125,7 +125,7 @@ export const actionLoadScene = register({
...loadedAppState, ...loadedAppState,
errorMessage: error, errorMessage: error,
}, },
commitToHistory: false, commitToHistory: true,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (

View File

@ -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) => {

View File

@ -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

View File

@ -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;
};
} }

View File

@ -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
View 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 }),
]);
});
});

View File

@ -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);
}); });

View File

@ -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;
} }