From 3a38e90178bd34243ab619788eda17adb55a2a83 Mon Sep 17 00:00:00 2001 From: sfja Date: Sat, 9 May 2026 03:37:43 +0200 Subject: [PATCH] pan and select --- editor/src/App.css | 0 editor/src/App.tsx | 8 +- editor/src/Canvas.css | 6 - editor/src/Canvas.tsx | 7 +- editor/src/Editor.ts | 258 ++++++++++++++++++++++++++++++++++++--- editor/src/Toolbar.tsx | 29 ++++- editor/src/index.css | 4 + editor/src/style.css | 37 ++++++ editor/tsconfig.app.json | 2 +- 9 files changed, 312 insertions(+), 39 deletions(-) delete mode 100644 editor/src/App.css delete mode 100644 editor/src/Canvas.css create mode 100644 editor/src/style.css diff --git a/editor/src/App.css b/editor/src/App.css deleted file mode 100644 index e69de29..0000000 diff --git a/editor/src/App.tsx b/editor/src/App.tsx index 00365d6..70717bb 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -1,5 +1,5 @@ import { useState, type ReactElement } from "react"; -import "./App.css"; +import "./style.css"; import Canvas from "./Canvas"; import { Editor } from "./Editor"; import Toolbar from "./Toolbar"; @@ -10,8 +10,10 @@ function App(): ReactElement { return ( <>

nandsim

- - +
+ + +
); } diff --git a/editor/src/Canvas.css b/editor/src/Canvas.css deleted file mode 100644 index 6d8e94e..0000000 --- a/editor/src/Canvas.css +++ /dev/null @@ -1,6 +0,0 @@ - -.EditorView { - canvas { - image-rendering: pixelated; - } -} diff --git a/editor/src/Canvas.tsx b/editor/src/Canvas.tsx index 44efc05..6aae2fc 100644 --- a/editor/src/Canvas.tsx +++ b/editor/src/Canvas.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, type ReactElement } from "react"; -import "./Canvas.css"; import { V2, type Editor } from "./Editor"; type Props = { editor: Editor }; @@ -15,7 +14,7 @@ function Canvas({ editor }: Props): ReactElement { return ( <> -
+
{ - console.log(ev.key); + editor.keyDown(ev.key); }} onKeyUp={(ev) => { - console.log(ev.key); + editor.keyUp(ev.key); }} />
diff --git a/editor/src/Editor.ts b/editor/src/Editor.ts index 31fe37f..b3e6f97 100644 --- a/editor/src/Editor.ts +++ b/editor/src/Editor.ts @@ -2,24 +2,78 @@ export type V2 = { x: number; y: number }; export const V2 = (x: number, y: number): V2 => ({ x, y }); export class Editor { - private offset = V2(0, 0); - private dragging = false; - private renderNeeded = false; + private cx = new Cx(); render(canvas: HTMLCanvasElement) { - const cx = canvas.getContext("2d")!; + this.cx.render(canvas); + } - cx.imageSmoothingEnabled = false; - cx.fillStyle = "#666"; - cx.fillRect(0, 0, canvas.width, canvas.height); + renderIfNeeded(canvas: HTMLCanvasElement) { + this.cx.renderIfNeeded(canvas); + } + + mouseDown(pos: V2) { + this.cx.mouseDown(pos); + } + + mouseUp(pos: V2) { + this.cx.mouseUp(pos); + } + + mouseMove(deltaPos: V2) { + this.cx.mouseMove(deltaPos); + } + + keyDown(key: string) { + this.cx.keyDown(key); + } + + keyUp(key: string) { + this.cx.keyUp(key); + } + + selectTool(tool: Tool) { + this.cx.selectTool(tool); + } + + selectedTool(): Tool | null { + return this.cx.selectedTool(); + } + + tools(): Tool[] { + return ["select", "pan", "and"]; + } + + addUpdateAction(action: () => void): object { + return this.cx.addUpdateAction(action); + } + + removeUpdateAction(actionId: object) { + this.cx.removeUpdateAction(actionId); + } +} + +class Cx { + private offset = V2(0, 0); + private renderNeeded = false; + private state = new Normal(this) as State; + private updateActions: (() => void)[] = []; + private selectionRect: SelectionRect | null = null; + + render(canvas: HTMLCanvasElement) { + const c = canvas.getContext("2d")!; + + c.imageSmoothingEnabled = false; + c.fillStyle = "#666"; + c.fillRect(0, 0, canvas.width, canvas.height); const gridSize = { x: 20, y: 20 }; const dotSize = { x: 2, y: 2 }; - cx.fillStyle = "#111"; + c.fillStyle = "#111"; for (let y = 0; y < canvas.width / gridSize.x + 1; ++y) { for (let x = 0; x < canvas.height / gridSize.y + 1; ++x) { - cx.fillRect( + c.fillRect( (this.offset.x % gridSize.x) + x * gridSize.x - dotSize.x / 2, (this.offset.y % gridSize.y) + y * gridSize.y - dotSize.y / 2, dotSize.x, @@ -27,6 +81,19 @@ export class Editor { ); } } + + if (this.selectionRect) { + const { + pos: { x, y }, + size: { x: w, y: h }, + } = this.selectionRect; + + c.fillStyle = `#ff880088`; + c.fillRect(x, y, w, h); + c.strokeStyle = `#ff8800`; + c.lineWidth = 2; + c.strokeRect(x, y, w, h); + } } renderIfNeeded(canvas: HTMLCanvasElement) { @@ -37,22 +104,175 @@ export class Editor { } mouseDown(pos: V2) { - this.dragging = true; + this.state.onMouseDown?.(pos); } - mouseUp(pos: V2) { - this.dragging = false; + this.state.onMouseUp?.(pos); + } + mouseMove(deltaPos: V2) { + this.state.onMouseMove?.(deltaPos); + } + keyDown(key: string) { + this.state.onKeyDown?.(key); + } + keyUp(key: string) { + this.state.onKeyUp?.(key); + } + selectTool(tool: Tool) { + this.state.selectTool?.(tool); + } + selectedTool(): Tool | null { + return this.state.selectedTool?.() ?? null; } - mouseMove(deltaPos: V2) { - if (this.dragging) { - this.offset.x += deltaPos.x; - this.offset.y += deltaPos.y; - this.renderNeeded = true; + addUpdateAction(action: () => void): object { + this.updateActions.push(action); + return action; + } + + removeUpdateAction(actionId: object) { + this.updateActions = this.updateActions.filter( + (action) => action !== actionId, + ); + } + + transitionTo(S: S) { + this.state = new S(this); + this.notifyListeners(); + } + + notifyListeners() { + for (const action of this.updateActions) { + action(); } } - selectTool(tool: Tool) {} + moveOffset(deltaPos: V2) { + this.offset.x += deltaPos.x; + this.offset.y += deltaPos.y; + this.renderNeeded = true; + } + + addSelectionRect(pos: V2) { + this.selectionRect = { pos, size: V2(0, 0) }; + this.renderNeeded = true; + } + + removeSelectionRect() { + this.selectionRect = null; + this.renderNeeded = true; + } + + moveSelectionRect(deltaPos: V2) { + if (this.selectionRect) { + this.selectionRect.size.x += deltaPos.x; + this.selectionRect.size.y += deltaPos.y; + this.renderNeeded = true; + } + } } -type Tool = "and" | "not" | "pin in" | "pin out"; +interface State { + onMouseDown?(pos: V2): void; + onMouseUp?(pos: V2): void; + onMouseMove?(deltaPos: V2): void; + onKeyDown?(key: string): void; + onKeyUp?(key: string): void; + selectTool?(tool: Tool): void; + selectedTool?(): Tool | null; +} + +class Normal implements State { + constructor(private cx: Cx) {} + + selectTool(tool: Tool): void { + switch (tool) { + case "pan": + this.cx.transitionTo(Panning); + break; + } + } + + onMouseDown(pos: V2): void { + this.cx.addSelectionRect(pos); + this.cx.transitionTo(Selecting); + } + + onKeyDown(key: string): void { + if (key === "Shift") { + this.cx.transitionTo(Panning); + return; + } + } + + selectedTool(): Tool | null { + return "select"; + } +} + +class Panning implements State { + private dragging = false; + + constructor(private cx: Cx) {} + + onMouseDown(_pos: V2): void { + this.dragging = true; + } + + onMouseUp(_pos: V2): void { + this.dragging = false; + } + + onMouseMove(deltaPos: V2): void { + if (this.dragging) { + this.cx.moveOffset(deltaPos); + } + } + + onKeyDown(key: string): void { + if (key === "Escape") { + this.cx.transitionTo(Normal); + return; + } + } + + onKeyUp(key: string): void { + if (key === "Shift") { + this.cx.transitionTo(Normal); + return; + } + } + + selectTool(tool: Tool): void { + this.cx.transitionTo(Normal); + this.cx.selectTool(tool); + } + + selectedTool(): Tool | null { + return "pan"; + } +} + +type SelectionRect = { + pos: V2; + size: V2; +}; + +class Selecting implements State { + constructor(private cx: Cx) {} + + onMouseUp(_pos: V2): void { + this.cx.removeSelectionRect(); + this.cx.transitionTo(Normal); + } + + onMouseMove(deltaPos: V2): void { + this.cx.moveSelectionRect(deltaPos); + } + + selectedTool(): Tool | null { + return "select"; + } +} + +type Tool = "select" | "pan" | "and"; diff --git a/editor/src/Toolbar.tsx b/editor/src/Toolbar.tsx index 0334570..41ada01 100644 --- a/editor/src/Toolbar.tsx +++ b/editor/src/Toolbar.tsx @@ -1,16 +1,33 @@ -import type { ReactElement } from "react"; +import { useEffect, useState, type ReactElement } from "react"; import type { Editor } from "./Editor"; type Props = { editor: Editor }; +function useUpdate(): [number, () => void] { + const [value, setValue] = useState(0); + return [value, () => setValue(value + 1)] as const; +} + function Toolbar({ editor }: Props): ReactElement { + const [uid, update] = useUpdate(); + + useEffect(() => { + const handle = editor.addUpdateAction(() => update()); + return () => editor.removeUpdateAction(handle); + }); + return ( <> -
- - - - +
+ {editor.tools().map((tool, key) => ( + + ))}
); diff --git a/editor/src/index.css b/editor/src/index.css index 962bd0e..d33751d 100644 --- a/editor/src/index.css +++ b/editor/src/index.css @@ -11,3 +11,7 @@ body { margin: 0; } + +h1 { + margin: 0; +} diff --git a/editor/src/style.css b/editor/src/style.css new file mode 100644 index 0000000..a4c629c --- /dev/null +++ b/editor/src/style.css @@ -0,0 +1,37 @@ + +.Editor { + display: flex; + flex-direction: row; + justify-content: center; + + .Toolbar { + + display: flex; + flex-direction: column; + gap: 2px; + + button { + padding-left: 5px; + padding-right: 5px; + padding-top: 5px; + padding-bottom: 5px; + + font-size: 1rem; + text-transform: capitalize; + + width: 200px; + text-align: left; + border: 2px solid gray; + border-radius: 5px; + } + button.active { + border: 2px solid #ff8800; + } + } + + .Canvas { + canvas { + image-rendering: pixelated; + } + } +} diff --git a/editor/tsconfig.app.json b/editor/tsconfig.app.json index 7f42e5f..7e96ad1 100644 --- a/editor/tsconfig.app.json +++ b/editor/tsconfig.app.json @@ -18,7 +18,7 @@ /* Linting */ "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + // "erasableSyntaxOnly": true, // i don't see why this isn't allowed in app code "noFallthroughCasesInSwitch": true }, "include": ["src"]