hover pins

This commit is contained in:
sfja 2026-05-17 00:04:00 +02:00
parent ed36f21bb2
commit 60e589e689
10 changed files with 190 additions and 37 deletions

View File

@ -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<HTMLCanvasElement | null> };
@ -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);
}}

View File

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

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

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