selecting and wiring

This commit is contained in:
sfja 2026-05-20 03:11:40 +02:00
parent 2a172f687c
commit 0ec608ee54
5 changed files with 431 additions and 196 deletions

View File

@ -11,12 +11,18 @@ export default defineConfig([
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommendedTypeChecked,
reactHooks.configs.flat.recommended, reactHooks.configs.flat.recommended,
reactRefresh.configs.vite, reactRefresh.configs.vite,
], ],
languageOptions: { languageOptions: {
globals: globals.browser, globals: globals.browser,
parserOptions: {
projectService: true,
}
}, },
rules: {
"@typescript-eslint/no-unused-vars": "off"
}
}, },
]) ])

View File

@ -1,25 +1,61 @@
import type { Selection } from "./Cx";
import type { Renderer } from "./Renderer"; import type { Renderer } from "./Renderer";
import { pointInsideRect, rectsCollide, v2, V2 } from "./V2"; import { pointInsideRect, rectsCollide, v2, V2 } from "./V2";
export class Board { export class Board {
private components: Component[] = []; private components: PlacedComponent[] = [];
private hoveredOverInput: [Component, number] | null = null; private hoveredOverInput: [PlacedComponent, number] | null = null;
private hoveredOverOutput: [Component, number] | null = null; private hoveredOverOutput: [PlacedComponent, number] | null = null;
canPlaceComponent(def: ComponentDef, pos: V2): boolean { constructor() {}
canPlaceComponent(kind: ComponentKind, pos: V2): boolean {
return !this.components.some((comp) => return !this.components.some((comp) =>
rectsCollide(comp.pos, comp.def.size, pos, def.size), rectsCollide(comp.pos, comp.kind.size, pos, kind.size),
); );
} }
placeComponent(def: ComponentDef, pos: V2) { placeComponent(kind: ComponentKind, pos: V2) {
this.components.push({ def, pos }); this.components.push({ kind: kind, pos });
} }
render(r: Renderer) { render(r: Renderer, selection: Selection | null) {
for (const comp of this.components) { for (const comp of this.components) {
r.drawComponent(comp, this.hoveredOverInput, this.hoveredOverOutput); const { pos, kind } = comp;
if (selection?.isComponentSelected(comp)) {
r.drawComponentBodySelected(pos, kind);
} else {
r.drawComponentBody(pos, kind);
}
for (const { i, pinOffset } of kind.inputPinIter()) {
if (kind.inputs[i] !== null) {
throw new Error("pin text not implemented");
}
r.drawComponentInputPin(pos, pinOffset);
if (
this.hoveredOverInput?.[0] === comp &&
this.hoveredOverInput[1] === i
) {
r.drawComponentInputPinHover(pos, pinOffset);
}
}
for (const { i, pinOffset } of kind.outputPinIter()) {
if (kind.outputs[i] !== null) {
throw new Error("pin text not implemented");
}
r.drawComponentOutputPin(pos, kind, pinOffset);
if (
this.hoveredOverOutput?.[0] === comp &&
this.hoveredOverOutput[1] === i
) {
r.drawComponentOutputPinHover(pos, kind, pinOffset);
}
}
} }
} }
@ -30,10 +66,8 @@ export class Board {
for (const comp of this.components) { for (const comp of this.components) {
const { const {
pos: { x, y }, pos: { x, y },
def: { kind: {
size: { x: w, y: h }, size: { x: w },
inputs,
outputs,
}, },
} = comp; } = comp;
@ -41,43 +75,35 @@ export class Board {
!pointInsideRect( !pointInsideRect(
pos, pos,
comp.pos.sub(v2(5, 5)), comp.pos.sub(v2(5, 5)),
comp.def.size.add(v2(10, 10)), comp.kind.size.add(v2(10, 10)),
) )
) { ) {
continue; continue;
} }
{ for (const { i, pinOffset } of comp.kind.inputPinIter()) {
const pinSpace = h / (inputs.length + 1); if (v2(x, y + pinOffset).distance(pos) < 6) {
for (let i = 0; i < inputs.length; ++i) {
if (v2(x, y + (i + 1) * pinSpace).distance(pos) < 5) {
this.hoveredOverInput = [comp, i]; this.hoveredOverInput = [comp, i];
} }
} }
} for (const { i, pinOffset } of comp.kind.outputPinIter()) {
{ if (v2(x + w, y + pinOffset).distance(pos) < 6) {
const pinSpace = h / (outputs.length + 1);
for (let i = 0; i < outputs.length; ++i) {
if (v2(x + w, y + (i + 1) * pinSpace).distance(pos) < 5) {
this.hoveredOverOutput = [comp, i]; this.hoveredOverOutput = [comp, i];
} }
} }
} }
} }
}
handleMouseClick( handleMouseClick(
pos: V2, pos: V2,
inputPinClicked: (comp: Component, i: number) => void, inputPinClicked: (comp: PlacedComponent, i: number) => void,
outputPinClicked: (comp: Component, i: number) => void, outputPinClicked: (comp: PlacedComponent, i: number) => void,
componentClicked: (comp: Component) => void, componentClicked: (comp: PlacedComponent) => void,
): "handled" | "not handled" { ): "handled" | "not handled" {
for (const comp of this.components) { for (const comp of this.components) {
const { const {
pos: { x, y }, pos: { x, y },
def: { kind: {
size: { x: w, y: h }, size: { x: w },
inputs,
outputs,
}, },
} = comp; } = comp;
@ -85,97 +111,118 @@ export class Board {
!pointInsideRect( !pointInsideRect(
pos, pos,
comp.pos.sub(v2(5, 5)), comp.pos.sub(v2(5, 5)),
comp.def.size.add(v2(10, 10)), comp.kind.size.add(v2(10, 10)),
) )
) { ) {
continue; continue;
} }
{ for (const { i, pinOffset } of comp.kind.inputPinIter()) {
const pinSpace = h / (inputs.length + 1); if (v2(x, y + pinOffset).distance(pos) < 6) {
for (let i = 0; i < inputs.length; ++i) {
if (v2(x, y + (i + 1) * pinSpace).distance(pos) < 5) {
inputPinClicked(comp, i); inputPinClicked(comp, i);
return "handled"; return "handled";
} }
} }
} for (const { i, pinOffset } of comp.kind.outputPinIter()) {
{ if (v2(x + w, y + pinOffset).distance(pos) < 6) {
const pinSpace = h / (outputs.length + 1);
for (let i = 0; i < outputs.length; ++i) {
if (v2(x + w, y + (i + 1) * pinSpace).distance(pos) < 5) {
outputPinClicked(comp, i); outputPinClicked(comp, i);
return "handled"; return "handled";
} }
} }
}
componentClicked(comp); componentClicked(comp);
return "handled"; return "handled";
} }
return "not handled"; return "not handled";
} }
componentsInRect(pos: V2, size: V2): PlacedComponent[] {
return this.components.filter((comp) =>
rectsCollide(pos, size, comp.pos, comp.kind.size),
);
}
} }
export class ComponentRepo { export class ComponentRepo {
private defs = new Map<string, ComponentDef>(); private defs = new Map<string, ComponentKind>();
static withDefaults(): ComponentRepo { static withDefaults(): ComponentRepo {
const repo = new ComponentRepo(); const repo = new ComponentRepo();
repo.add("input", { for (const { label, size, inputs, outputs } of defaultDefs) {
label: "input", repo.add(label, new ComponentKind(size, label, inputs, outputs));
size: v2(80, 40), }
inputs: [],
outputs: [null],
});
repo.add("output", {
label: "output",
size: v2(80, 40),
inputs: [null],
outputs: [],
});
repo.add("and", {
label: "and",
size: v2(80, 40),
inputs: [null, null],
outputs: [null],
});
repo.add("or", {
label: "or",
size: v2(80, 40),
inputs: [null, null],
outputs: [null],
});
repo.add("not", {
label: "not",
size: v2(80, 40),
inputs: [null],
outputs: [null],
});
return repo; return repo;
} }
add(ident: string, def: ComponentDef) { add(ident: string, kind: ComponentKind) {
this.defs.set(ident, def); this.defs.set(ident, kind);
} }
get(ident: string): ComponentDef { get(ident: string): ComponentKind {
const def = this.defs.get(ident); const kind = this.defs.get(ident);
if (!def) { if (!kind) {
throw new Error("should be defined"); throw new Error("should be defined");
} }
return def; return kind;
} }
} }
export type Component = { export type PlacedComponent = {
def: ComponentDef; kind: ComponentKind;
pos: V2; pos: V2;
}; };
export type ComponentDef = { export class ComponentKind {
size: V2; constructor(
label: string; public size: V2,
inputs: (string | null)[]; public label: string,
outputs: (string | null)[]; public inputs: (string | null)[],
}; public outputs: (string | null)[],
) {}
inputPinIter(): { i: number; pinOffset: number }[] {
return this.inputs.map((_, i) => ({
i,
pinOffset: ((i + 1) * this.size.y) / (this.inputs.length + 1),
}));
}
outputPinIter(): { i: number; pinOffset: number }[] {
return this.outputs.map((_, i) => ({
i,
pinOffset: ((i + 1) * this.size.y) / (this.outputs.length + 1),
}));
}
}
const defaultDefs = [
{
label: "input",
size: v2(80, 40),
inputs: [],
outputs: [null],
},
{
label: "output",
size: v2(80, 40),
inputs: [null],
outputs: [],
},
{
label: "and",
size: v2(80, 40),
inputs: [null, null],
outputs: [null],
},
{
label: "or",
size: v2(80, 40),
inputs: [null, null],
outputs: [null],
},
{
label: "not",
size: v2(80, 40),
inputs: [null],
outputs: [null],
},
];

View File

@ -1,28 +1,35 @@
import { Board, ComponentRepo } from "./Board"; import { Board, ComponentRepo, type PlacedComponent } from "./Board";
import { Renderer } from "./Renderer"; import { Renderer } from "./Renderer";
import * as states from "./states"; import * as states from "./states";
import { v2, V2 } from "./V2"; import { v2, V2 } from "./V2";
export type Tool = string;
export class Cx { export class Cx {
public offset = v2(0, 0); public offset = v2(0, 0);
private renderNeeded = false; private renderNeeded = false;
private state = new states.Normal(this) as states.State; private state = new states.Normal(this) as states.State;
private updateActions: (() => void)[] = []; private updateActions: (() => void)[] = [];
private selectionBox: SelectionBox | null = null; public selectionBox: SelectionBox | null = null;
private componentPlacer: ComponentPlacer | null = null; private componentPlacer: ComponentPlacer | null = null;
public selection: Selection | null = null;
public connectingWire: ConnectingWire | null = null;
public board = new Board(); public board = new Board();
public componentRepo = ComponentRepo.withDefaults(); public componentRepo = ComponentRepo.withDefaults();
public keysPressed = new Set<string>();
render(canvas: HTMLCanvasElement) { render(canvas: HTMLCanvasElement) {
const r = new Renderer(canvas, this.offset); const r = new Renderer(canvas, this.offset);
r.clear(); r.clear();
r.drawGrid(); r.drawGrid();
this.board.render(r); this.board.render(r, this.selection);
this.selectionBox?.render(r); this.selectionBox?.render(r);
this.componentPlacer?.render(r); this.componentPlacer?.render(r);
this.connectingWire?.render(r);
} }
renderIfNeeded(canvas: HTMLCanvasElement) { renderIfNeeded(canvas: HTMLCanvasElement) {
@ -32,24 +39,27 @@ export class Cx {
} }
} }
setRenderNeeded() {
this.renderNeeded = true;
}
mouseDown(pos: V2) { mouseDown(pos: V2) {
this.state.onMouseDown?.(pos); this.state.onMouseDown?.(pos);
this.renderNeeded = true;
} }
mouseUp(pos: V2) { mouseUp(pos: V2) {
this.state.onMouseUp?.(pos); this.state.onMouseUp?.(pos);
this.renderNeeded = true;
} }
mouseMove(deltaPos: V2, pos: V2) { mouseMove(deltaPos: V2, pos: V2) {
this.state.onMouseMove?.(deltaPos, pos); this.state.onMouseMove?.(deltaPos, pos);
this.renderNeeded = true;
} }
keyDown(key: string) { keyDown(key: string) {
this.keysPressed.add(key);
this.state.onKeyDown?.(key); this.state.onKeyDown?.(key);
this.renderNeeded = true;
} }
keyUp(key: string) { keyUp(key: string) {
this.keysPressed.delete(key);
this.state.onKeyUp?.(key); this.state.onKeyUp?.(key);
this.renderNeeded = true;
} }
selectTool(tool: Tool) { selectTool(tool: Tool) {
switch (tool) { switch (tool) {
@ -67,6 +77,9 @@ export class Cx {
this.transitionTo(new states.Normal(this)); this.transitionTo(new states.Normal(this));
} }
} }
selectedTool(): Tool {
return this.state.selectedTool?.() || "select";
}
addUpdateAction(action: () => void): object { addUpdateAction(action: () => void): object {
this.updateActions.push(action); this.updateActions.push(action);
@ -82,6 +95,7 @@ export class Cx {
transitionTo(newState: states.State) { transitionTo(newState: states.State) {
this.state.leaveState?.(); this.state.leaveState?.();
this.state = newState; this.state = newState;
console.log(`Entering state ${newState.constructor.name}`);
this.state.enterState?.(); this.state.enterState?.();
this.notifyListeners(); this.notifyListeners();
} }
@ -95,41 +109,19 @@ export class Cx {
moveOffset(deltaPos: V2) { moveOffset(deltaPos: V2) {
this.offset.x += deltaPos.x; this.offset.x += deltaPos.x;
this.offset.y += deltaPos.y; this.offset.y += deltaPos.y;
this.renderNeeded = true;
}
addSelectionRect(pos: V2) {
this.selectionBox = new SelectionBox(pos, v2(0, 0));
this.renderNeeded = true;
}
removeSelectionRect() {
this.selectionBox = null;
this.renderNeeded = true;
}
moveSelectionRect(deltaPos: V2) {
if (this.selectionBox) {
this.selectionBox.size.x += deltaPos.x;
this.selectionBox.size.y += deltaPos.y;
this.renderNeeded = true;
}
} }
addComponentPlacer(pos: V2, size: V2) { addComponentPlacer(pos: V2, size: V2) {
this.componentPlacer = new ComponentPlacer(pos, size); this.componentPlacer = new ComponentPlacer(pos, size);
this.renderNeeded = true;
} }
removeComponentPlacer() { removeComponentPlacer() {
this.componentPlacer = null; this.componentPlacer = null;
this.renderNeeded = true;
} }
setComponentPlacerPos(pos: V2) { setComponentPlacerPos(pos: V2) {
if (this.componentPlacer) { if (this.componentPlacer) {
this.componentPlacer.pos = pos; this.componentPlacer.pos = pos;
this.renderNeeded = true;
} }
} }
@ -141,14 +133,27 @@ export class Cx {
} }
export class SelectionBox { export class SelectionBox {
constructor( public size = v2(0, 0);
public pos: V2,
public size: V2, constructor(public pos: V2) {}
) {}
render(r: Renderer) { render(r: Renderer) {
r.drawSelectionBox(this.pos, this.size); r.drawSelectionBox(this.pos, this.size);
} }
move(deltaPos: V2) {
this.size = this.size.add(deltaPos);
}
normalized(): { pos: V2; size: V2 } {
const normalizedAxis = (p: number, s: number): [number, number] =>
s >= 0 ? [p, s] : [p + s, -s];
const [x, w] = normalizedAxis(this.pos.x, this.size.x);
const [y, h] = normalizedAxis(this.pos.y, this.size.y);
return { pos: v2(x, y), size: v2(w, h) };
}
} }
export class ComponentPlacer { export class ComponentPlacer {
@ -162,4 +167,62 @@ export class ComponentPlacer {
} }
} }
export type Tool = string; export class Selection {
selectedComponents = new Set<PlacedComponent>();
addComponent(comp: PlacedComponent) {
this.selectedComponents.add(comp);
}
toggleComponent(comp: PlacedComponent) {
if (this.selectedComponents.has(comp)) {
this.selectedComponents.delete(comp);
} else {
this.selectedComponents.add(comp);
}
}
isComponentSelected(comp: PlacedComponent) {
return this.selectedComponents.has(comp);
}
}
export class ConnectingWire {
constructor(
public kind: ConnectingWireKind,
public pos: V2,
) {}
render(r: Renderer) {
switch (this.kind.tag) {
case "InputPin":
case "OutputPin":
}
r.drawConnectingWire(this.beginPos(), this.pos);
}
move(pos: V2) {
this.pos = pos;
}
private beginPos(): V2 {
switch (this.kind.tag) {
case "InputPin":
return v2(
this.kind.comp.pos.x,
this.kind.comp.pos.y +
this.kind.comp.kind.inputPinIter()[this.kind.i].pinOffset,
);
case "OutputPin":
return v2(
this.kind.comp.pos.x + this.kind.comp.kind.size.x,
this.kind.comp.pos.y +
this.kind.comp.kind.outputPinIter()[this.kind.i].pinOffset,
);
}
}
}
export type ConnectingWireKind =
| { tag: "InputPin"; comp: PlacedComponent; i: number }
| { tag: "OutputPin"; comp: PlacedComponent; i: number };

View File

@ -1,4 +1,4 @@
import type { Component } from "./Board"; import type { ComponentKind } from "./Board";
import { v2, type V2 } from "./V2"; import { v2, type V2 } from "./V2";
export class Renderer { export class Renderer {
@ -57,23 +57,10 @@ export class Renderer {
c.strokeRect(x, y, w, h); c.strokeRect(x, y, w, h);
} }
drawComponent( drawComponentBody(pos: V2, kind: ComponentKind) {
comp: Component,
hoveredOverInput: [Component, number] | null,
hoveredOverOutput: [Component, number] | null,
) {
const { c, offset } = this; const { c, offset } = this;
const { const { x, y } = pos.add(offset);
def: { const { x: w, y: h } = kind.size;
size: { x: w, y: h },
label,
inputs,
outputs,
},
pos,
} = comp;
const [x, y] = [pos.x + offset.x, pos.y + offset.y];
c.fillStyle = `#6abbde`; c.fillStyle = `#6abbde`;
c.fillRect(x, y, w, h); c.fillRect(x, y, w, h);
@ -83,52 +70,92 @@ export class Renderer {
c.fillStyle = `#333333`; c.fillStyle = `#333333`;
c.font = "bold 16px monospace"; c.font = "bold 16px monospace";
const textMetrix = c.measureText(label); const textMetrix = c.measureText(kind.label);
c.fillText( c.fillText(
label, kind.label,
x + w / 2 - textMetrix.width / 2, x + w / 2 - textMetrix.width / 2,
y + 13 + h / 2 - 16 / 2, y + 13 + h / 2 - 16 / 2,
); );
{
const pinSpace = h / (inputs.length + 1);
for (let i = 0; i < inputs.length; ++i) {
if (inputs[i] !== null) {
throw new Error("pin text not implemented");
} }
c.fillStyle = `#333333`; drawComponentBodySelected(pos: V2, kind: ComponentKind) {
c.beginPath(); const { c, offset } = this;
c.arc(x, y + (i + 1) * pinSpace, 4, 0, Math.PI * 2); const { x, y } = pos.add(offset);
c.fill(); const { x: w, y: h } = kind.size;
if (hoveredOverInput?.[0] === comp && hoveredOverInput[1] === i) { c.fillStyle = `#6abbde`;
c.strokeStyle = `#bbbbbb`; c.fillRect(x, y, w, h);
c.strokeStyle = `#ff8800`;
c.lineWidth = 2; c.lineWidth = 2;
c.beginPath(); c.strokeRect(x, y, w, h);
c.arc(x, y + (i + 1) * pinSpace, 5, 0, Math.PI * 2);
c.stroke(); c.fillStyle = `#333333`;
} c.font = "bold 16px monospace";
} const textMetrix = c.measureText(kind.label);
} c.fillText(
{ kind.label,
const pinSpace = h / (outputs.length + 1); x + w / 2 - textMetrix.width / 2,
for (let i = 0; i < outputs.length; ++i) { y + 13 + h / 2 - 16 / 2,
if (outputs[i] !== null) { );
throw new Error("pin text not implemented");
} }
drawComponentInputPin(pos: V2, pinOffset: number) {
const { c, offset } = this;
const { x, y } = pos.add(offset);
c.fillStyle = `#333333`; c.fillStyle = `#333333`;
c.beginPath(); c.beginPath();
c.arc(x + w, y + (i + 1) * pinSpace, 4, 0, Math.PI * 2); c.arc(x, y + pinOffset, 4, 0, Math.PI * 2);
c.fill(); c.fill();
}
drawComponentInputPinHover(pos: V2, pinOffset: number) {
const { c, offset } = this;
const { x, y } = pos.add(offset);
if (hoveredOverOutput?.[0] === comp && hoveredOverOutput[1] === i) {
c.strokeStyle = `#eee`; c.strokeStyle = `#eee`;
c.lineWidth = 2; c.lineWidth = 2;
c.beginPath(); c.beginPath();
c.arc(x + w, y + (i + 1) * pinSpace, 5, 0, Math.PI * 2); c.arc(x, y + pinOffset, 5, 0, Math.PI * 2);
c.stroke();
}
drawComponentOutputPin(pos: V2, kind: ComponentKind, pinOffset: number) {
const { c, offset } = this;
const {
size: { x: w },
} = kind;
const { x, y } = pos.add(offset);
c.fillStyle = `#333333`;
c.beginPath();
c.arc(x + w, y + pinOffset, 4, 0, Math.PI * 2);
c.fill();
}
drawComponentOutputPinHover(pos: V2, kind: ComponentKind, pinOffset: number) {
const { c, offset } = this;
const {
size: { x: w },
} = kind;
const { x, y } = pos.add(offset);
c.strokeStyle = `#eee`;
c.lineWidth = 2;
c.beginPath();
c.arc(x + w, y + pinOffset, 5, 0, Math.PI * 2);
c.stroke();
}
drawConnectingWire(begin: V2, end: V2) {
const { c, offset } = this;
const { x: x0, y: y0 } = begin.add(offset);
const { x: x1, y: y1 } = end.add(offset);
c.strokeStyle = `#333333`;
c.lineWidth = 3;
c.beginPath();
c.moveTo(x0, y0);
c.lineTo(x1, y1);
c.stroke(); c.stroke();
} }
} }
}
}
}

View File

@ -1,5 +1,6 @@
import type { ComponentDef } from "./Board"; import { type ComponentKind } from "./Board";
import type { Cx, Tool } from "./Cx"; import { ConnectingWire, Selection } from "./Cx";
import { SelectionBox, type Cx, type Tool } from "./Cx";
import { v2, type V2 } from "./V2"; import { v2, type V2 } from "./V2";
export interface State { export interface State {
@ -20,21 +21,45 @@ export class Normal implements State {
if ( if (
this.cx.board.handleMouseClick( this.cx.board.handleMouseClick(
pos.sub(this.cx.offset), pos.sub(this.cx.offset),
(comp, i) => {}, (comp, i) => {
(comp, i) => {}, console.log({ comp, i });
(comp) => {}, this.cx.connectingWire = new ConnectingWire(
{
tag: "InputPin",
comp,
i,
},
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
},
(comp, i) => {
this.cx.connectingWire = new ConnectingWire(
{
tag: "OutputPin",
comp,
i,
},
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
},
(comp) => {
this.cx.selection = new Selection();
this.cx.selection.addComponent(comp);
this.cx.transitionTo(new Selecting(this.cx));
},
) === "handled" ) === "handled"
) { ) {
return; return;
} else { } else {
this.cx.addSelectionRect(pos); this.cx.selectionBox = new SelectionBox(pos);
this.cx.transitionTo(new SelectingBox(this.cx)); this.cx.transitionTo(new SelectingBox(this.cx));
} }
} }
onMouseMove(_deltaPos: V2, pos: V2): void { onMouseMove(_deltaPos: V2, pos: V2): void {
this.cx.board.updateMouseHover(pos.sub(this.cx.offset)); this.cx.board.updateMouseHover(pos.sub(this.cx.offset));
this.cx.setRenderNeeded();
} }
onKeyDown(key: string): void { onKeyDown(key: string): void {
@ -88,7 +113,7 @@ export class Panning implements State {
} }
export class Placing implements State { export class Placing implements State {
private compDef: ComponentDef; private compDef: ComponentKind;
constructor( constructor(
private cx: Cx, private cx: Cx,
@ -131,21 +156,88 @@ export class Placing implements State {
export class Selecting implements State { export class Selecting implements State {
constructor(private cx: Cx) {} constructor(private cx: Cx) {}
onMouseDown(pos: V2): void {
if (
this.cx.board.handleMouseClick(
pos.sub(this.cx.offset),
(_comp, _i) => {},
(_comp, _i) => {},
(comp) => {
if (this.cx.keysPressed.has("Control")) {
this.cx.selection?.toggleComponent(comp);
} else {
this.cx.selection = new Selection();
this.cx.selection.addComponent(comp);
}
},
) === "handled"
) {
return;
} else {
if (this.cx.keysPressed.has("Control")) {
this.cx.selectionBox = new SelectionBox(pos);
this.cx.transitionTo(new SelectingBox(this.cx));
} else {
this.cx.selection = null;
this.cx.transitionTo(new Normal(this.cx));
}
}
}
} }
export class SelectingBox implements State { export class SelectingBox implements State {
constructor(private cx: Cx) {} constructor(private cx: Cx) {}
onMouseUp(_pos: V2): void { onMouseUp(_pos: V2): void {
this.cx.removeSelectionRect(); if (!this.cx.selectionBox) {
throw new Error("expected selectionBox to active");
}
const { pos, size } = this.cx.selectionBox.normalized();
const selected = this.cx.board.componentsInRect(
pos.sub(this.cx.offset),
size,
);
if (selected.length > 0) {
this.cx.selection ??= new Selection();
}
for (const comp of selected) {
this.cx.selection?.addComponent(comp);
}
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)); this.cx.transitionTo(new Normal(this.cx));
} }
}
onMouseMove(deltaPos: V2): void { onMouseMove(deltaPos: V2): void {
this.cx.moveSelectionRect(deltaPos); this.cx.selectionBox?.move(deltaPos);
} }
selectedTool(): Tool | null { selectedTool(): Tool | null {
return "select"; return "select";
} }
} }
export class Wiring implements State {
constructor(private cx: Cx) {}
onMouseMove(_deltaPos: V2, pos: V2): void {
if (!this.cx.connectingWire) {
throw new Error("expected connectingWire to be active");
}
this.cx.connectingWire.move(pos);
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;
}
}
}