feat: replaces xstate with custom state machine implementation

This commit is contained in:
Mark Mankarious 2023-03-22 00:19:00 +00:00
parent 08ff6865fc
commit 6b90ad9b7b
19 changed files with 9624 additions and 304 deletions

5
jest.config.js Normal file
View file

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};

9417
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,16 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/jsdom": "^21.1.0",
"@types/node": "^16.18.12",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"jsdom": "^21.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ts-jest": "^29.0.5",
"ts-loader": "^9.4.2",
"typescript": "^4.9.5",
"webpack": "^5.76.2",
@ -24,13 +29,13 @@
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.9",
"@mui/material": "^5.11.10",
"@xstate/react": "^3.2.1",
"auto-bind": "^5.0.1",
"gsap": "^3.11.4",
"mobx": "^6.8.0",
"mobx-react": "^7.6.0",
"paper": "^0.12.17",
"react-hook-form": "^7.43.2",
"react-router-dom": "^6.8.1",
"xstate": "^4.37.0",
"zod": "^3.20.6",
"zustand": "^4.3.3"
},
@ -39,7 +44,9 @@
"react-dom": ">=17"
},
"scripts": {
"build": "webpack"
"dev": "webpack --watch",
"build": "webpack",
"test": "jest"
},
"eslintConfig": {
"extends": [

View file

@ -1,12 +1,13 @@
import React from "react";
import { ThemeProvider } from "@mui/material/styles";
import { theme } from "./theme";
import Box from "@mui/material/Box";
import { SideNav } from "./components/SideNav";
import { Sidebar } from "./components/Sidebars";
import { ToolMenu } from "./components/ToolMenu";
import { RendererContainer } from "./components/RendererContainer";
import { SceneI } from "./validation/SceneSchema";
import { GlobalStateProvider } from "./contexts/GlobalStateContext";
import { ModeManagerProvider } from "./contexts/ModeManagerContext";
interface Props {
initialScene: SceneI;
@ -15,12 +16,14 @@ interface Props {
export const App = ({ initialScene }: Props) => {
return (
<ThemeProvider theme={theme}>
<GlobalStateProvider>
<RendererContainer key="renderer" />
<Sidebar />
<SideNav />
<ToolMenu />
</GlobalStateProvider>
<ModeManagerProvider>
<Box sx={{ width: "100%", height: "100%", position: "relative" }}>
<RendererContainer key="renderer" />
<Sidebar />
<SideNav />
<ToolMenu />
</Box>
</ModeManagerProvider>
</ThemeProvider>
);
};

View file

@ -1,35 +0,0 @@
import { Select } from "./Select";
import { Pan } from "./Pan";
import { createMachine, assign } from "xstate";
import { Events, Context } from "./types";
export const EditorMachine = createMachine({
id: "Editor",
initial: "UNINITIALISED",
on: {
SWITCH_TO_SELECT: {
target: "SELECT_MODE",
},
SWITCH_TO_PAN: {
target: "PAN_MODE",
},
},
states: {
UNINITIALISED: {
on: {
INIT: {
actions: assign({
renderer: (ctx, { renderer }) => renderer,
}),
target: "SELECT_MODE",
},
},
},
SELECT_MODE: Select,
PAN_MODE: Pan,
},
schema: {
context: {} as Context,
events: {} as Events,
},
});

View file

@ -1,37 +0,0 @@
import { MachineConfig } from "xstate";
import type { Events, Context } from "./types";
const changeCursor = (cursorType: string) => (ctx: Context) => {
if (ctx.renderer)
ctx.renderer.domElements.container.style.cursor = cursorType;
};
export const Pan: MachineConfig<Context, any, Events> = {
id: "Pan",
initial: "IDLE",
entry: changeCursor("grab"),
exit: changeCursor("default"),
states: {
IDLE: {
on: {
MOUSE_DOWN: {
target: "PANNING",
},
},
},
PANNING: {
entry: changeCursor("grabbing"),
exit: changeCursor("grab"),
on: {
MOUSE_MOVE: {
actions: (ctx, data) => {
ctx.renderer?.scrollToDelta(data.delta.x, data.delta.y);
},
},
MOUSE_UP: {
target: "IDLE",
},
},
},
},
};

View file

@ -1,31 +0,0 @@
import { MachineConfig } from "xstate";
import type { Events, Context } from "./types";
export const Select: MachineConfig<Context, any, Events> = {
id: "#Select",
initial: "HOVERING",
states: {
HOVERING: {
entry: (ctx) => {
ctx.renderer?.sceneElements.cursor.enable();
},
exit: (ctx) => {
ctx.renderer?.sceneElements.cursor.disable();
},
on: {
MOUSE_MOVE: {
actions: (ctx, data) => {
if (!ctx.renderer) return;
const tile = ctx.renderer.getTileFromMouse(
data.position.x,
data.position.y
);
ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
},
},
},
},
},
};

View file

@ -1,59 +0,0 @@
import { Renderer } from "../renderer/Renderer";
export interface Context {
renderer?: Renderer;
}
interface Coords {
x: number;
y: number;
}
export type MouseEvents =
| {
type: "MOUSE_DOWN";
position: Coords;
delta: Coords;
renderer: Renderer;
}
| {
type: "MOUSE_MOVE";
position: Coords;
delta: Coords;
renderer: Renderer;
}
| {
type: "MOUSE_UP";
position: Coords;
delta: Coords;
renderer: Renderer;
}
| {
type: "MOUSE_ENTER";
position: Coords;
delta: Coords;
renderer: Renderer;
}
| {
type: "MOUSE_LEAVE";
position: Coords;
delta: Coords;
renderer: Renderer;
};
export type Events =
| {
type: "INIT";
renderer: Renderer;
}
| {
type: "SWITCH_TO_SELECT";
}
| {
type: "SWITCH_TO_PAN";
}
| {
type: "SWITCH_TOOL";
tool: "SELECT" | "PAN";
}
| MouseEvents;

View file

@ -1,12 +1,14 @@
import React from "react";
import { useRef, useEffect, useContext } from "react";
import React, { useContext } from "react";
import { observer } from "mobx-react";
import { useRef, useEffect, useState } from "react";
import { Renderer } from "../renderer/Renderer";
import { useGlobalState } from "../hooks/useGlobalState";
import { GlobalStateContext } from "../contexts/GlobalStateContext";
import { useMouseInput } from "../hooks/useMouseInput";
import { modeManagerContext } from "../contexts/ModeManagerContext";
import { Select } from "../modes/Select";
export const RendererContainer = () => {
const { editor } = useContext(GlobalStateContext);
export const RendererContainer = observer(() => {
const modeManager = useContext(modeManagerContext);
const rendererEl = useRef<HTMLDivElement>(null);
const { setDomEl, setCallbacks } = useMouseInput();
const setRenderer = useGlobalState((state) => state.setRenderer);
@ -17,32 +19,31 @@ export const RendererContainer = () => {
const renderer = new Renderer(rendererEl.current);
setRenderer(renderer);
setDomEl(rendererEl.current);
editor.send("INIT", {
renderer,
});
}, [setRenderer, setDomEl, editor]);
modeManager.setRenderer(renderer);
modeManager.activateMode(Select);
}, [setRenderer, setDomEl, modeManager]);
useEffect(() => {
if (!rendererEl.current) return;
if (!modeManager) return;
setCallbacks({
onMouseMove: (event) => {
editor.send("MOUSE_MOVE", { ...event });
modeManager.onMouseEvent("MOUSE_MOVE", event);
},
onMouseDown: (event) => {
editor.send("MOUSE_DOWN", { ...event });
modeManager.onMouseEvent("MOUSE_DOWN", event);
},
onMouseUp: (event) => {
editor.send("MOUSE_UP", { ...event });
modeManager.onMouseEvent("MOUSE_UP", event);
},
onMouseEnter: (event) => {
editor.send("MOUSE_ENTER", { ...event });
modeManager.onMouseEvent("MOUSE_ENTER", event);
},
onMouseLeave: (event) => {
editor.send("MOUSE_LEAVE", { ...event });
modeManager.onMouseEvent("MOUSE_LEAVE", event);
},
});
}, [setCallbacks, editor]);
}, [setCallbacks, modeManager]);
return (
<div
@ -51,9 +52,9 @@ export const RendererContainer = () => {
position: "absolute",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
width: "100%",
height: "100%",
}}
/>
);
};
});

View file

@ -1,20 +1,21 @@
import React from "react";
import { observer } from "mobx-react";
import { useContext } from "react";
import { useTheme } from "@mui/material";
import { useActor } from "@xstate/react";
import Card from "@mui/material/Card";
import { MenuItem } from "../MenuItem";
import PanToolIcon from "@mui/icons-material/PanTool";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import NearMeIcon from "@mui/icons-material/NearMe";
import { GlobalStateContext } from "../../contexts/GlobalStateContext";
import { useZoom } from "../../hooks/useZoom";
import { modeManagerContext } from "../../contexts/ModeManagerContext";
import { Select } from "../../modes/Select";
import { Pan } from "../../modes/Pan";
export const ToolMenu = () => {
export const ToolMenu = observer(() => {
const modeManager = useContext(modeManagerContext);
const theme = useTheme();
const { editor } = useContext(GlobalStateContext);
const [state] = useActor(editor);
const { incrementZoom, decrementZoom } = useZoom();
return (
@ -30,16 +31,16 @@ export const ToolMenu = () => {
<MenuItem
name="Select"
Icon={NearMeIcon}
onClick={() => editor.send("SWITCH_TO_SELECT")}
onClick={() => modeManager.activateMode(Select)}
size={theme.customVars.toolMenu.height}
isActive={state.matches("SELECT_MODE")}
isActive={modeManager.currentMode instanceof Select}
/>
<MenuItem
name="Pan"
Icon={PanToolIcon}
onClick={() => editor.send("SWITCH_TO_PAN")}
onClick={() => modeManager.activateMode(Pan)}
size={theme.customVars.toolMenu.height}
isActive={state.matches("PAN_MODE")}
isActive={modeManager.currentMode instanceof Pan}
/>
<MenuItem
name="Zoom in"
@ -55,4 +56,4 @@ export const ToolMenu = () => {
/>
</Card>
);
};
});

View file

@ -1,29 +0,0 @@
import React from "react";
import { createContext } from "react";
import { interpret, InterpreterFrom } from "xstate";
import { useInterpret } from "@xstate/react";
import { EditorMachine } from "../actions/Editor";
interface Props {
children: React.ReactNode;
}
type Service = InterpreterFrom<typeof EditorMachine>;
interface Context {
editor: Service;
}
export const GlobalStateContext = createContext<Context>({
editor: interpret(EditorMachine),
});
export const GlobalStateProvider = ({ children }: Props) => {
const editor = useInterpret(EditorMachine);
return (
<GlobalStateContext.Provider value={{ editor }}>
{children}
</GlobalStateContext.Provider>
);
};

View file

@ -0,0 +1,16 @@
import React, { createContext, useMemo } from "react";
import { ModeManager } from "../modes/ModeManager";
interface Props {
children: React.ReactNode;
}
export const modeManagerContext = createContext(new ModeManager());
export const ModeManagerProvider = ({ children }: Props) => {
return (
<modeManagerContext.Provider value={new ModeManager()}>
{children}
</modeManagerContext.Provider>
);
};

15
src/modes/ModeBase.ts Normal file
View file

@ -0,0 +1,15 @@
import { ModeContext, Mouse } from "./types";
import { makeAutoObservable } from "mobx";
export class ModeBase {
ctx;
constructor(ctx: ModeContext) {
makeAutoObservable(this);
this.ctx = ctx;
}
entry(mouse: Mouse) {}
exit() {}
}

50
src/modes/ModeManager.ts Normal file
View file

@ -0,0 +1,50 @@
import { makeAutoObservable } from "mobx";
import autoBind from "auto-bind";
import { Renderer } from "../renderer/Renderer";
import { ModeBase } from "./ModeBase";
import { Mouse } from "./types";
export class ModeManager {
renderer?: Renderer = undefined;
currentMode?: ModeBase = undefined;
mouse: Mouse = {
position: { x: 0, y: 0 },
delta: null,
};
constructor() {
makeAutoObservable(this);
autoBind(this);
}
setRenderer(renderer: Renderer) {
this.renderer = renderer;
}
activateMode(Mode: typeof ModeBase) {
if (!this.renderer) return;
const lastMode = this.currentMode;
this.currentMode?.exit();
this.currentMode = new Mode({
renderer: this.renderer,
activateMode: this.activateMode.bind(this),
deactivate: lastMode?.exit ?? (() => {}),
});
this.currentMode.entry(this.mouse);
}
onMouseEvent(eventName: string, mouse: Mouse) {
this.mouse = mouse;
this.send(eventName, mouse);
}
send(eventName: string, params?: any) {
// TODO: Improve typings below
// @ts-ignore
this.currentMode?.[eventName]?.(params);
}
}

39
src/modes/Pan.ts Normal file
View file

@ -0,0 +1,39 @@
import { ModeBase } from "./ModeBase";
import { Mouse } from "./types";
import { Renderer } from "../renderer/Renderer";
const changeCursor = (cursorType: string, renderer: Renderer) => {
renderer.domElements.container.style.cursor = cursorType;
};
export class Pan extends ModeBase {
isPanning = false;
entry() {
changeCursor("grab", this.ctx.renderer);
}
exit() {
changeCursor("default", this.ctx.renderer);
}
MOUSE_DOWN() {
if (!this.isPanning) {
this.isPanning = true;
changeCursor("grabbing", this.ctx.renderer);
}
}
MOUSE_UP() {
if (this.isPanning) {
this.isPanning = false;
changeCursor("grab", this.ctx.renderer);
}
}
MOUSE_MOVE(mouse: Mouse) {
if (this.isPanning && mouse.delta !== null) {
this.ctx.renderer.scrollToDelta(mouse.delta.x, mouse.delta.y);
}
}
}

35
src/modes/Select.ts Normal file
View file

@ -0,0 +1,35 @@
import { ModeBase } from "./ModeBase";
import { Mouse } from "./types";
export class Select extends ModeBase {
entry(mouse: Mouse) {
const tile = this.ctx.renderer.getTileFromMouse(
mouse.position.x,
mouse.position.y
);
this.ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
this.ctx.renderer.sceneElements.cursor.enable();
}
exit() {
this.ctx.renderer.sceneElements.cursor.disable();
}
MOUSE_ENTER() {
this.ctx.renderer.sceneElements.cursor.enable();
}
MOUSE_LEAVE() {
this.ctx.renderer.sceneElements.cursor.disable();
}
MOUSE_MOVE(mouse: Mouse) {
const tile = this.ctx.renderer.getTileFromMouse(
mouse.position.x,
mouse.position.y
);
this.ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
}
}

View file

@ -0,0 +1,34 @@
import { ModeManager } from "../ModeManager";
import { Renderer } from "../../renderer/Renderer";
import { TestMode } from "./fixtures/TestMode";
jest.mock("../../renderer/Renderer", () => ({
Renderer: jest.fn(),
}));
describe("Mode manager functions correctly", () => {
it("Activating a mode works correctly", () => {
const entrySpy = jest.spyOn(TestMode.prototype, "entry");
const exitSpy = jest.spyOn(TestMode.prototype, "exit");
const eventSpy = jest.spyOn(TestMode.prototype, "TEST_EVENT");
const mouseEventSpy = jest.spyOn(TestMode.prototype, "MOUSE_MOVE");
const renderer = new Renderer({} as unknown as HTMLDivElement);
const modeManager = new ModeManager();
modeManager.setRenderer(renderer);
modeManager.activateMode(TestMode);
modeManager.send("TEST_EVENT");
modeManager.send("TEST_EVENT");
modeManager.send("MOUSE_MOVE", { x: 10, y: 10 });
expect(entrySpy).toHaveBeenCalled();
expect(eventSpy).toHaveBeenCalledTimes(2);
expect(mouseEventSpy).toHaveBeenCalledWith({
x: 10,
y: 10,
});
modeManager.activateMode(TestMode);
expect(exitSpy).toHaveBeenCalled();
});
});

12
src/modes/tests/fixtures/TestMode.ts vendored Normal file
View file

@ -0,0 +1,12 @@
import { MouseCoords, ModeContext } from "../../types";
import { ModeBase } from "../../ModeBase";
export class TestMode extends ModeBase {
entry() {}
exit() {}
TEST_EVENT() {}
MOUSE_MOVE(e: MouseCoords) {}
}

24
src/modes/types.ts Normal file
View file

@ -0,0 +1,24 @@
import { Renderer } from "../renderer/Renderer";
import type { ModeBase } from "./ModeBase";
export interface Mode {
initial: string;
ctx: ModeContext;
destroy?: () => void;
}
export interface MouseCoords {
x: number;
y: number;
}
export interface Mouse {
position: MouseCoords;
delta: MouseCoords | null;
}
export interface ModeContext {
renderer: Renderer;
activateMode: (mode: typeof ModeBase) => void;
deactivate: () => void;
}