refactor to use new events

This commit is contained in:
sfja 2026-06-10 02:54:59 +02:00
parent cc1ef05b95
commit 9def12cad9
8 changed files with 279 additions and 256 deletions

View File

@ -27,30 +27,30 @@ function Canvas({ editor, canvasRef, width, height }: Props): ReactElement {
tabIndex={0}
onMouseDown={(ev) => {
const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY);
editor.mouseDown(pos);
editor.events.send({ tag: "MouseDown", pos });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}}
onMouseUp={(ev) => {
const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY);
editor.mouseUp(pos);
editor.events.send({ tag: "MouseUp", pos });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}}
onMouseMove={(ev) => {
const deltaPos = v2(ev.movementX, ev.movementY);
const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY);
editor.mouseMove(deltaPos, pos);
editor.events.send({ tag: "MouseMove", pos, deltaPos });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}}
onMouseLeave={(ev) => {
editor.mouseLeave();
editor.events.send({ tag: "MouseLeave" });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}}
onKeyDown={(ev) => {
editor.keyDown(ev.key);
editor.events.send({ tag: "KeyDown", key: ev.key });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}}
onKeyUp={(ev) => {
editor.keyUp(ev.key);
editor.events.send({ tag: "KeyUp", key: ev.key });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}}
/>

View File

@ -3,28 +3,24 @@ import type { Editor } from "./editor/Editor";
type Props = { editor: Editor; canvasRef: RefObject<HTMLCanvasElement | null> };
function useUpdate(): [number, () => void] {
const [value, setValue] = useState(0);
return [value, () => setValue(value + 1)] as const;
}
function Toolbar({ editor, canvasRef }: Props): ReactElement {
const [uid, update] = useUpdate();
const [selectedTool, setSelectedTool] = useState("select");
useEffect(() => {
const handle = editor.addUpdateAction(() => update());
return () => editor.removeUpdateAction(handle);
});
useEffect(() =>
editor.events.subscribe(["ShowSelectedTool"], (ev) => {
setSelectedTool(ev.tool);
}),
);
return (
<>
<div className="Toolbar">
{editor.tools().map((tool, key) => (
<button
key={`${uid}${key}`}
className={editor.selectedTool() === tool ? "active" : ""}
key={`${key}`}
className={selectedTool === tool ? "active" : ""}
onClick={() => {
editor.selectTool(tool);
editor.events.send({ tag: "SelectTool", tool });
canvasRef.current?.focus();
}}
>

View File

@ -18,8 +18,8 @@ export type Tool = string;
export class Cx {
public offset = v2(0, 0);
private renderNeeded = false;
private state = new states.Normal(this) as states.State;
private updateActions: (() => void)[] = [];
public selectionBox: SelectionBox | null = null;
private componentPlacer: ComponentPlacer | null = null;
@ -31,8 +31,30 @@ export class Cx {
public keysPressed = new Set<string>();
public eventBus = new EventBus();
public mouse = new Mouse(this.eventBus);
public mouse: Mouse;
constructor(public events: EventBus) {
this.mouse = new Mouse(this.events);
this.state.enter();
this.events.subscribe(
["MouseDown", "MouseUp", "MouseMove", "KeyDown", "KeyUp", "SelectTool"],
(ev) => {
switch (ev.tag) {
case "KeyDown":
this.keysPressed.add(ev.key);
break;
case "KeyUp":
this.keysPressed.delete(ev.key);
break;
case "SelectTool":
this.onSelectTool(ev.tool);
}
this.renderNeeded = true;
},
);
}
render(canvas: HTMLCanvasElement) {
const r = new Renderer(canvas, this.offset);
@ -52,29 +74,7 @@ export class Cx {
}
}
mouseDown(pos: V2) {
this.state.onMouseDown?.(pos);
this.renderNeeded = true;
}
mouseUp(pos: V2) {
this.state.onMouseUp?.(pos);
this.renderNeeded = true;
}
mouseMove(deltaPos: V2, pos: V2) {
this.state.onMouseMove?.(deltaPos, pos);
this.renderNeeded = true;
}
keyDown(key: string) {
this.keysPressed.add(key);
this.state.onKeyDown?.(key);
this.renderNeeded = true;
}
keyUp(key: string) {
this.keysPressed.delete(key);
this.state.onKeyUp?.(key);
this.renderNeeded = true;
}
selectTool(tool: Tool) {
private onSelectTool(tool: Tool) {
switch (tool) {
case "pan":
this.transitionTo(new states.Panning(this));
@ -89,34 +89,14 @@ export class Cx {
default:
this.transitionTo(new states.Normal(this));
}
}
selectedTool(): Tool {
return this.state.selectedTool?.() || "select";
}
addUpdateAction(action: () => void): object {
this.updateActions.push(action);
return action;
}
removeUpdateAction(actionId: object) {
this.updateActions = this.updateActions.filter(
(action) => action !== actionId,
);
this.events.send({ tag: "ShowSelectedTool", tool });
}
transitionTo(newState: states.State) {
this.state.leaveState?.();
this.state.leave();
this.state = newState;
// console.log(`Entering state ${newState.constructor.name}`);
this.state.enterState?.();
this.notifyListeners();
}
notifyListeners() {
for (const action of this.updateActions) {
action();
}
console.log(`Entering state ${newState.constructor.name}`);
this.state.enter();
}
moveOffset(deltaPos: V2) {

View File

@ -1,8 +1,10 @@
import { Cx, type Tool } from "./Cx";
import { EventBus } from "./events";
import { V2 } from "./V2";
export class Editor {
private cx = new Cx();
public events = new EventBus();
private cx = new Cx(this.events);
render(canvas: HTMLCanvasElement) {
this.cx.render(canvas);
@ -12,50 +14,7 @@ export class Editor {
this.cx.renderIfNeeded(canvas);
}
mouseDown(pos: V2) {
this.cx.eventBus.send({ tag: "MouseDown", pos });
this.cx.mouseDown(pos);
}
mouseUp(pos: V2) {
this.cx.eventBus.send({ tag: "MouseUp", pos });
this.cx.mouseUp(pos);
}
mouseMove(deltaPos: V2, pos: V2) {
this.cx.eventBus.send({ tag: "MouseMove", pos, deltaPos });
this.cx.mouseMove(deltaPos, pos);
}
mouseLeave() {
this.cx.eventBus.send({ tag: "MouseLeave" });
}
keyDown(key: string) {
this.cx.keyDown(key);
}
keyUp(key: string) {
this.cx.keyUp(key);
}
selectTool(tool: Tool) {
this.cx.selectTool(tool);
}
selectedTool(): Tool | null {
return this.cx.selectedTool();
}
tools(): Tool[] {
return ["select", "pan", "input", "output", "and", "or", "not"];
}
addUpdateAction(action: () => void): object {
return this.cx.addUpdateAction(action);
}
removeUpdateAction(actionId: object) {
this.cx.removeUpdateAction(actionId);
}
}

View File

@ -24,8 +24,6 @@ class Normal implements State {
private unsubscribe: EventUnsub;
constructor(private cx: Mouse) {
console.log("Mouse::Normal");
this.unsubscribe = cx.eventBus.subscribe(
["MouseDown", "MouseUp", "MouseMove", "MouseLeave"],
(ev) => {
@ -61,8 +59,6 @@ class FirstPress implements State {
private cx: Mouse,
private pos: V2,
) {
console.log("Mouse::FirstPress");
this.unsubscribe = cx.eventBus.subscribe(
["MouseDown", "MouseUp", "MouseMove", "MouseLeave"],
(ev) => {
@ -112,8 +108,6 @@ class FirstRelease implements State {
private cx: Mouse,
private pos: V2,
) {
console.log("Mouse::FirstRelease");
this.unsubscribe = cx.eventBus.subscribe(
["MouseDown", "MouseUp", "MouseMove", "MouseLeave"],
(ev) => {
@ -121,14 +115,13 @@ class FirstRelease implements State {
case "MouseDown":
this.cx.transitionTo(new SecondPress(this.cx, this.pos));
break;
case "MouseUp":
break;
case "MouseMove":
break;
case "MouseLeave":
this.cx.transitionTo(new Normal(this.cx));
break;
default:
throw new Error("invalid state");
throw new Error(`unexpected event ${ev.tag}`);
}
},
);
@ -153,8 +146,6 @@ class SecondPress implements State {
private cx: Mouse,
private pos: V2,
) {
console.log("Mouse::SecondPress");
this.unsubscribe = cx.eventBus.subscribe(
["MouseDown", "MouseUp", "MouseMove", "MouseLeave"],
(ev) => {
@ -179,7 +170,7 @@ class SecondPress implements State {
this.cx.transitionTo(new Normal(this.cx));
break;
default:
throw new Error("invalid state");
throw new Error(`unexpected event ${ev.tag}`);
}
},
);
@ -194,8 +185,6 @@ class Dragging implements State {
private unsubscribe: EventUnsub;
constructor(private cx: Mouse) {
console.log("Mouse::Dragging");
this.unsubscribe = cx.eventBus.subscribe(
["MouseDown", "MouseUp", "MouseMove", "MouseLeave"],
(ev) => {
@ -217,7 +206,7 @@ class Dragging implements State {
this.cx.transitionTo(new Normal(this.cx));
break;
default:
throw new Error("invalid state");
throw new Error(`unexpected event ${ev.tag}`);
}
},
);

View File

@ -24,6 +24,10 @@ export class V2 {
abs(): V2 {
return new V2(Math.abs(this.x), Math.abs(this.y));
}
toString(): string {
return `V2(${this.x}, ${this.y})`;
}
}
export const v2 = (x: number, y: number): V2 => new V2(x, y);

View File

@ -14,7 +14,8 @@ export type Event =
pos: V2;
deltaPos: V2;
}
| { tag: "MouseDrag" };
| { tag: "KeyDown" | "KeyUp"; key: string }
| { tag: "SelectTool" | "ShowSelectedTool"; tool: string };
export type EventOf<Tag extends Event["tag"]> = Event & { tag: Tag };

View File

@ -1,30 +1,56 @@
import { Component, Joint, type ComponentKind } from "./Board";
import { ConnectingWire, Selection, type ConnectingWireKind } from "./Cx";
import { SelectionBox, type Cx, type Tool } from "./Cx";
import type { EventUnsub } from "./events";
import { v2, type V2 } from "./V2";
export interface State {
enterState?(): void;
leaveState?(): void;
onMouseDown?(pos: V2): void;
onMouseUp?(pos: V2): void;
onMouseMove?(deltaPos: V2, pos: V2): void;
onKeyDown?(key: string): void;
onKeyUp?(key: string): void;
leave(): void;
enter(): void;
selectedTool?(): Tool | null;
}
export class Normal implements State {
private dragStart = v2(0, 0);
private isMouseDown = false;
private unsubscribe!: EventUnsub;
constructor(private cx: Cx) {}
enterState(): void {
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDown", "MouseMove", "MouseDragBegin", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDown":
this._onMouseDown(ev.pos);
break;
case "MouseMove":
this.cx.board.updateMouseHover(ev.pos.sub(this.cx.offset));
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();
}
onMouseDown(pos: V2): void {
leave(): void {
this.unsubscribe();
}
private _onMouseDown(pos: V2): void {
if (
this.cx.board.handleMouseClick(pos.sub(this.cx.offset), {
onInputPinClicked: (comp, i) => {
@ -61,26 +87,6 @@ export class Normal implements State {
},
}) !== "handled"
) {
this.isMouseDown = true;
this.dragStart = pos;
}
}
onMouseMove(_deltaPos: V2, pos: V2): void {
if (this.isMouseDown && this.dragStart.sub(pos).len() > 5) {
this.cx.selectionBox = new SelectionBox(
this.dragStart,
pos.sub(this.dragStart),
);
this.cx.transitionTo(new SelectingBox(this.cx));
}
this.cx.board.updateMouseHover(pos.sub(this.cx.offset));
}
onKeyDown(key: string): void {
if (key === "Shift") {
this.cx.transitionTo(new Panning(this.cx));
return;
}
}
@ -90,36 +96,42 @@ export class Normal implements State {
}
export class Panning implements State {
private dragging = false;
private unsubscribe!: EventUnsub;
constructor(private cx: Cx) {}
onMouseDown(_pos: V2): void {
this.dragging = true;
}
onMouseUp(_pos: V2): void {
this.dragging = false;
}
onMouseMove(deltaPos: V2): void {
if (this.dragging) {
this.cx.moveOffset(deltaPos);
}
}
onKeyDown(key: string): void {
if (key === "Escape") {
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDragBegin", "MouseDrag", "KeyDown", "KeyUp"],
(ev) => {
switch (ev.tag) {
case "MouseDragBegin":
case "MouseDrag":
this.cx.moveOffset(ev.deltaPos);
break;
case "KeyDown": {
if (ev.key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
return;
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" });
}
onKeyUp(key: string): void {
if (key === "Shift") {
this.cx.transitionTo(new Normal(this.cx));
return;
}
leave(): void {
this.unsubscribe();
}
selectedTool(): Tool | null {
@ -128,6 +140,8 @@ export class Panning implements State {
}
export class Placing implements State {
private unsubscribe!: EventUnsub;
private compDef: ComponentKind;
constructor(
@ -137,31 +151,39 @@ export class Placing implements State {
this.compDef = this.cx.componentRepo.get(this.tool);
}
enterState(): void {
this.cx.addComponentPlacer(v2(0, 0), this.compDef.size);
}
leaveState(): void {
this.cx.removeComponentPlacer();
}
onMouseDown(pos: V2): void {
const boardPos = this.cx.canvasPosToBoard(pos);
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDown", "MouseMove", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDown": {
const boardPos = this.cx.canvasPosToBoard(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;
}
onMouseMove(_deltaPos: V2, pos: V2): void {
this.cx.setComponentPlacerPos(pos);
}
onKeyDown(key: string): void {
if (key === "Escape") {
case "MouseMove":
this.cx.setComponentPlacerPos(ev.pos);
break;
case "KeyDown": {
if (ev.key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
return;
break;
}
break;
}
}
},
);
this.cx.addComponentPlacer(v2(0, 0), this.compDef.size);
}
leave(): void {
this.cx.removeComponentPlacer();
this.unsubscribe();
}
selectedTool(): Tool | null {
@ -170,11 +192,51 @@ export class Placing implements State {
}
export class Selecting implements State {
private unsubscribe!: EventUnsub;
private isMouseDown = false;
constructor(private cx: Cx) {}
onMouseDown(pos: V2): void {
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDown", "MouseUp", "MouseMove", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDown":
this.onMouseDown(ev.pos);
break;
case "MouseUp":
this.isMouseDown = false;
break;
case "MouseMove": {
this.cx.board.updateMouseHover(ev.pos.sub(this.cx.offset));
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): void {
if (
this.cx.board.handleMouseClick(pos.sub(this.cx.offset), {
onComponentClicked: (comp) => {
@ -207,46 +269,60 @@ export class Selecting implements State {
this.isMouseDown = true;
}
onMouseUp(_pos: V2): void {
this.isMouseDown = false;
}
onMouseMove(_deltaPos: V2, pos: V2): void {
this.cx.board.updateMouseHover(pos.sub(this.cx.offset));
if (this.isMouseDown) {
this.cx.transitionTo(new Moving(this.cx));
}
}
onKeyDown(key: string): void {
if (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));
}
}
}
export class Moving implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Cx) {}
onMouseUp(_pos: V2): void {
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;
}
},
);
}
onMouseMove(deltaPos: V2, _pos: V2): void {
this.cx.selection?.move(deltaPos);
leave(): void {
this.unsubscribe();
}
}
export class SelectingBox implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Cx) {}
onMouseUp(_pos: V2): void {
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");
}
@ -278,19 +354,50 @@ export class SelectingBox implements State {
}
}
onMouseMove(deltaPos: V2): void {
this.cx.selectionBox?.move(deltaPos);
}
selectedTool(): Tool | null {
return "select";
}
}
export class Wiring implements State {
private unsubscribe!: EventUnsub;
constructor(private cx: Cx) {}
onMouseDown(pos: V2): void {
enter(): void {
this.unsubscribe = this.cx.events.subscribe(
["MouseDown", "MouseMove", "KeyDown"],
(ev) => {
switch (ev.tag) {
case "MouseDown":
this.onMouseDown(ev.pos);
break;
case "MouseMove": {
if (!this.cx.connectingWire) {
throw new Error("expected connectingWire to be active");
}
this.cx.connectingWire.move(ev.pos.sub(this.cx.offset));
this.cx.board.updateMouseHover(ev.pos.sub(this.cx.offset));
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.sub(this.cx.offset), {
onInputPinClicked: (comp, i) => {
@ -315,23 +422,10 @@ export class Wiring implements State {
prev: this.cx.connectingWire!,
pos: pos.sub(this.cx.offset),
};
this.cx.connectingWire = new ConnectingWire(kind, pos);
}
}
onMouseMove(_deltaPos: V2, pos: V2): void {
if (!this.cx.connectingWire) {
throw new Error("expected connectingWire to be active");
}
this.cx.connectingWire.move(pos.sub(this.cx.offset));
this.cx.board.updateMouseHover(pos.sub(this.cx.offset));
}
onKeyDown(key: string): void {
if (key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
this.cx.connectingWire = null;
return;
this.cx.connectingWire = new ConnectingWire(
kind,
pos.sub(this.cx.offset),
);
}
}
}