From 4cadf3adbdee018e7f5f168a6618d389eb92da1d Mon Sep 17 00:00:00 2001 From: sfja Date: Wed, 3 Jun 2026 03:34:15 +0200 Subject: [PATCH] visual editor works --- editor/src/Canvas.tsx | 9 +++- editor/src/editor/Board.ts | 39 ++++++++++++++- editor/src/editor/Cx.ts | 29 ++++++++++- editor/src/editor/Renderer.ts | 12 +++++ editor/src/editor/states.ts | 92 ++++++++++++++++++++++++++++++----- 5 files changed, 165 insertions(+), 16 deletions(-) diff --git a/editor/src/Canvas.tsx b/editor/src/Canvas.tsx index c674b06..1203f11 100644 --- a/editor/src/Canvas.tsx +++ b/editor/src/Canvas.tsx @@ -2,7 +2,12 @@ import { useEffect, type ReactElement, type RefObject } from "react"; import { type Editor } from "./editor/Editor"; import { v2 } from "./editor/V2"; -type Props = { editor: Editor; canvasRef: RefObject, width: number, height: number }; +type Props = { + editor: Editor; + canvasRef: RefObject; + width: number; + height: number; +}; function Canvas({ editor, canvasRef, width, height }: Props): ReactElement { useEffect(() => { @@ -38,9 +43,11 @@ function Canvas({ editor, canvasRef, width, height }: Props): ReactElement { }} onKeyDown={(ev) => { editor.keyDown(ev.key); + editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} onKeyUp={(ev) => { editor.keyUp(ev.key); + editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} /> diff --git a/editor/src/editor/Board.ts b/editor/src/editor/Board.ts index 908fb34..63a2cfc 100644 --- a/editor/src/editor/Board.ts +++ b/editor/src/editor/Board.ts @@ -48,7 +48,11 @@ export class Board { } for (const joint of this.joints) { - r.drawJoint(joint.pos); + if (selection?.isJointSelected(joint)) { + r.drawJointSelected(joint.pos); + } else { + r.drawJoint(joint.pos); + } if (this.hoveredOverJoint === joint) { r.drawJointHover(joint.pos); @@ -164,6 +168,9 @@ export class Board { rectsCollide(pos, size, comp.pos, comp.kind.size), ); } + jointsInRect(pos: V2, size: V2): Joint[] { + return this.joints.filter((joint) => pointInsideRect(joint.pos, pos, size)); + } addJoint(pos: V2): Joint { const t = new Joint(pos); @@ -175,6 +182,16 @@ export class Board { this.wires.push(wire); return wire; } + + deleteSelection(selection: Selection) { + this.components = this.components.filter( + (comp) => !selection.isComponentSelected(comp), + ); + this.joints = this.joints.filter( + (joint) => !selection.isJointSelected(joint), + ); + this.wires = this.wires.filter((wire) => !wire.isSelected(selection)); + } } export class ComponentRepo { @@ -296,6 +313,26 @@ export class Wire { return distance !== null && distance < 6; } + isSelected(selection: Selection): boolean { + return ( + this.connectionIsSelected(this.begin, selection) || + this.connectionIsSelected(this.end, selection) + ); + } + + private connectionIsSelected( + connection: WireConnection, + selection: Selection, + ): boolean { + switch (connection.tag) { + case "InputPin": + case "OutputPin": + return selection.isComponentSelected(connection.comp); + case "Joint": + return selection.isJointSelected(connection.joint); + } + } + beginPos(): V2 { return this.connectionPos(this.begin); } diff --git a/editor/src/editor/Cx.ts b/editor/src/editor/Cx.ts index fcedc4e..33263d8 100644 --- a/editor/src/editor/Cx.ts +++ b/editor/src/editor/Cx.ts @@ -175,11 +175,15 @@ export class ComponentPlacer { } export class Selection { - selectedComponents = new Set(); + private selectedComponents = new Set(); + private selectedJoints = new Set(); addComponent(comp: Component) { this.selectedComponents.add(comp); } + addJoint(joint: Joint) { + this.selectedJoints.add(joint); + } toggleComponent(comp: Component) { if (this.selectedComponents.has(comp)) { @@ -188,10 +192,29 @@ export class Selection { this.selectedComponents.add(comp); } } + toggleJoint(joint: Joint) { + if (this.selectedJoints.has(joint)) { + this.selectedJoints.delete(joint); + } else { + this.selectedJoints.add(joint); + } + } isComponentSelected(comp: Component) { return this.selectedComponents.has(comp); } + isJointSelected(joint: Joint) { + return this.selectedJoints.has(joint); + } + + move(deltaPos: V2) { + for (const comp of this.selectedComponents) { + comp.pos = comp.pos.add(deltaPos); + } + for (const joint of this.selectedJoints) { + joint.pos = joint.pos.add(deltaPos); + } + } } export class ConnectingWire { @@ -225,6 +248,10 @@ export class ConnectingWire { this.pushWire(board, { tag: "OutputPin", comp, i }); } + connectToJoint(board: Board, joint: Joint) { + this.pushWire(board, { tag: "Joint", joint }); + } + private pushWire(board: Board, end: WireConnection) { switch (this.kind.tag) { case "InputPin": diff --git a/editor/src/editor/Renderer.ts b/editor/src/editor/Renderer.ts index 788c0c4..dc2e4b1 100644 --- a/editor/src/editor/Renderer.ts +++ b/editor/src/editor/Renderer.ts @@ -182,6 +182,18 @@ export class Renderer { c.fill(); } + drawJointSelected(pos: V2) { + const { c, offset } = this; + const { x, y } = pos.add(offset); + + this.drawJoint(pos); + c.strokeStyle = `#ff8800`; + c.lineWidth = 1; + c.beginPath(); + c.arc(x, y, 5, 0, Math.PI * 2); + c.stroke(); + } + drawJointHover(pos: V2) { const { c, offset } = this; const { x, y } = pos.add(offset); diff --git a/editor/src/editor/states.ts b/editor/src/editor/states.ts index eb7bc10..f5fcc22 100644 --- a/editor/src/editor/states.ts +++ b/editor/src/editor/states.ts @@ -1,4 +1,4 @@ -import { type ComponentKind } from "./Board"; +import { Component, Joint, type ComponentKind } from "./Board"; import { ConnectingWire, Selection, type ConnectingWireKind } from "./Cx"; import { SelectionBox, type Cx, type Tool } from "./Cx"; import { v2, type V2 } from "./V2"; @@ -43,11 +43,17 @@ export class Normal implements State { this.cx.transitionTo(new Selecting(this.cx)); }, onJointClicked: (joint) => { - this.cx.connectingWire = new ConnectingWire( - { tag: "Joint", joint }, - pos.sub(this.cx.offset), - ); - this.cx.transitionTo(new Wiring(this.cx)); + 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.sub(this.cx.offset), + ); + this.cx.transitionTo(new Wiring(this.cx)); + } }, }) !== "handled" ) { @@ -57,7 +63,7 @@ export class Normal implements State { } onMouseMove(_deltaPos: V2, pos: V2): void { - if (this.isMouseDown && this.dragStart.sub(pos).len() > 40) { + if (this.isMouseDown && this.dragStart.sub(pos).len() > 5) { this.cx.selectionBox = new SelectionBox( this.dragStart, pos.sub(this.dragStart), @@ -160,6 +166,8 @@ export class Placing implements State { } export class Selecting implements State { + private isMouseDown = false; + constructor(private cx: Cx) {} onMouseDown(pos: V2): void { @@ -168,11 +176,19 @@ export class Selecting implements State { onComponentClicked: (comp) => { if (this.cx.keysPressed.has("Control")) { this.cx.selection?.toggleComponent(comp); - } else { + } 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")) { @@ -180,9 +196,46 @@ export class Selecting implements State { this.cx.transitionTo(new SelectingBox(this.cx)); } else { this.cx.selection = null; - this.cx.transitionTo(new Normal(this.cx)); + this.cx.selectionBox = new SelectionBox(pos); + this.cx.transitionTo(new SelectingBox(this.cx)); } } + + 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 { + constructor(private cx: Cx) {} + + onMouseUp(_pos: V2): void { + this.cx.transitionTo(new Selecting(this.cx)); + } + + onMouseMove(deltaPos: V2, _pos: V2): void { + this.cx.selection?.move(deltaPos); } } @@ -194,16 +247,24 @@ export class SelectingBox implements State { throw new Error("expected selectionBox to active"); } const { pos, size } = this.cx.selectionBox.normalized(); - const selected = this.cx.board.componentsInRect( + + const components = this.cx.board.componentsInRect( pos.sub(this.cx.offset), size, ); - if (selected.length > 0) { + const joints = this.cx.board.jointsInRect(pos.sub(this.cx.offset), size); + + if (components.length > 0 || joints.length > 0) { this.cx.selection ??= new Selection(); } - for (const comp of selected) { + + 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)); @@ -234,7 +295,12 @@ export class Wiring implements State { this.cx.transitionTo(new Normal(this.cx)); }, onOutputPinClicked: (comp, i) => { - this.cx.connectingWire!.connectToInput(this.cx.board, 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)); },