pan and select
This commit is contained in:
parent
eb40f5fba9
commit
3a38e90178
@ -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 (
|
||||
<>
|
||||
<h1>nandsim</h1>
|
||||
<Canvas editor={editor} />
|
||||
<Toolbar editor={editor} />
|
||||
<div className="Editor">
|
||||
<Toolbar editor={editor} />
|
||||
<Canvas editor={editor} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
|
||||
.EditorView {
|
||||
canvas {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
}
|
||||
@ -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 (
|
||||
<>
|
||||
<div className="EditorView">
|
||||
<div className="Canvas">
|
||||
<canvas
|
||||
ref={ref}
|
||||
width={1000}
|
||||
@ -37,10 +36,10 @@ function Canvas({ editor }: Props): ReactElement {
|
||||
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
|
||||
}}
|
||||
onKeyDown={(ev) => {
|
||||
console.log(ev.key);
|
||||
editor.keyDown(ev.key);
|
||||
}}
|
||||
onKeyUp={(ev) => {
|
||||
console.log(ev.key);
|
||||
editor.keyUp(ev.key);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -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 extends { new (cx: Cx): State }>(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";
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<div>
|
||||
<button onClick={() => editor.selectTool("and")}>and</button>
|
||||
<button onClick={() => editor.selectTool("not")}>not</button>
|
||||
<button onClick={() => editor.selectTool("pin in")}>pin in</button>
|
||||
<button onClick={() => editor.selectTool("pin out")}>pin out</button>
|
||||
<div className="Toolbar">
|
||||
{editor.tools().map((tool, key) => (
|
||||
<button
|
||||
key={`${uid}${key}`}
|
||||
className={editor.selectedTool() === tool ? "active" : ""}
|
||||
onClick={() => editor.selectTool(tool)}
|
||||
>
|
||||
{tool}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -11,3 +11,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
37
editor/src/style.css
Normal file
37
editor/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user