move states into editor

This commit is contained in:
sfja 2026-06-10 05:32:56 +02:00
parent 9a1894c6b5
commit ae14bc3724
2 changed files with 409 additions and 415 deletions

View File

@ -2,20 +2,21 @@ import { Board, ComponentRepo } from "./Board";
import { SelectionBox } from "./SelectionBox"; import { SelectionBox } from "./SelectionBox";
import { ComponentPlacer } from "./ComponentPlacer"; import { ComponentPlacer } from "./ComponentPlacer";
import { Selection } from "./Selection"; import { Selection } from "./Selection";
import { ConnectingWire } from "./ConnectingWire"; import { ConnectingWire, type ConnectingWireKind } from "./ConnectingWire";
import { EventBus } from "./events"; import { EventBus } from "./events";
import { Mouse } from "./Mouse"; import { Mouse } from "./Mouse";
import { Renderer } from "./Renderer"; import { Renderer } from "./Renderer";
import * as states from "./states"; import { v2, type V2 } from "./V2";
import type { V2 } from "./V2";
import { ViewPos } from "./ViewPos"; import { ViewPos } from "./ViewPos";
import { type ComponentKind } from "./Board";
import type { EventUnsub } from "./events";
export class Editor { export class Editor {
public events = new EventBus(); public events = new EventBus();
public viewpos = new ViewPos(this.events); public viewpos = new ViewPos(this.events);
private renderNeeded = false; private renderNeeded = false;
private state = new states.Normal(this) as states.State; private state: State = new Normal(this);
public selectionBox: SelectionBox | null = null; public selectionBox: SelectionBox | null = null;
private componentPlacer: ComponentPlacer | null = null; private componentPlacer: ComponentPlacer | null = null;
@ -71,22 +72,22 @@ export class Editor {
private onSelectTool(tool: string) { private onSelectTool(tool: string) {
switch (tool) { switch (tool) {
case "pan": case "pan":
this.transitionTo(new states.Panning(this)); this.transitionTo(new Panning(this));
break; break;
case "input": case "input":
case "output": case "output":
case "and": case "and":
case "or": case "or":
case "not": case "not":
this.transitionTo(new states.Placing(this, tool)); this.transitionTo(new Placing(this, tool));
break; break;
default: default:
this.transitionTo(new states.Normal(this)); this.transitionTo(new Normal(this));
} }
this.events.send({ tag: "ShowSelectedTool", tool }); this.events.send({ tag: "ShowSelectedTool", tool });
} }
transitionTo(newState: states.State) { transitionTo(newState: State) {
this.state.leave(); this.state.leave();
this.state = newState; this.state = newState;
// console.log(`Entering state ${newState.constructor.name}`); // console.log(`Entering state ${newState.constructor.name}`);
@ -122,3 +123,403 @@ export class Editor {
return ["select", "pan", "input", "output", "and", "or", "not"]; return ["select", "pan", "input", "output", "and", "or", "not"];
} }
} }
interface State {
leave(): void;
enter(): void;
}
class Normal implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseMoveOffset", "MouseDragBegin", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDownOffset":
this.onMouseDown(ev.pos);
break;
case "MouseMoveOffset":
this.cx.board.updateMouseHover(ev.pos);
break;
case "MouseDragBegin": {
this.cx.selectionBox = new SelectionBox(ev.pos, ev.deltaPos);
this.cx.transitionTo(new SelectingBox(this.cx));
break;
}
case "KeyDown": {
if (ev.key === "Shift") {
this.cx.transitionTo(new Panning(this.cx));
return;
}
break;
}
}
},
);
this.cx.events.send({ tag: "ShowSelectedTool", tool: "select" });
this.cx.runSimulation();
}
leave(): void {
this.unsubscribe();
}
private onMouseDown(pos: V2): void {
this.cx.board.handleMouseClick(pos, {
onInputPinClicked: (comp, i) => {
this.cx.connectingWire = new ConnectingWire(
{ tag: "InputPin", comp, i },
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
},
onOutputPinClicked: (comp, i) => {
this.cx.connectingWire = new ConnectingWire(
{ tag: "OutputPin", comp, i },
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
},
onComponentClicked: (comp) => {
this.cx.selection = new Selection();
this.cx.selection.addComponent(comp);
this.cx.transitionTo(new Selecting(this.cx));
},
onJointClicked: (joint) => {
if (this.cx.keysPressed.has("Control")) {
this.cx.selection = new Selection();
this.cx.selection.addJoint(joint);
this.cx.transitionTo(new Selecting(this.cx));
} else {
this.cx.connectingWire = new ConnectingWire(
{ tag: "Joint", joint },
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
}
},
});
}
}
class Panning implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDragBegin", "MouseDrag", "KeyDown", "KeyUp"],
(ev) => {
switch (ev.tag) {
case "MouseDragBegin":
case "MouseDrag":
this.cx.viewpos.move(ev.deltaPos);
break;
case "KeyDown": {
if (ev.key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
break;
}
break;
}
case "KeyUp": {
if (ev.key === "Shift") {
this.cx.transitionTo(new Normal(this.cx));
break;
}
break;
}
}
},
);
this.cx.events.send({ tag: "ShowSelectedTool", tool: "pan" });
}
leave(): void {
this.unsubscribe();
}
}
class Placing implements State {
private unsubscribe!: EventUnsub;
private compDef: ComponentKind;
constructor(
private cx: Editor,
private tool: string,
) {
this.compDef = this.cx.componentRepo.get(this.tool);
}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseMove", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDownOffset": {
const boardPos = ev.pos;
if (this.cx.board.canPlaceComponent(this.compDef, boardPos)) {
this.cx.board.placeComponent(this.compDef, boardPos);
this.cx.transitionTo(new Normal(this.cx));
}
break;
}
case "MouseMove":
this.cx.setComponentPlacerPos(ev.pos);
break;
case "KeyDown": {
if (ev.key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
break;
}
break;
}
}
},
);
this.cx.addComponentPlacer(v2(0, 0), this.compDef.size);
}
leave(): void {
this.cx.removeComponentPlacer();
this.unsubscribe();
}
}
class Selecting implements State {
private unsubscribe!: EventUnsub;
private isMouseDown = false;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseUp", "MouseMoveOffset", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDownOffset":
this.onMouseDown(ev.pos, ev.absPos);
break;
case "MouseUp":
this.isMouseDown = false;
break;
case "MouseMoveOffset": {
this.cx.board.updateMouseHover(ev.pos);
if (this.isMouseDown) {
this.cx.transitionTo(new Moving(this.cx));
}
break;
}
case "KeyDown": {
if (ev.key === "Delete") {
if (!this.cx.selection) {
throw new Error("expected selection");
}
this.cx.board.deleteSelection(this.cx.selection);
this.cx.selection = null;
this.cx.transitionTo(new Normal(this.cx));
}
break;
}
}
},
);
}
leave(): void {
this.unsubscribe();
}
private onMouseDown(pos: V2, absPos: V2): void {
if (
this.cx.board.handleMouseClick(pos, {
onComponentClicked: (comp) => {
if (this.cx.keysPressed.has("Control")) {
this.cx.selection?.toggleComponent(comp);
} else if (!this.cx.selection?.isComponentSelected(comp)) {
this.cx.selection = new Selection();
this.cx.selection.addComponent(comp);
}
},
onJointClicked: (joint) => {
if (this.cx.keysPressed.has("Control")) {
this.cx.selection?.toggleJoint(joint);
} else if (!this.cx.selection?.isJointSelected(joint)) {
this.cx.selection = new Selection();
this.cx.selection.addJoint(joint);
}
},
}) !== "handled"
) {
if (this.cx.keysPressed.has("Control")) {
this.cx.selectionBox = new SelectionBox(absPos);
this.cx.transitionTo(new SelectingBox(this.cx));
} else {
this.cx.selection = null;
this.cx.selectionBox = new SelectionBox(absPos);
this.cx.transitionTo(new SelectingBox(this.cx));
}
}
this.isMouseDown = true;
}
}
class Moving implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseUp", "MouseMove"],
(ev) => {
switch (ev.tag) {
case "MouseUp":
this.cx.transitionTo(new Selecting(this.cx));
break;
case "MouseMove":
this.cx.selection?.move(ev.deltaPos);
break;
}
},
);
}
leave(): void {
this.unsubscribe();
}
}
class SelectingBox implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseUp", "MouseMove"],
(ev) => {
switch (ev.tag) {
case "MouseUp":
this.onMouseUp(ev.pos);
break;
case "MouseMove":
this.cx.selectionBox?.move(ev.deltaPos);
break;
}
},
);
}
leave(): void {
this.unsubscribe();
}
private onMouseUp(_pos: V2): void {
if (!this.cx.selectionBox) {
throw new Error("expected selectionBox to active");
}
const { pos, size } = this.cx.selectionBox.boardRect(this.cx.viewpos);
const components = this.cx.board.componentsInRect(pos, size);
const joints = this.cx.board.jointsInRect(pos, size);
if (components.length > 0 || joints.length > 0) {
this.cx.selection ??= new Selection();
}
for (const comp of components) {
this.cx.selection?.addComponent(comp);
}
for (const joint of joints) {
this.cx.selection?.addJoint(joint);
}
if (this.cx.selection) {
this.cx.selectionBox = null;
this.cx.transitionTo(new Selecting(this.cx));
} else {
this.cx.selectionBox = null;
this.cx.transitionTo(new Normal(this.cx));
}
}
}
class Wiring implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseMoveOffset", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDownOffset":
this.onMouseDown(ev.pos);
break;
case "MouseMoveOffset": {
if (!this.cx.connectingWire) {
throw new Error("expected connectingWire to be active");
}
this.cx.connectingWire.move(ev.pos);
this.cx.board.updateMouseHover(ev.pos);
break;
}
case "KeyDown": {
if (ev.key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
this.cx.connectingWire = null;
return;
}
break;
}
}
},
);
}
leave(): void {
this.unsubscribe();
}
private onMouseDown(pos: V2): void {
if (
this.cx.board.handleMouseClick(pos, {
onInputPinClicked: (comp, i) => {
this.cx.connectingWire!.connectToInput(this.cx.board, comp, i);
this.cx.connectingWire = null;
this.cx.transitionTo(new Normal(this.cx));
},
onOutputPinClicked: (comp, i) => {
this.cx.connectingWire!.connectToOutput(this.cx.board, comp, i);
this.cx.connectingWire = null;
this.cx.transitionTo(new Normal(this.cx));
},
onJointClicked: (joint) => {
this.cx.connectingWire!.connectToJoint(this.cx.board, joint);
this.cx.connectingWire = null;
this.cx.transitionTo(new Normal(this.cx));
},
}) !== "handled"
) {
const kind: ConnectingWireKind = {
tag: "Intermediary",
prev: this.cx.connectingWire!,
pos,
};
this.cx.connectingWire = new ConnectingWire(kind, pos);
}
}
}

View File

@ -1,407 +0,0 @@
import { Component, Joint, type ComponentKind } from "./Board";
import { Selection } from "./Selection";
import { ConnectingWire, type ConnectingWireKind } from "./ConnectingWire";
import { SelectionBox } from "./SelectionBox";
import type { Editor } from "./Editor";
import type { EventUnsub } from "./events";
import { v2, type V2 } from "./V2";
export interface State {
leave(): void;
enter(): void;
}
export class Normal implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseMoveOffset", "MouseDragBegin", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDownOffset":
this.onMouseDown(ev.pos);
break;
case "MouseMoveOffset":
this.cx.board.updateMouseHover(ev.pos);
break;
case "MouseDragBegin": {
this.cx.selectionBox = new SelectionBox(ev.pos, ev.deltaPos);
this.cx.transitionTo(new SelectingBox(this.cx));
break;
}
case "KeyDown": {
if (ev.key === "Shift") {
this.cx.transitionTo(new Panning(this.cx));
return;
}
break;
}
}
},
);
this.cx.events.send({ tag: "ShowSelectedTool", tool: "select" });
this.cx.runSimulation();
}
leave(): void {
this.unsubscribe();
}
private onMouseDown(pos: V2): void {
this.cx.board.handleMouseClick(pos, {
onInputPinClicked: (comp, i) => {
this.cx.connectingWire = new ConnectingWire(
{ tag: "InputPin", comp, i },
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
},
onOutputPinClicked: (comp, i) => {
this.cx.connectingWire = new ConnectingWire(
{ tag: "OutputPin", comp, i },
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
},
onComponentClicked: (comp) => {
this.cx.selection = new Selection();
this.cx.selection.addComponent(comp);
this.cx.transitionTo(new Selecting(this.cx));
},
onJointClicked: (joint) => {
if (this.cx.keysPressed.has("Control")) {
this.cx.selection = new Selection();
this.cx.selection.addJoint(joint);
this.cx.transitionTo(new Selecting(this.cx));
} else {
this.cx.connectingWire = new ConnectingWire(
{ tag: "Joint", joint },
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
}
},
});
}
}
export class Panning implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDragBegin", "MouseDrag", "KeyDown", "KeyUp"],
(ev) => {
switch (ev.tag) {
case "MouseDragBegin":
case "MouseDrag":
this.cx.viewpos.move(ev.deltaPos);
break;
case "KeyDown": {
if (ev.key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
break;
}
break;
}
case "KeyUp": {
if (ev.key === "Shift") {
this.cx.transitionTo(new Normal(this.cx));
break;
}
break;
}
}
},
);
this.cx.events.send({ tag: "ShowSelectedTool", tool: "pan" });
}
leave(): void {
this.unsubscribe();
}
}
export class Placing implements State {
private unsubscribe!: EventUnsub;
private compDef: ComponentKind;
constructor(
private cx: Editor,
private tool: string,
) {
this.compDef = this.cx.componentRepo.get(this.tool);
}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseMove", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDownOffset": {
const boardPos = ev.pos;
if (this.cx.board.canPlaceComponent(this.compDef, boardPos)) {
this.cx.board.placeComponent(this.compDef, boardPos);
this.cx.transitionTo(new Normal(this.cx));
}
break;
}
case "MouseMove":
this.cx.setComponentPlacerPos(ev.pos);
break;
case "KeyDown": {
if (ev.key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
break;
}
break;
}
}
},
);
this.cx.addComponentPlacer(v2(0, 0), this.compDef.size);
}
leave(): void {
this.cx.removeComponentPlacer();
this.unsubscribe();
}
}
export class Selecting implements State {
private unsubscribe!: EventUnsub;
private isMouseDown = false;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseUp", "MouseMoveOffset", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDownOffset":
this.onMouseDown(ev.pos, ev.absPos);
break;
case "MouseUp":
this.isMouseDown = false;
break;
case "MouseMoveOffset": {
this.cx.board.updateMouseHover(ev.pos);
if (this.isMouseDown) {
this.cx.transitionTo(new Moving(this.cx));
}
break;
}
case "KeyDown": {
if (ev.key === "Delete") {
if (!this.cx.selection) {
throw new Error("expected selection");
}
this.cx.board.deleteSelection(this.cx.selection);
this.cx.selection = null;
this.cx.transitionTo(new Normal(this.cx));
}
break;
}
}
},
);
}
leave(): void {
this.unsubscribe();
}
private onMouseDown(pos: V2, absPos: V2): void {
if (
this.cx.board.handleMouseClick(pos, {
onComponentClicked: (comp) => {
if (this.cx.keysPressed.has("Control")) {
this.cx.selection?.toggleComponent(comp);
} else if (!this.cx.selection?.isComponentSelected(comp)) {
this.cx.selection = new Selection();
this.cx.selection.addComponent(comp);
}
},
onJointClicked: (joint) => {
if (this.cx.keysPressed.has("Control")) {
this.cx.selection?.toggleJoint(joint);
} else if (!this.cx.selection?.isJointSelected(joint)) {
this.cx.selection = new Selection();
this.cx.selection.addJoint(joint);
}
},
}) !== "handled"
) {
if (this.cx.keysPressed.has("Control")) {
this.cx.selectionBox = new SelectionBox(absPos);
this.cx.transitionTo(new SelectingBox(this.cx));
} else {
this.cx.selection = null;
this.cx.selectionBox = new SelectionBox(absPos);
this.cx.transitionTo(new SelectingBox(this.cx));
}
}
this.isMouseDown = true;
}
}
export class Moving implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseUp", "MouseMove"],
(ev) => {
switch (ev.tag) {
case "MouseUp":
this.cx.transitionTo(new Selecting(this.cx));
break;
case "MouseMove":
this.cx.selection?.move(ev.deltaPos);
break;
}
},
);
}
leave(): void {
this.unsubscribe();
}
}
export class SelectingBox implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseUp", "MouseMove"],
(ev) => {
switch (ev.tag) {
case "MouseUp":
this.onMouseUp(ev.pos);
break;
case "MouseMove":
this.cx.selectionBox?.move(ev.deltaPos);
break;
}
},
);
}
leave(): void {
this.unsubscribe();
}
private onMouseUp(_pos: V2): void {
if (!this.cx.selectionBox) {
throw new Error("expected selectionBox to active");
}
const { pos, size } = this.cx.selectionBox.boardRect(this.cx.viewpos);
const components = this.cx.board.componentsInRect(pos, size);
const joints = this.cx.board.jointsInRect(pos, size);
if (components.length > 0 || joints.length > 0) {
this.cx.selection ??= new Selection();
}
for (const comp of components) {
this.cx.selection?.addComponent(comp);
}
for (const joint of joints) {
this.cx.selection?.addJoint(joint);
}
if (this.cx.selection) {
this.cx.selectionBox = null;
this.cx.transitionTo(new Selecting(this.cx));
} else {
this.cx.selectionBox = null;
this.cx.transitionTo(new Normal(this.cx));
}
}
}
export class Wiring implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Editor) {}
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseMoveOffset", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDownOffset":
this.onMouseDown(ev.pos);
break;
case "MouseMoveOffset": {
if (!this.cx.connectingWire) {
throw new Error("expected connectingWire to be active");
}
this.cx.connectingWire.move(ev.pos);
this.cx.board.updateMouseHover(ev.pos);
break;
}
case "KeyDown": {
if (ev.key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
this.cx.connectingWire = null;
return;
}
break;
}
}
},
);
}
leave(): void {
this.unsubscribe();
}
private onMouseDown(pos: V2): void {
if (
this.cx.board.handleMouseClick(pos, {
onInputPinClicked: (comp, i) => {
this.cx.connectingWire!.connectToInput(this.cx.board, comp, i);
this.cx.connectingWire = null;
this.cx.transitionTo(new Normal(this.cx));
},
onOutputPinClicked: (comp, i) => {
this.cx.connectingWire!.connectToOutput(this.cx.board, comp, i);
this.cx.connectingWire = null;
this.cx.transitionTo(new Normal(this.cx));
},
onJointClicked: (joint) => {
this.cx.connectingWire!.connectToJoint(this.cx.board, joint);
this.cx.connectingWire = null;
this.cx.transitionTo(new Normal(this.cx));
},
}) !== "handled"
) {
const kind: ConnectingWireKind = {
tag: "Intermediary",
prev: this.cx.connectingWire!,
pos,
};
this.cx.connectingWire = new ConnectingWire(kind, pos);
}
}
}