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 (
<>
+
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"]