feat: adds ability to remove a node

This commit is contained in:
Mark Mankarious
2023-07-04 18:17:06 +01:00
parent 409691e69c
commit 2e2a98f5e9
26 changed files with 412 additions and 128 deletions

View File

@@ -6,11 +6,12 @@ import { theme } from "./theme";
import { SideNav } from "./components/SideNav";
import { Sidebar } from "./components/Sidebars";
import { ToolMenu } from "./components/ToolMenu";
import { ContextMenu } from "./components/ContextMenus";
import { RendererContainer } from "./components/RendererContainer";
import { SceneI } from "./validation/SceneSchema";
import { ModeManagerProvider } from "./contexts/ModeManagerContext";
import { useGlobalState } from "./hooks/useGlobalState";
import { OnSceneChange } from "./renderer/types";
import { OnSceneChange } from "./types";
interface Props {
initialScene: SceneI;
@@ -33,6 +34,7 @@ const InnerApp = React.memo(
}}
>
<RendererContainer />
<ContextMenu />
<Sidebar />
<SideNav />
<ToolMenu />
@@ -42,7 +44,6 @@ const InnerApp = React.memo(
);
}
);
const App = observer(
({ initialScene, width, height, onSceneChange }: Props) => {
const setInitialScene = useGlobalState((state) => state.setInitialScene);

View File

@@ -0,0 +1,53 @@
import React from "react";
import { List, Box, Card } from "@mui/material";
import { keyframes } from "@emotion/react";
interface Props {
children: React.ReactNode;
position: { x: number; y: number };
}
const COLOR = "grey.900";
const SIZE = 14;
const ANIMATIONS = {
in: keyframes`
0% {
opacity: 0;
transform: translateX(15px);
}
100% {
opacity: 1;
transform: translateX(0);
}
`,
};
export const ContextMenu = ({ position, children }: Props) => {
return (
<Box
sx={{
position: "absolute",
top: position.y,
left: position.x + SIZE,
animation: `${ANIMATIONS.in} 0.2s ease-in-out`,
}}
>
<Box
sx={{
position: "absolute",
left: -(SIZE - 2),
top: 8,
width: 0,
height: 0,
borderTop: `${SIZE}px solid transparent`,
borderBottom: `${SIZE}px solid transparent`,
borderRight: `${SIZE}px solid`,
borderRightColor: COLOR,
}}
/>
<Card sx={{ borderRadius: 2 }}>
<List sx={{ p: 0 }}>{children}</List>
</Card>
</Box>
);
};

View File

@@ -0,0 +1,42 @@
import React, { useMemo } from "react";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
interface Props {
onClick: () => void;
icon: React.ReactNode;
label: string;
}
export const ContextMenuItem = ({ onClick, icon, label }: Props) => {
return (
<ListItem
sx={{
p: 0,
"&:not(:last-child)": {
borderBottom: "1px solid",
borderBottomColor: "grey.800",
},
}}
>
<ListItemButton onClick={onClick} sx={{ py: 0.5, px: 2 }}>
<ListItemIcon
sx={{
pr: 1,
minWidth: "auto",
color: "grey.400",
svg: {
maxWidth: 18,
maxHeight: 18,
},
}}
>
{icon}
</ListItemIcon>
<ListItemText secondary={label} />
</ListItemButton>
</ListItem>
);
};

View File

@@ -0,0 +1,34 @@
import React from "react";
import { observer } from "mobx-react";
import { useGlobalState } from "../../hooks/useGlobalState";
import { ContextMenu } from "./ContextMenu";
import { ContextMenuItem } from "./ContextMenuItem";
import { Node } from "../../renderer/elements/Node";
import { ArrowRightAlt, Delete } from "@mui/icons-material";
interface Props {
node: Node;
}
export const NodeContextMenu = ({ node }: Props) => {
const renderer = useGlobalState((state) => state.renderer);
const position = renderer.getTileScreenPosition(
node.position.x,
node.position.y
);
return (
<ContextMenu position={position}>
<ContextMenuItem
onClick={() => {}}
icon={<ArrowRightAlt />}
label="Connect"
/>
<ContextMenuItem
onClick={node.destroy}
icon={<Delete />}
label="Remove"
/>
</ContextMenu>
);
};

View File

@@ -0,0 +1,13 @@
import React from "react";
import { useGlobalState } from "../../hooks/useGlobalState";
import { NodeContextMenu } from "./NodeContextMenu";
export const ContextMenu = () => {
const targetElement = useGlobalState((state) => state.showContextMenuAt);
if (!targetElement) {
return null;
}
return <NodeContextMenu node={targetElement} />;
};

View File

@@ -1,6 +1,5 @@
import React, { useRef, useEffect, useContext } from "react";
import { observer } from "mobx-react";
import Box from "@mui/material/Box";
import { Renderer } from "../renderer/Renderer";
import { useGlobalState } from "../hooks/useGlobalState";
import { useMouseInput } from "../hooks/useMouseInput";
@@ -17,10 +16,10 @@ export const RendererContainer = observer(() => {
useEffect(() => {
if (!rendererEl.current) return;
const renderer = new Renderer(rendererEl.current, onSceneChange);
setRenderer(renderer);
const renderer = setRenderer(rendererEl.current);
setDomEl(rendererEl.current);
modeManager.setRenderer(renderer);
modeManager.setEventEmitter(renderer.callbacks.emitEvent);
modeManager.activateMode(Select);
setCallbacks({

View File

@@ -1,10 +1,11 @@
import { create } from "zustand";
import { SceneI } from "../validation/SceneSchema";
import { Node } from "../renderer/elements/Node";
import { Renderer } from "../renderer/Renderer";
import { OnSceneChange } from "../renderer/types";
import { getRandom } from "../utils";
import { OnSceneChange, SceneEventI } from "../types";
interface GlobalState {
showContextMenuAt: Node | null;
onSceneChange: OnSceneChange;
setOnSceneChange: (onSceneChange: OnSceneChange) => void;
initialScene: SceneI;
@@ -13,10 +14,15 @@ interface GlobalState {
setSelectedSideNavItem: (index: number) => void;
closeSideNav: () => void;
renderer: Renderer;
setRenderer: (renderer: Renderer) => void;
selectedElements: string[];
setRenderer: (containerEl: HTMLDivElement) => Renderer;
onRendererEvent: (event: SceneEventI) => void;
}
export const useGlobalState = create<GlobalState>((set, get) => ({
showContextMenuAt: null,
selectedElements: [],
selectedSideNavItem: null,
onSceneChange: () => {},
setOnSceneChange: (onSceneChange) => set({ onSceneChange }),
initialScene: {
@@ -28,30 +34,63 @@ export const useGlobalState = create<GlobalState>((set, get) => ({
setInitialScene: (scene) => {
set({ initialScene: scene });
},
selectedSideNavItem: null,
setSelectedSideNavItem: (val) => {
set({ selectedSideNavItem: val });
},
closeSideNav: () => {
set({ selectedSideNavItem: null });
},
renderer: new Renderer(document.createElement("div"), () => {}),
setRenderer: (renderer: Renderer) =>
renderer: new Renderer(document.createElement("div")),
onRendererEvent: (event) => {
const { renderer } = get();
switch (event.type) {
case "GRID_SELECTED":
set({ showContextMenuAt: null, selectedElements: [] });
break;
case "NODES_SELECTED":
set({ selectedElements: event.data.nodes });
if (event.data.nodes.length === 1) {
const node = renderer.sceneElements.nodes.getNodeById(
event.data.nodes[0]
);
set({ showContextMenuAt: node });
}
break;
case "NODE_REMOVED":
set({
showContextMenuAt: null,
selectedElements: [],
selectedSideNavItem: null,
});
break;
case "NODE_MOVED":
set({ showContextMenuAt: null, selectedElements: [] });
break;
case "ZOOM_CHANGED":
set({ showContextMenuAt: null, selectedElements: [] });
break;
default:
break;
}
},
setRenderer: (containerEl) => {
set((state) => {
if (state.renderer) {
state.renderer.destroy();
}
const scene = state.initialScene;
const renderer = new Renderer(containerEl);
renderer.setEventHandler(state.onRendererEvent);
renderer.loadScene(scene);
renderer.setZoom(state.renderer.zoom);
// setInterval(() => {
// const node = renderer.sceneElements.nodes.getNodeById("Node1");
// node?.moveTo(getRandom(-5, 5), getRandom(-5, 5));
// }, 1000);
return { renderer };
}),
});
return get().renderer;
},
}));

View File

@@ -3,14 +3,14 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import GlobalStyles from "@mui/material/GlobalStyles";
import { mockScene } from "./mockData";
import { OnSceneChange } from "./renderer/types";
import { OnSceneChange } from "./types";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
const DataLayer = () => {
const onSceneChange = useCallback<OnSceneChange>((event, scene) => {}, []);
const onSceneChange = useCallback<OnSceneChange>(() => {}, []);
return (
<>

View File

@@ -1,4 +1,4 @@
import { ModeContext, Mouse } from "./types";
import { ModeContext, Mouse } from "../types";
export class ModeBase {
ctx;

View File

@@ -1,7 +1,7 @@
import { makeAutoObservable, action } from "mobx";
import { makeAutoObservable } from "mobx";
import { Renderer } from "../renderer/Renderer";
import { ModeBase } from "./ModeBase";
import { Mouse } from "./types";
import type { Mouse, OnSceneChange } from "../types";
export class ModeManager {
// mobx requires all properties to be initialised explicitly (i.e. prop = undefined)
@@ -15,6 +15,7 @@ export class ModeManager {
position: { x: 0, y: 0 },
delta: null,
};
emitEvent?: OnSceneChange;
constructor() {
makeAutoObservable(this);
@@ -24,6 +25,10 @@ export class ModeManager {
this.renderer = renderer;
}
setEventEmitter(fn: OnSceneChange) {
this.emitEvent = fn;
}
activateMode<T extends typeof ModeBase>(
Mode: T,
init?: (instance: InstanceType<T>) => void
@@ -39,6 +44,7 @@ export class ModeManager {
instance: new Mode({
renderer: this.renderer,
activateMode: this.activateMode.bind(this),
emitEvent: this.emitEvent ?? (() => {}),
}),
class: Mode,
};

View File

@@ -1,5 +1,5 @@
import { ModeBase } from "./ModeBase";
import { Mouse } from "./types";
import { Mouse } from "../types";
import { Renderer } from "../renderer/Renderer";
const changeCursor = (cursorType: string, renderer: Renderer) => {

View File

@@ -1,5 +1,5 @@
import { ModeBase } from "./ModeBase";
import { Mouse } from "./types";
import { Mouse } from "../types";
import { getTargetFromSelection } from "./utils";
import { SelectNode } from "./SelectNode";
import { Node } from "../renderer/elements/Node";
@@ -38,7 +38,10 @@ export class Select extends ModeBase {
if (target instanceof Node) {
this.ctx.activateMode(SelectNode, (instance) => (instance.node = target));
return;
}
this.ctx.emitEvent({ type: "GRID_SELECTED" });
}
MOUSE_MOVE(mouse: Mouse) {

View File

@@ -1,10 +1,11 @@
import { ModeBase } from "./ModeBase";
import { Select } from "../modes/Select";
import { Mouse, ModeContext } from "./types";
import { Mouse, ModeContext } from "../types";
import { Node } from "../renderer/elements/Node";
export class SelectNode extends ModeBase {
node?: Node;
hasMoved = false;
entry(mouse: Mouse) {
const tile = this.ctx.renderer.getTileFromMouse(
@@ -28,11 +29,21 @@ export class SelectNode extends ModeBase {
mouse.position.y
);
this.node.moveTo(tile.x, tile.y);
this.ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
if (this.node.position.x !== tile.x || this.node.position.y !== tile.y) {
this.node.moveTo(tile.x, tile.y);
this.ctx.renderer.sceneElements.cursor.displayAt(tile.x, tile.y);
this.hasMoved = true;
}
}
MOUSE_UP() {
if (!this.node) return;
if (!this.hasMoved) {
this.ctx.renderer.sceneElements.nodes.setSelectedNodes([this.node.id]);
} else {
}
this.ctx.activateMode(Select);
}
}

View File

@@ -12,7 +12,7 @@ describe("Mode manager functions correctly", () => {
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 renderer = new Renderer({} as unknown as HTMLDivElement);
const modeManager = new ModeManager();
modeManager.setRenderer(renderer);
modeManager.activateMode(TestMode);

View File

@@ -1,4 +1,4 @@
import { MouseCoords, ModeContext } from "../../types";
import { MouseCoords, ModeContext } from "../../../types";
import { ModeBase } from "../../ModeBase";
export class TestMode extends ModeBase {

View File

@@ -1,23 +0,0 @@
import { Renderer } from "../renderer/Renderer";
import type { ModeManager } from "./ModeManager";
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: ModeManager["activateMode"];
}

View File

@@ -1,4 +1,4 @@
import { makeAutoObservable } from "mobx";
import { makeAutoObservable, toJS } from "mobx";
import Paper, { Group } from "paper";
import gsap from "gsap";
import autobind from "auto-bind";
@@ -8,9 +8,7 @@ import { PROJECTED_TILE_WIDTH, PROJECTED_TILE_HEIGHT } from "./constants";
import { clamp } from "../utils";
import { Nodes } from "./elements/Nodes";
import { SceneI, IconI } from "../validation/SceneSchema";
import { OnSceneChange } from "./types";
import { createSceneEvent, SceneEvent } from "./SceneEvent";
import { mockScene } from "../mockData";
import { OnSceneChange, SceneEventI } from "../types";
interface Config {
grid: {
@@ -31,9 +29,8 @@ export class Renderer {
},
icons: [],
};
createSceneEvent: ReturnType<typeof createSceneEvent>;
callbacks: {
onSceneChange: OnSceneChange;
emitEvent: OnSceneChange;
};
groups: {
container: paper.Group;
@@ -54,21 +51,19 @@ export class Renderer {
};
rafRef?: number;
constructor(containerEl: HTMLDivElement, onChange: OnSceneChange) {
constructor(containerEl: HTMLDivElement) {
makeAutoObservable(this);
autobind(this);
this.createSceneEvent = createSceneEvent(this.onSceneChange);
this.callbacks = {
onSceneChange: onChange,
};
Paper.settings = {
insertelements: false,
applyMatrix: false,
};
this.callbacks = {
emitEvent: () => {},
};
this.domElements = {
container: containerEl,
...this.initDOM(containerEl),
@@ -106,18 +101,16 @@ export class Renderer {
init() {}
loadScene(scene: SceneI) {
const sceneEvent = this.createSceneEvent({
type: "SCENE_LOAD",
});
setEventHandler(eventHandler: OnSceneChange) {
this.callbacks.emitEvent = eventHandler;
}
loadScene(scene: SceneI) {
this.config.icons = scene.icons;
scene.nodes.forEach((node) => {
this.sceneElements.nodes.addNode(node, sceneEvent);
this.sceneElements.nodes.addNode(node);
});
sceneEvent.complete();
}
getIconById(id: string) {
@@ -192,6 +185,33 @@ export class Renderer {
};
}
getTileScreenPosition(x: number, y: number) {
const { width: viewW, height: viewH } = Paper.view.bounds;
const { offsetLeft: offsetX, offsetTop: offsetY } = this.domElements.canvas;
const tilePosition = this.getTileBounds(x, y).center;
const globalItemsGroupPosition = this.groups.elements.globalToLocal([0, 0]);
const screenPosition = {
x:
(tilePosition.x +
this.scrollPosition.x +
globalItemsGroupPosition.x +
this.groups.elements.position.x +
viewW * 0.5) *
this.zoom +
offsetX,
y:
(tilePosition.y +
this.scrollPosition.y +
globalItemsGroupPosition.y +
this.groups.elements.position.y +
viewH * 0.5) *
this.zoom +
offsetY,
};
return screenPosition;
}
setGrid(width: number, height: number) {}
setZoom(zoom: number) {
@@ -202,6 +222,11 @@ export class Renderer {
duration: 0.3,
zoom: this.zoom,
});
this.emitEvent({
type: "ZOOM_CHANGED",
data: { level: zoom },
});
}
scrollTo(x: number, y: number) {
@@ -247,7 +272,7 @@ export class Renderer {
}
}
exportScene() {
exportScene(): SceneI {
const exported = {
icons: this.config.icons,
nodes: this.sceneElements.nodes.export(),
@@ -258,17 +283,13 @@ export class Renderer {
return exported;
}
onSceneChange(sceneEvent: SceneEvent) {
this.callbacks.onSceneChange(sceneEvent.event, this.exportScene());
emitEvent(event: SceneEventI) {
this.callbacks.emitEvent(event);
}
getItemsByTile(x: number, y: number) {
const node = this.nodes.getNodeByTile(x, y);
const node = this.sceneElements.nodes.getNodeByTile(x, y);
return [node].filter((i) => Boolean(i));
}
get nodes() {
return this.sceneElements.nodes;
}
}

View File

@@ -1,5 +1,5 @@
import { Group } from "paper";
import { Context } from "./types";
import { Context } from "../types";
export class SceneElement {
container = new Group();

View File

@@ -1,5 +1,5 @@
import cuid from "cuid";
import { SceneEventI } from "./types";
import { SceneEventI } from "../types";
type OnSceneEventComplete = (event: SceneEvent) => void;

View File

@@ -7,7 +7,7 @@ import {
getBoundingBox,
getTileBounds,
} from "../utils/gridHelpers";
import type { Context, Coords } from "../types";
import type { Context, Coords } from "../../types";
import { SceneElement } from "../SceneElement";
import { tweenPosition } from "../../utils";

View File

@@ -1,6 +1,6 @@
import { Group, Path, Point } from "paper";
import { applyProjectionMatrix } from "../utils/projection";
import type { Context } from "../types";
import type { Context } from "../../types";
import { TILE_SIZE, PIXEL_UNIT, SCALING_CONST } from "../constants";
import { SceneElement } from "../SceneElement";

View File

@@ -1,7 +1,6 @@
import { makeAutoObservable } from "mobx";
import { Group, Raster } from "paper";
import { Coords, Context } from "../types";
import { IconI } from "../../validation/SceneSchema";
import { Coords, Context } from "../../types";
import { PROJECTED_TILE_WIDTH, PIXEL_UNIT } from "../constants";
const NODE_IMG_PADDING = 0 * PIXEL_UNIT;
@@ -14,6 +13,7 @@ export interface NodeOptions {
interface Callbacks {
onMove: (x: number, y: number, node: Node) => void;
onDestroy: (node: Node) => void;
}
export class Node {
@@ -21,6 +21,7 @@ export class Node {
container = new Group();
id;
selected = false;
callbacks: Callbacks;
position;
icon;
@@ -41,6 +42,8 @@ export class Node {
this.renderElements.iconContainer.addChild(this.renderElements.icon);
this.container.addChild(this.renderElements.iconContainer);
this.destroy = this.destroy.bind(this);
this.init();
}
@@ -87,5 +90,6 @@ export class Node {
destroy() {
this.container.remove();
this.callbacks.onDestroy(this);
}
}

View File

@@ -1,11 +1,9 @@
import { Group } from "paper";
import gsap from "gsap";
import autobind from "auto-bind";
import { makeAutoObservable } from "mobx";
import { Context } from "../types";
import { makeAutoObservable, toJS } from "mobx";
import { Context } from "../../types";
import { Node, NodeOptions } from "./Node";
import cuid from "cuid";
import { SceneElement } from "../SceneElement";
import { SceneEvent } from "../SceneEvent";
import { tweenPosition } from "../../utils";
@@ -13,6 +11,7 @@ export class Nodes {
ctx: Context;
container = new Group();
nodes: Node[] = [];
selected: Node[] = [];
constructor(ctx: Context) {
makeAutoObservable(this);
@@ -30,24 +29,23 @@ export class Nodes {
},
{
onMove: this.onMove.bind(this),
onDestroy: this.onDestroy.bind(this),
}
);
this.nodes.push(node);
this.container.addChild(node.container);
this.ctx
.createSceneEvent(
{
type: "NODE_CREATED",
node,
},
sceneEvent
)
.complete();
this.ctx.emitEvent({
type: "NODE_CREATED",
data: { node: node.id },
});
}
onMove(x: number, y: number, node: Node, opts?: { skipAnimation: boolean }) {
const from = node.position;
const to = { x, y };
const tile = this.ctx.getTileBounds(x, y);
node.position = {
x,
@@ -58,6 +56,25 @@ export class Nodes {
...tile.bottom,
duration: opts?.skipAnimation ? 0 : 0.05,
});
this.ctx.emitEvent({
type: "NODE_MOVED",
data: { node: node.id, from, to },
});
}
onDestroy(node: Node) {
const id = node.id;
const nodeIndex = this.nodes.indexOf(node);
if (nodeIndex === -1) return;
this.nodes.splice(nodeIndex, 1);
this.ctx.emitEvent({
type: "NODE_REMOVED",
data: { node: id },
});
}
getNodeById(id: string) {
@@ -75,6 +92,17 @@ export class Nodes {
this.nodes = [];
}
setSelectedNodes(ids: string[]) {
this.nodes.forEach((node) => {
node.selected = ids.includes(node.id);
});
this.ctx.emitEvent({
type: "NODES_SELECTED",
data: { nodes: ids },
});
}
export() {
const exported = this.nodes.map((node) => node.export());
return exported;

View File

@@ -1,28 +0,0 @@
import type { Renderer } from "./Renderer";
import { SceneI } from "../validation/SceneSchema";
import type { Node } from "./elements/Node";
export interface Coords {
x: number;
y: number;
}
export type GeneralEventI = {
type: "SCENE_LOAD";
};
export type NodeEventI =
| {
type: "NODE_CREATED";
node: Node;
}
| {
type: "NODE_REMOVED";
node: Node;
};
export type SceneEventI = NodeEventI | GeneralEventI;
export type Context = Renderer;
export type OnSceneChange = (event: SceneEventI, scene: SceneI) => void;

View File

@@ -1,5 +1,5 @@
import { PROJECTED_TILE_HEIGHT, PROJECTED_TILE_WIDTH } from "../constants";
import { Coords } from "../types";
import { Coords } from "../../types";
// Iterates over every item in a 2 dimensional array
// const tileIterator = (w, h, cb) => {

81
src/types.ts Normal file
View File

@@ -0,0 +1,81 @@
import { Renderer } from "./renderer/Renderer";
import type { ModeManager } from "./modes/ModeManager";
import type { Node } from "./renderer/elements/Node";
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: ModeManager["activateMode"];
emitEvent: OnSceneChange;
}
export interface Coords {
x: number;
y: number;
}
export type GeneralEventI = {
type: "SCENE_LOAD";
data: {};
};
export type NodeEventI =
// Grid Events
| {
type: "GRID_SELECTED";
}
// Node Events
| {
type: "NODE_CREATED";
data: {
node: string;
};
}
| {
type: "NODE_REMOVED";
data: {
node: string;
};
}
| {
type: "NODES_SELECTED";
data: {
nodes: string[];
};
}
| {
type: "NODE_MOVED";
data: {
node: string;
from: Coords;
to: Coords;
};
}
// Utility Events
| {
type: "ZOOM_CHANGED";
data: {
level: number;
};
};
export type SceneEventI = NodeEventI | GeneralEventI;
export type Context = Renderer;
export type OnSceneChange = (event: SceneEventI) => void;