visual editor works

This commit is contained in:
sfja 2026-06-03 03:34:15 +02:00
parent 6a5b051872
commit 4cadf3adbd
5 changed files with 165 additions and 16 deletions

View File

@ -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<HTMLCanvasElement | null>, width: number, height: number };
type Props = {
editor: Editor;
canvasRef: RefObject<HTMLCanvasElement | null>;
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);
}}
/>
</div>

View File

@ -48,7 +48,11 @@ export class Board {
}
for (const joint of this.joints) {
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);
}

View File

@ -175,11 +175,15 @@ export class ComponentPlacer {
}
export class Selection {
selectedComponents = new Set<Component>();
private selectedComponents = new Set<Component>();
private selectedJoints = new Set<Joint>();
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":

View File

@ -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);

View File

@ -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) => {
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,22 +176,67 @@ 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")) {
this.cx.selectionBox = new SelectionBox(pos);
this.cx.transitionTo(new SelectingBox(this.cx));
} else {
this.cx.selection = null;
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);
}
}
export class SelectingBox implements State {
@ -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));
},