From 60e589e689098cad4efc60b0b3f2a2dbd0156b73 Mon Sep 17 00:00:00 2001 From: sfja Date: Sun, 17 May 2026 00:04:00 +0200 Subject: [PATCH] hover pins --- editor/src/Canvas.tsx | 10 +- editor/src/editor/Board.ts | 129 ++++++++++++++++++++++++-- editor/src/editor/Cx.ts | 16 ++-- editor/src/editor/Editor.ts | 2 +- editor/src/editor/State.ts | 8 +- editor/src/editor/V2.ts | 36 ++++++- editor/src/editor/states/Normal.ts | 8 ++ editor/src/editor/states/Panning.ts | 8 +- editor/src/editor/states/Placing.ts | 4 +- editor/src/editor/states/Selecting.ts | 6 +- 10 files changed, 190 insertions(+), 37 deletions(-) diff --git a/editor/src/Canvas.tsx b/editor/src/Canvas.tsx index 50b6f42..1bf2269 100644 --- a/editor/src/Canvas.tsx +++ b/editor/src/Canvas.tsx @@ -1,6 +1,6 @@ import { useEffect, type ReactElement, type RefObject } from "react"; import { type Editor } from "./editor/Editor"; -import { V2 } from "./editor/V2"; +import { v2 } from "./editor/V2"; type Props = { editor: Editor; canvasRef: RefObject }; @@ -21,18 +21,18 @@ function Canvas({ editor, canvasRef }: Props): ReactElement { style={{ width: 1000, height: 1000, backgroundColor: "black" }} tabIndex={0} onMouseDown={(ev) => { - const pos = V2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); + const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); editor.mouseDown(pos); editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} onMouseUp={(ev) => { - const pos = V2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); + const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); editor.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); + const deltaPos = v2(ev.movementX, ev.movementY); + const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); editor.mouseMove(deltaPos, pos); editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} diff --git a/editor/src/editor/Board.ts b/editor/src/editor/Board.ts index 9347506..de0c701 100644 --- a/editor/src/editor/Board.ts +++ b/editor/src/editor/Board.ts @@ -1,8 +1,11 @@ -import { rectsCollide, V2 } from "./V2"; +import { pointInsideRect, rectsCollide, v2, V2 } from "./V2"; export class Board { private components: Component[] = []; + private hoveredOverInput: [Component, number] | null = null; + private hoveredOverOutput: [Component, number] | null = null; + canPlaceComponent(def: ComponentDef, pos: V2): boolean { return !this.components.some((comp) => rectsCollide(comp.pos, comp.def.size, pos, def.size), @@ -22,26 +25,114 @@ export class Board { inputs, outputs, }, - pos: { x, y }, + pos, } = comp; + const [x, y] = [pos.x + offset.x, pos.y + offset.y]; + c.fillStyle = `#6abbde`; - c.fillRect(x + offset.x, y + offset.y, w, h); + c.fillRect(x, y, w, h); c.strokeStyle = `#333333`; c.lineWidth = 2; - c.strokeRect(x + offset.x, y + offset.y, w, h); + c.strokeRect(x, y, w, h); c.fillStyle = `#333333`; c.font = "bold 16px monospace"; const textMetrix = c.measureText(label); c.fillText( label, - x + offset.x + w / 2 - textMetrix.width / 2, - y + offset.y + 13 + h / 2 - 16 / 2, + x + w / 2 - textMetrix.width / 2, + y + 13 + h / 2 - 16 / 2, ); - const pinSpace = h / (inputs.length + 1); - for (let i = 0; i < inputs.length; ++i) {} + { + 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`; + c.beginPath(); + c.arc(x, y + (i + 1) * pinSpace, 4, 0, Math.PI * 2); + c.fill(); + + if ( + this.hoveredOverInput?.[0] === comp && + this.hoveredOverInput[1] === i + ) { + c.strokeStyle = `#bbbbbb`; + c.lineWidth = 2; + c.beginPath(); + c.arc(x, y + (i + 1) * pinSpace, 5, 0, Math.PI * 2); + c.stroke(); + } + } + } + { + const pinSpace = h / (outputs.length + 1); + for (let i = 0; i < outputs.length; ++i) { + if (outputs[i] !== null) { + throw new Error("pin text not implemented"); + } + c.fillStyle = `#333333`; + c.beginPath(); + c.arc(x + w, y + (i + 1) * pinSpace, 4, 0, Math.PI * 2); + c.fill(); + + if ( + this.hoveredOverOutput?.[0] === comp && + this.hoveredOverOutput[1] === i + ) { + c.strokeStyle = `#bbbbbb`; + c.lineWidth = 2; + c.beginPath(); + c.arc(x + w, y + (i + 1) * pinSpace, 5, 0, Math.PI * 2); + c.stroke(); + } + } + } + } + } + + updateMouseHover(pos: V2) { + this.hoveredOverInput = null; + this.hoveredOverOutput = null; + + for (const comp of this.components) { + const { + pos: { x, y }, + def: { + size: { x: w, y: h }, + inputs, + outputs, + }, + } = comp; + + if ( + !pointInsideRect( + pos, + comp.pos.sub(v2(5, 5)), + comp.def.size.add(v2(10, 10)), + ) + ) { + continue; + } + { + const pinSpace = h / (inputs.length + 1); + for (let i = 0; i < inputs.length; ++i) { + if (v2(x, y + (i + 1) * pinSpace).distance(pos) < 5) { + this.hoveredOverInput = [comp, i]; + } + } + } + { + 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]; + } + } + } } } } @@ -52,18 +143,36 @@ export class ComponentRepo { static withDefaults(): ComponentRepo { const repo = new ComponentRepo(); + repo.add("input", { + label: "input", + 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), + size: v2(80, 40), inputs: [null, null], outputs: [null], }); repo.add("or", { label: "or", - size: V2(80, 40), + size: v2(80, 40), inputs: [null, null], outputs: [null], }); + repo.add("not", { + label: "not", + size: v2(80, 40), + inputs: [null], + outputs: [null], + }); return repo; } diff --git a/editor/src/editor/Cx.ts b/editor/src/editor/Cx.ts index d9509c3..326a1a8 100644 --- a/editor/src/editor/Cx.ts +++ b/editor/src/editor/Cx.ts @@ -1,10 +1,10 @@ import { Board, ComponentRepo } from "./Board"; import type { State } from "./State"; import { Normal } from "./states/Normal"; -import { V2 } from "./V2"; +import { v2, V2 } from "./V2"; export class Cx { - private offset = V2(0, 0); + public offset = v2(0, 0); private renderNeeded = false; private state = new Normal(this) as State; private updateActions: (() => void)[] = []; @@ -23,7 +23,7 @@ export class Cx { c.fillRect(0, 0, canvas.width, canvas.height); const dotSize = { x: 2, y: 2 }; - const gridSize = V2(20, 20); + const gridSize = v2(20, 20); c.fillStyle = "#111"; for (let y = 0; y < canvas.width / gridSize.x + 1; ++y) { @@ -71,6 +71,10 @@ export class Cx { } } + setRenderNeeded() { + this.renderNeeded = true; + } + mouseDown(pos: V2) { this.state.onMouseDown?.(pos); } @@ -130,7 +134,7 @@ export class Cx { } addSelectionRect(pos: V2) { - this.selectionRect = { pos, size: V2(0, 0) }; + this.selectionRect = { pos, size: v2(0, 0) }; this.renderNeeded = true; } @@ -167,7 +171,7 @@ export class Cx { canvasPosToBoard(pos: V2): V2 { const absX = pos.x - this.offset.x; const absY = pos.y - this.offset.y; - return V2(absX, absY); + return v2(absX, absY); } } @@ -181,4 +185,4 @@ export type ComponentPlacer = { size: V2; }; -export type Tool = "select" | "pan" | "and" | "or"; +export type Tool = string; diff --git a/editor/src/editor/Editor.ts b/editor/src/editor/Editor.ts index a9b239a..1d26e44 100644 --- a/editor/src/editor/Editor.ts +++ b/editor/src/editor/Editor.ts @@ -41,7 +41,7 @@ export class Editor { } tools(): Tool[] { - return ["select", "pan", "and", "or"]; + return ["select", "pan", "input", "output", "and", "or", "not"]; } addUpdateAction(action: () => void): object { diff --git a/editor/src/editor/State.ts b/editor/src/editor/State.ts index e1b9e35..a0ecd8b 100644 --- a/editor/src/editor/State.ts +++ b/editor/src/editor/State.ts @@ -1,12 +1,12 @@ import type { Tool } from "./Cx"; -import type { V2 } from "./V2"; +import 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; + onMouseDown?(pos: V2_): void; + onMouseUp?(pos: V2_): void; + onMouseMove?(deltaPos: V2_, pos: V2_): void; onKeyDown?(key: string): void; onKeyUp?(key: string): void; selectTool?(tool: Tool): void; diff --git a/editor/src/editor/V2.ts b/editor/src/editor/V2.ts index 19e7e84..d1f93c4 100644 --- a/editor/src/editor/V2.ts +++ b/editor/src/editor/V2.ts @@ -1,5 +1,29 @@ -export type V2 = { x: number; y: number }; -export const V2 = (x: number, y: number): V2 => ({ x, y }); +export class V2 { + constructor( + public x: number, + public y: number, + ) {} + + add(other: V2): V2 { + return new V2(this.x + other.x, this.y + other.y); + } + sub(other: V2): V2 { + return new V2(this.x - other.x, this.y - other.y); + } + rsub(other: V2): V2 { + return new V2(other.x - this.x, other.y - this.y); + } + + len(): number { + return Math.sqrt(this.x ** 2 + this.y ** 2); + } + + distance(other: V2) { + return this.rsub(other).len(); + } +} + +export const v2 = (x: number, y: number): V2 => new V2(x, y); export function rectsCollide( { x: ax, y: ay }: V2, @@ -9,3 +33,11 @@ export function rectsCollide( ): boolean { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; } + +export function pointInsideRect( + { x: ax, y: ay }: V2, + { x: bx, y: by }: V2, + { x: bw, y: bh }: V2, +): boolean { + return ax < bx + bw && ax > bx && ay < by + bh && ay > by; +} diff --git a/editor/src/editor/states/Normal.ts b/editor/src/editor/states/Normal.ts index d9fba23..1087796 100644 --- a/editor/src/editor/states/Normal.ts +++ b/editor/src/editor/states/Normal.ts @@ -13,8 +13,11 @@ export class Normal implements State { case "pan": this.cx.transitionTo(new Panning(this.cx)); break; + case "input": + case "output": case "and": case "or": + case "not": this.cx.transitionTo(new Placing(this.cx, tool)); } } @@ -24,6 +27,11 @@ export class Normal implements State { this.cx.transitionTo(new Selecting(this.cx)); } + onMouseMove(_deltaPos: V2, pos: V2): void { + this.cx.board.updateMouseHover(pos.sub(this.cx.offset)); + this.cx.setRenderNeeded(); + } + onKeyDown(key: string): void { if (key === "Shift") { this.cx.transitionTo(new Panning(this.cx)); diff --git a/editor/src/editor/states/Panning.ts b/editor/src/editor/states/Panning.ts index ebacb31..8f03163 100644 --- a/editor/src/editor/states/Panning.ts +++ b/editor/src/editor/states/Panning.ts @@ -1,5 +1,5 @@ import type { Cx, Tool } from "../Cx"; -import type { V2 } from "../V2"; +import type { V2_ } from "../V2"; import type { State } from "../State"; import { Normal } from "./Normal"; @@ -8,15 +8,15 @@ export class Panning implements State { constructor(private cx: Cx) {} - onMouseDown(_pos: V2): void { + onMouseDown(_pos: V2_): void { this.dragging = true; } - onMouseUp(_pos: V2): void { + onMouseUp(_pos: V2_): void { this.dragging = false; } - onMouseMove(deltaPos: V2): void { + onMouseMove(deltaPos: V2_): void { if (this.dragging) { this.cx.moveOffset(deltaPos); } diff --git a/editor/src/editor/states/Placing.ts b/editor/src/editor/states/Placing.ts index 41763f6..b43a441 100644 --- a/editor/src/editor/states/Placing.ts +++ b/editor/src/editor/states/Placing.ts @@ -1,5 +1,5 @@ import { type Cx, type Tool } from "../Cx"; -import { V2 } from "../V2"; +import { V2, v2 } from "../V2"; import type { State } from "../State"; import { Normal } from "./Normal"; import type { ComponentDef } from "../Board"; @@ -15,7 +15,7 @@ export class Placing implements State { } enterState(): void { - this.cx.addComponentPlacer(V2(0, 0), this.compDef.size); + this.cx.addComponentPlacer(v2(0, 0), this.compDef.size); } leaveState(): void { diff --git a/editor/src/editor/states/Selecting.ts b/editor/src/editor/states/Selecting.ts index 0b4c181..556864e 100644 --- a/editor/src/editor/states/Selecting.ts +++ b/editor/src/editor/states/Selecting.ts @@ -1,17 +1,17 @@ import type { Cx, Tool } from "../Cx"; -import type { V2 } from "../V2"; +import type { V2_ } from "../V2"; import type { State } from "../State"; import { Normal } from "./Normal"; export class Selecting implements State { constructor(private cx: Cx) {} - onMouseUp(_pos: V2): void { + onMouseUp(_pos: V2_): void { this.cx.removeSelectionRect(); this.cx.transitionTo(new Normal(this.cx)); } - onMouseMove(deltaPos: V2): void { + onMouseMove(deltaPos: V2_): void { this.cx.moveSelectionRect(deltaPos); }