pan and select

This commit is contained in:
sfja 2026-05-09 03:37:43 +02:00
parent eb40f5fba9
commit 3a38e90178
9 changed files with 312 additions and 39 deletions

View File

View File

@ -1,5 +1,5 @@
import { useState, type ReactElement } from "react"; import { useState, type ReactElement } from "react";
import "./App.css"; import "./style.css";
import Canvas from "./Canvas"; import Canvas from "./Canvas";
import { Editor } from "./Editor"; import { Editor } from "./Editor";
import Toolbar from "./Toolbar"; import Toolbar from "./Toolbar";
@ -10,8 +10,10 @@ function App(): ReactElement {
return ( return (
<> <>
<h1>nandsim</h1> <h1>nandsim</h1>
<Canvas editor={editor} /> <div className="Editor">
<Toolbar editor={editor} /> <Toolbar editor={editor} />
<Canvas editor={editor} />
</div>
</> </>
); );
} }

View File

@ -1,6 +0,0 @@
.EditorView {
canvas {
image-rendering: pixelated;
}
}

View File

@ -1,5 +1,4 @@
import { useEffect, useRef, type ReactElement } from "react"; import { useEffect, useRef, type ReactElement } from "react";
import "./Canvas.css";
import { V2, type Editor } from "./Editor"; import { V2, type Editor } from "./Editor";
type Props = { editor: Editor }; type Props = { editor: Editor };
@ -15,7 +14,7 @@ function Canvas({ editor }: Props): ReactElement {
return ( return (
<> <>
<div className="EditorView"> <div className="Canvas">
<canvas <canvas
ref={ref} ref={ref}
width={1000} width={1000}
@ -37,10 +36,10 @@ function Canvas({ editor }: Props): ReactElement {
editor.renderIfNeeded(ev.target as HTMLCanvasElement); editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}} }}
onKeyDown={(ev) => { onKeyDown={(ev) => {
console.log(ev.key); editor.keyDown(ev.key);
}} }}
onKeyUp={(ev) => { onKeyUp={(ev) => {
console.log(ev.key); editor.keyUp(ev.key);
}} }}
/> />
</div> </div>

View File

@ -2,24 +2,78 @@ export type V2 = { x: number; y: number };
export const V2 = (x: number, y: number): V2 => ({ x, y }); export const V2 = (x: number, y: number): V2 => ({ x, y });
export class Editor { export class Editor {
private offset = V2(0, 0); private cx = new Cx();
private dragging = false;
private renderNeeded = false;
render(canvas: HTMLCanvasElement) { render(canvas: HTMLCanvasElement) {
const cx = canvas.getContext("2d")!; this.cx.render(canvas);
}
cx.imageSmoothingEnabled = false; renderIfNeeded(canvas: HTMLCanvasElement) {
cx.fillStyle = "#666"; this.cx.renderIfNeeded(canvas);
cx.fillRect(0, 0, canvas.width, canvas.height); }
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 gridSize = { x: 20, y: 20 };
const dotSize = { x: 2, y: 2 }; 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 y = 0; y < canvas.width / gridSize.x + 1; ++y) {
for (let x = 0; x < canvas.height / gridSize.y + 1; ++x) { 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.x % gridSize.x) + x * gridSize.x - dotSize.x / 2,
(this.offset.y % gridSize.y) + y * gridSize.y - dotSize.y / 2, (this.offset.y % gridSize.y) + y * gridSize.y - dotSize.y / 2,
dotSize.x, 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) { renderIfNeeded(canvas: HTMLCanvasElement) {
@ -37,22 +104,175 @@ export class Editor {
} }
mouseDown(pos: V2) { mouseDown(pos: V2) {
this.dragging = true; this.state.onMouseDown?.(pos);
} }
mouseUp(pos: V2) { 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) { addUpdateAction(action: () => void): object {
if (this.dragging) { this.updateActions.push(action);
return action;
}
removeUpdateAction(actionId: object) {
this.updateActions = this.updateActions.filter(
(action) => action !== actionId,
);
}
transitionTo<S extends { new (cx: Cx): State }>(S: S) {
this.state = new S(this);
this.notifyListeners();
}
notifyListeners() {
for (const action of this.updateActions) {
action();
}
}
moveOffset(deltaPos: V2) {
this.offset.x += deltaPos.x; this.offset.x += deltaPos.x;
this.offset.y += deltaPos.y; this.offset.y += deltaPos.y;
this.renderNeeded = true; this.renderNeeded = true;
} }
addSelectionRect(pos: V2) {
this.selectionRect = { pos, size: V2(0, 0) };
this.renderNeeded = true;
} }
selectTool(tool: Tool) {} removeSelectionRect() {
this.selectionRect = null;
this.renderNeeded = true;
} }
type Tool = "and" | "not" | "pin in" | "pin out"; moveSelectionRect(deltaPos: V2) {
if (this.selectionRect) {
this.selectionRect.size.x += deltaPos.x;
this.selectionRect.size.y += deltaPos.y;
this.renderNeeded = true;
}
}
}
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";

View File

@ -1,16 +1,33 @@
import type { ReactElement } from "react"; import { useEffect, useState, type ReactElement } from "react";
import type { Editor } from "./Editor"; import type { Editor } from "./Editor";
type Props = { editor: 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 { function Toolbar({ editor }: Props): ReactElement {
const [uid, update] = useUpdate();
useEffect(() => {
const handle = editor.addUpdateAction(() => update());
return () => editor.removeUpdateAction(handle);
});
return ( return (
<> <>
<div> <div className="Toolbar">
<button onClick={() => editor.selectTool("and")}>and</button> {editor.tools().map((tool, key) => (
<button onClick={() => editor.selectTool("not")}>not</button> <button
<button onClick={() => editor.selectTool("pin in")}>pin in</button> key={`${uid}${key}`}
<button onClick={() => editor.selectTool("pin out")}>pin out</button> className={editor.selectedTool() === tool ? "active" : ""}
onClick={() => editor.selectTool(tool)}
>
{tool}
</button>
))}
</div> </div>
</> </>
); );

View File

@ -11,3 +11,7 @@
body { body {
margin: 0; margin: 0;
} }
h1 {
margin: 0;
}

37
editor/src/style.css Normal file
View File

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

View File

@ -18,7 +18,7 @@
/* Linting */ /* Linting */
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, // "erasableSyntaxOnly": true, // i don't see why this isn't allowed in app code
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"] "include": ["src"]