save components
This commit is contained in:
parent
c1e72f9ae2
commit
cbc416da46
@ -56,7 +56,13 @@ function Tabbar({ editor, canvasRef }: Props): ReactElement {
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button onClick={() => {}}>Close</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.events.send({ tag: "CloseComponent" });
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -4,10 +4,12 @@ import type { Editor } from "./editor/Editor";
|
||||
type Props = { editor: Editor; canvasRef: RefObject<HTMLCanvasElement | null> };
|
||||
|
||||
function Toolbar({ editor, canvasRef }: Props): ReactElement {
|
||||
const [updateId, update] = useState(0);
|
||||
const [selectedTool, setSelectedTool] = useState("select");
|
||||
|
||||
useEffect(() =>
|
||||
editor.events.subscribe(["ShowSelectedTool"], (ev) => {
|
||||
update(updateId + 1);
|
||||
setSelectedTool(ev.tool);
|
||||
}),
|
||||
);
|
||||
@ -25,6 +27,10 @@ function Toolbar({ editor, canvasRef }: Props): ReactElement {
|
||||
editor.events.send({ tag: "SelectTool", tool });
|
||||
canvasRef.current?.focus();
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
editor.events.send({ tag: "OpenTabWithTool", tool });
|
||||
canvasRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{tool}
|
||||
</button>
|
||||
|
||||
@ -8,6 +8,8 @@ import {
|
||||
V2,
|
||||
} from "./V2";
|
||||
import * as ir from "./ir";
|
||||
import { Sim } from "./sim";
|
||||
import * as ser from "./serialize";
|
||||
|
||||
export class Board {
|
||||
private components: Component[] = [];
|
||||
@ -19,6 +21,14 @@ export class Board {
|
||||
private hoveredOverJoint: Joint | null = null;
|
||||
private hoveredOverWire: Wire | null = null;
|
||||
|
||||
private stateWireMap = new Map<ir.State, Wire[]>();
|
||||
private activatedWires = new Set<Wire>();
|
||||
private state = new Map<ir.State, boolean>();
|
||||
|
||||
private activatedOutputs = new Set<Component>();
|
||||
|
||||
private wireCachedState = new Map<Wire, ir.State>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
static withExample(repo: ComponentRepo): Board {
|
||||
@ -42,6 +52,34 @@ export class Board {
|
||||
return board;
|
||||
}
|
||||
|
||||
static fromSerialized(
|
||||
data: ser.Board,
|
||||
kindMap: Map<string, ComponentKind>,
|
||||
): Board {
|
||||
const board = new Board();
|
||||
board.components = data.components.map((c) =>
|
||||
Component.fromSerialized(c, kindMap),
|
||||
);
|
||||
board.joints = data.joints.map((j) => Joint.fromSerialized(j));
|
||||
board.wires = data.wires.map((w) =>
|
||||
Wire.fromSerialized(w, board.components, board.joints),
|
||||
);
|
||||
return board;
|
||||
}
|
||||
|
||||
serialize(): ser.Board {
|
||||
return {
|
||||
components: this.components.map((c) => c.serialize()),
|
||||
joints: this.joints.map((j) => j.serialize()),
|
||||
wires: this.wires.map((w) =>
|
||||
w.serialize(
|
||||
new Map(this.components.map((v, i) => [v, i])),
|
||||
new Map(this.joints.map((v, i) => [v, i])),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
canPlaceComponent(kind: ComponentKind, pos: V2): boolean {
|
||||
return !this.components.some((comp) =>
|
||||
rectsCollide(comp.pos, comp.kind.size, pos, kind.size),
|
||||
@ -52,20 +90,47 @@ export class Board {
|
||||
this.components.push(new Component(kind, pos));
|
||||
}
|
||||
|
||||
render(r: Renderer, selection: Selection | null) {
|
||||
render(
|
||||
r: Renderer,
|
||||
selection: Selection | null,
|
||||
inputStates: Map<Component, boolean>,
|
||||
) {
|
||||
for (const comp of this.components) {
|
||||
const { pos, kind } = comp;
|
||||
if (selection?.isComponentSelected(comp)) {
|
||||
r.drawComponentBodySelected(pos, kind);
|
||||
} else {
|
||||
r.drawComponentBody(pos, kind);
|
||||
}
|
||||
|
||||
const isSelected = selection?.isComponentSelected(comp);
|
||||
|
||||
for (const wire of this.wires) {
|
||||
if (this.hoveredOverWire == wire) {
|
||||
r.drawWireHovered(wire.beginPos(), wire.endPos());
|
||||
} else {
|
||||
r.drawWire(wire.beginPos(), wire.endPos());
|
||||
r.drawWire(
|
||||
wire.beginPos(),
|
||||
wire.endPos(),
|
||||
this.activatedWires.has(wire),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (comp.kind.label === "input") {
|
||||
const active = inputStates.get(comp) ?? false;
|
||||
if (isSelected) {
|
||||
r.drawInputComponentBodySelected(pos, kind, active);
|
||||
} else {
|
||||
r.drawInputComponentBody(pos, kind, active);
|
||||
}
|
||||
} else if (comp.kind.label === "output") {
|
||||
const active = this.activatedOutputs.has(comp);
|
||||
if (isSelected) {
|
||||
r.drawOutputComponentBodySelected(pos, kind, active);
|
||||
} else {
|
||||
r.drawOutputComponentBody(pos, kind, active);
|
||||
}
|
||||
} else {
|
||||
if (isSelected) {
|
||||
r.drawComponentBodySelected(pos, kind);
|
||||
} else {
|
||||
r.drawComponentBody(pos, kind);
|
||||
}
|
||||
}
|
||||
|
||||
@ -231,9 +296,68 @@ export class Board {
|
||||
);
|
||||
}
|
||||
|
||||
toIr(): ir.Component {
|
||||
console.log("Lowering to IR");
|
||||
inputsOrdered(): Component[] {
|
||||
return this.components
|
||||
.filter((c) => c.kind.label === "input")
|
||||
.toSorted((a, b) => a.pos.y - b.pos.y);
|
||||
}
|
||||
|
||||
outputsOrdered(): Component[] {
|
||||
return this.components
|
||||
.filter((c) => c.kind.label === "output")
|
||||
.toSorted((a, b) => a.pos.y - b.pos.y);
|
||||
}
|
||||
|
||||
inputArray(activatedInputs: Map<Component, boolean>): boolean[] {
|
||||
return this.inputsOrdered().map((c) => activatedInputs.get(c) ?? false);
|
||||
}
|
||||
|
||||
outputCount(): number {
|
||||
return this.components.filter((c) => c.kind.label === "output").length;
|
||||
}
|
||||
|
||||
simulate(inputStates: Map<Component, boolean>) {
|
||||
console.log("Lowering to IR");
|
||||
const comp = this.toIr();
|
||||
console.log("Before optimizing");
|
||||
console.log(...new ir.ComponentPrinter().stringifyToConsole(comp));
|
||||
|
||||
const replacedStates: [ir.State, ir.State][] = [];
|
||||
new ir.ComponentOptimizer(comp, replacedStates).optimize();
|
||||
|
||||
for (const [oldState, newState] of replacedStates) {
|
||||
this.stateWireMap
|
||||
.get(newState)!
|
||||
.push(...this.stateWireMap.get(oldState)!);
|
||||
this.stateWireMap.delete(oldState);
|
||||
}
|
||||
|
||||
console.log("After optimizing");
|
||||
console.log(...new ir.ComponentPrinter().stringifyToConsole(comp));
|
||||
|
||||
const inputs = this.inputArray(inputStates);
|
||||
const outputs = this.outputsOrdered();
|
||||
const outputStates = outputs.map(() => false);
|
||||
const sim = new Sim(comp, inputs, outputStates, this.state);
|
||||
|
||||
sim.simulate();
|
||||
|
||||
this.activatedWires.clear();
|
||||
for (const state of sim.activatedState()) {
|
||||
for (const wire of this.stateWireMap.get(state) ?? []) {
|
||||
this.activatedWires.add(wire);
|
||||
}
|
||||
}
|
||||
|
||||
this.activatedOutputs.clear();
|
||||
for (const [i, active] of outputStates.entries()) {
|
||||
if (active) {
|
||||
this.activatedOutputs.add(outputs[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toIr(): ir.Component {
|
||||
for (const comp of this.components) {
|
||||
comp.markedWiresConnected = [];
|
||||
}
|
||||
@ -262,9 +386,13 @@ export class Board {
|
||||
|
||||
const b = new ir.ComponentBuilder(inputs.length, outputs.length, "main");
|
||||
|
||||
this.stateWireMap.clear();
|
||||
const wireStates = new Map<Wire, ir.State>();
|
||||
for (const wire of this.wires) {
|
||||
wireStates.set(wire, b.makeState());
|
||||
const state = this.wireCachedState.get(wire) ?? b.makeState();
|
||||
this.wireCachedState.set(wire, state);
|
||||
wireStates.set(wire, state);
|
||||
this.stateWireMap.set(state, [wire]);
|
||||
}
|
||||
|
||||
const compSet = new Set<Component>();
|
||||
@ -362,7 +490,7 @@ export interface BoardVisitor {
|
||||
}
|
||||
|
||||
export class ComponentRepo {
|
||||
private defs = new Map<string, ComponentKind>();
|
||||
public defs = new Map<string, ComponentKind>();
|
||||
|
||||
static withDefaults(): ComponentRepo {
|
||||
const repo = new ComponentRepo();
|
||||
@ -374,6 +502,20 @@ export class ComponentRepo {
|
||||
return repo;
|
||||
}
|
||||
|
||||
static fromSerialized(data: ser.ComponentRepo): ComponentRepo {
|
||||
const repo = new ComponentRepo();
|
||||
repo.defs = new Map(
|
||||
data.defs.map((e) => [e[0], ComponentKind.fromSerialized(e[1])]),
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
serialize(): ser.ComponentRepo {
|
||||
return {
|
||||
defs: [...this.defs.entries()].map((e) => [e[0], e[1].serialize()]),
|
||||
};
|
||||
}
|
||||
|
||||
available(): string[] {
|
||||
return [...this.defs.keys()];
|
||||
}
|
||||
@ -399,6 +541,23 @@ export class Component {
|
||||
public pos: V2,
|
||||
) {}
|
||||
|
||||
static fromSerialized(
|
||||
data: ser.Component,
|
||||
kindMap: Map<string, ComponentKind>,
|
||||
): Component {
|
||||
return new Component(
|
||||
kindMap.get(data.kindKey)!,
|
||||
V2.fromSerialized(data.pos),
|
||||
);
|
||||
}
|
||||
|
||||
serialize(): ser.Component {
|
||||
return {
|
||||
kindKey: this.kind.label,
|
||||
pos: this.pos.serialize(),
|
||||
};
|
||||
}
|
||||
|
||||
mouseOver(pos: V2): ComponentMouseOverResult | null {
|
||||
const {
|
||||
pos: { x, y },
|
||||
@ -462,6 +621,24 @@ export class ComponentKind {
|
||||
public outputs: (string | null)[],
|
||||
) {}
|
||||
|
||||
static fromSerialized(data: ser.ComponentKind): ComponentKind {
|
||||
return new ComponentKind(
|
||||
V2.fromSerialized(data.size),
|
||||
data.label,
|
||||
data.inputs,
|
||||
data.outputs,
|
||||
);
|
||||
}
|
||||
|
||||
serialize(): ser.ComponentKind {
|
||||
return {
|
||||
size: this.size.serialize(),
|
||||
label: this.label,
|
||||
inputs: this.inputs,
|
||||
outputs: this.outputs,
|
||||
};
|
||||
}
|
||||
|
||||
inputPinOffsets(): number[] {
|
||||
return this.inputs.map(
|
||||
(_, i) => ((i + 1) * this.size.y) / (this.inputs.length + 1),
|
||||
@ -479,6 +656,14 @@ export class Joint {
|
||||
|
||||
constructor(public pos: V2) {}
|
||||
|
||||
static fromSerialized(data: ser.Joint): Joint {
|
||||
return new Joint(V2.fromSerialized(data.pos));
|
||||
}
|
||||
|
||||
serialize(): ser.Joint {
|
||||
return { pos: this.pos.serialize() };
|
||||
}
|
||||
|
||||
isMouseOver(pos: V2): boolean {
|
||||
return this.pos.distance(pos) < 6;
|
||||
}
|
||||
@ -500,6 +685,51 @@ export class Wire {
|
||||
private end: WireConnection,
|
||||
) {}
|
||||
|
||||
static fromSerialized(
|
||||
data: ser.Wire,
|
||||
comps: Component[],
|
||||
joints: Joint[],
|
||||
): Wire {
|
||||
const [begin, end] = [data.begin, data.end].map((conn): WireConnection => {
|
||||
switch (conn.tag) {
|
||||
case "Joint":
|
||||
return { tag: "Joint", joint: joints[conn.jointIdx] };
|
||||
case "InputPin":
|
||||
case "OutputPin":
|
||||
return {
|
||||
tag: conn.tag,
|
||||
comp: comps[conn.compIdx],
|
||||
i: conn.i,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return new Wire(begin, end);
|
||||
}
|
||||
|
||||
serialize(
|
||||
compIdxMap: Map<Component, number>,
|
||||
jointIdxMap: Map<Joint, number>,
|
||||
): ser.Wire {
|
||||
const [begin, end] = [this.begin, this.end].map(
|
||||
(conn): ser.WireConnection => {
|
||||
switch (conn.tag) {
|
||||
case "Joint":
|
||||
return { tag: "Joint", jointIdx: jointIdxMap.get(conn.joint)! };
|
||||
case "InputPin":
|
||||
case "OutputPin":
|
||||
return {
|
||||
tag: conn.tag,
|
||||
compIdx: compIdxMap.get(conn.comp)!,
|
||||
i: conn.i,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return { begin, end };
|
||||
}
|
||||
|
||||
isInput(): boolean {
|
||||
return this.mapConns((connection) => connection.tag === "InputPin").some(
|
||||
(v) => v,
|
||||
@ -633,13 +863,13 @@ export type WireConnection =
|
||||
const defaultDefs = [
|
||||
{
|
||||
label: "input",
|
||||
size: v2(80, 40),
|
||||
size: v2(120, 40),
|
||||
inputs: [],
|
||||
outputs: [null],
|
||||
},
|
||||
{
|
||||
label: "output",
|
||||
size: v2(80, 40),
|
||||
size: v2(140, 40),
|
||||
inputs: [null],
|
||||
outputs: [],
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Board, ComponentRepo } from "./Board";
|
||||
import { Board, Component, ComponentRepo } from "./Board";
|
||||
import { SelectionBox } from "./SelectionBox";
|
||||
import { ComponentPlacer } from "./ComponentPlacer";
|
||||
import { Selection } from "./Selection";
|
||||
@ -11,6 +11,8 @@ import { ViewPos } from "./ViewPos";
|
||||
import { type ComponentKind } from "./Board";
|
||||
import type { EventUnsub } from "./events";
|
||||
import { Project } from "./Project";
|
||||
import * as ir from "./ir";
|
||||
import { Sim } from "./sim";
|
||||
|
||||
export class Editor {
|
||||
public events = new EventBus();
|
||||
@ -25,6 +27,8 @@ export class Editor {
|
||||
public selection: Selection | null = null;
|
||||
public connectingWire: ConnectingWire | null = null;
|
||||
|
||||
public inputStates = new Map<Component, boolean>();
|
||||
|
||||
public keysPressed = new Set<string>();
|
||||
|
||||
private state: State = new Normal(this);
|
||||
@ -38,10 +42,14 @@ export class Editor {
|
||||
"KeyDown",
|
||||
"KeyUp",
|
||||
"SelectTool",
|
||||
"OpenTabWithTool",
|
||||
"CreateTab",
|
||||
"CloseComponent",
|
||||
"SelectTab",
|
||||
"SaveComponent",
|
||||
"RenameComponent",
|
||||
"SimulateRequest",
|
||||
"SaveRequest",
|
||||
],
|
||||
(ev) => {
|
||||
switch (ev.tag) {
|
||||
@ -54,6 +62,11 @@ export class Editor {
|
||||
case "SelectTool":
|
||||
this.onSelectTool(ev.tool);
|
||||
break;
|
||||
case "OpenTabWithTool": {
|
||||
const idx = this.project.tabWithTool(ev.tool);
|
||||
this.switchTab(idx);
|
||||
break;
|
||||
}
|
||||
case "CreateTab": {
|
||||
const idx = this.project.newTab();
|
||||
this.switchTab(idx);
|
||||
@ -63,6 +76,10 @@ export class Editor {
|
||||
this.switchTab(ev.idx);
|
||||
break;
|
||||
}
|
||||
case "CloseComponent": {
|
||||
this.switchTab(this.project.closeTab());
|
||||
break;
|
||||
}
|
||||
case "SaveComponent": {
|
||||
this.project.saveComponent();
|
||||
break;
|
||||
@ -71,6 +88,14 @@ export class Editor {
|
||||
this.project.renameComponent(ev.newName);
|
||||
break;
|
||||
}
|
||||
case "SimulateRequest": {
|
||||
// this.runSimulation();
|
||||
break;
|
||||
}
|
||||
case "SaveRequest": {
|
||||
this.project.save();
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.events.send({ tag: "RenderRequest" });
|
||||
},
|
||||
@ -84,7 +109,7 @@ export class Editor {
|
||||
|
||||
r.clear();
|
||||
r.drawGrid();
|
||||
this.board.render(r, this.selection);
|
||||
this.board.render(r, this.selection, this.inputStates);
|
||||
this.selectionBox?.render(r);
|
||||
this.componentPlacer?.render(r);
|
||||
this.connectingWire?.render(r);
|
||||
@ -120,15 +145,10 @@ export class Editor {
|
||||
}
|
||||
|
||||
runSimulation() {
|
||||
// const comp = this.board.toIr();
|
||||
// console.log("Before optimizing");
|
||||
// console.log(...new ir.ComponentPrinter().stringifyToConsole(comp));
|
||||
// new ir.ComponentOptimizer(comp).optimize();
|
||||
// console.log("After optimizing");
|
||||
// console.log(...new ir.ComponentPrinter().stringifyToConsole(comp));
|
||||
// const sim = new Sim(comp, [], []);
|
||||
// sim.simulate();
|
||||
this.board.simulate(this.inputStates);
|
||||
this.events.send({ tag: "RenderRequest" });
|
||||
}
|
||||
|
||||
private onSelectTool(tool: string) {
|
||||
switch (tool) {
|
||||
case "pan":
|
||||
@ -142,7 +162,7 @@ export class Editor {
|
||||
this.transitionTo(new Placing(this, tool));
|
||||
break;
|
||||
default:
|
||||
this.transitionTo(new Normal(this));
|
||||
this.transitionTo(new Placing(this, tool));
|
||||
}
|
||||
this.events.send({ tag: "ShowSelectedTool", tool });
|
||||
}
|
||||
@ -150,6 +170,7 @@ export class Editor {
|
||||
private switchTab(idx: number) {
|
||||
this.project.switchTab(idx);
|
||||
this.events.send({ tag: "ShowSelectedTab", idx });
|
||||
this.events.send({ tag: "ShowSelectedTool", tool: "" });
|
||||
this.selectionBox = null;
|
||||
this.componentPlacer = null;
|
||||
this.selection = null;
|
||||
@ -173,17 +194,29 @@ class Normal implements State {
|
||||
enter(): void {
|
||||
this.unsubscribe = this.cx.events.subscribe(
|
||||
[
|
||||
"MouseDownOffset",
|
||||
"MouseClickOffset",
|
||||
"MouseDoubleClickOffset",
|
||||
"MouseMoveOffset",
|
||||
"MouseDragBegin",
|
||||
"KeyDown",
|
||||
"MouseDoubleClick",
|
||||
],
|
||||
(ev) => {
|
||||
switch (ev.tag) {
|
||||
case "MouseDownOffset":
|
||||
this.onMouseDown(ev.pos);
|
||||
case "MouseClickOffset":
|
||||
this.onMouseClick(ev.pos);
|
||||
break;
|
||||
case "MouseDoubleClickOffset": {
|
||||
this.cx.board.handleMouseClick(ev.pos, {
|
||||
onComponentClicked: (comp) => {
|
||||
if (comp.kind.label === "input") {
|
||||
const val = this.cx.inputStates.get(comp) ?? false;
|
||||
this.cx.inputStates.set(comp, !val);
|
||||
this.cx.events.send({ tag: "SimulateRequest" });
|
||||
}
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "MouseMoveOffset":
|
||||
this.cx.board.updateMouseHover(ev.pos);
|
||||
break;
|
||||
@ -199,28 +232,18 @@ class Normal implements State {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "MouseDoubleClick": {
|
||||
this.cx.board.handleMouseClick(ev.pos, {
|
||||
onComponentClicked: (comp) => {
|
||||
if (comp.kind.label === "input") {
|
||||
}
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.cx.events.send({ tag: "ShowSelectedTool", tool: "select" });
|
||||
this.cx.runSimulation();
|
||||
}
|
||||
|
||||
leave(): void {
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
private onMouseDown(pos: V2): void {
|
||||
private onMouseClick(pos: V2): void {
|
||||
this.cx.board.handleMouseClick(pos, {
|
||||
onInputPinClicked: (comp, i) => {
|
||||
this.cx.connectingWire = new ConnectingWire(
|
||||
@ -319,6 +342,7 @@ class Placing implements State {
|
||||
const boardPos = ev.pos;
|
||||
if (this.cx.board.canPlaceComponent(this.compDef, boardPos)) {
|
||||
this.cx.board.placeComponent(this.compDef, boardPos);
|
||||
this.cx.events.send({ tag: "SaveRequest" });
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
}
|
||||
break;
|
||||
@ -378,6 +402,7 @@ class Selecting implements State {
|
||||
}
|
||||
this.cx.board.deleteSelection(this.cx.selection);
|
||||
this.cx.selection = null;
|
||||
this.cx.events.send({ tag: "SaveRequest" });
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
}
|
||||
break;
|
||||
@ -429,6 +454,8 @@ class Selecting implements State {
|
||||
class Moving implements State {
|
||||
private unsubscribe!: EventUnsub;
|
||||
|
||||
private hasMoved = false;
|
||||
|
||||
constructor(private cx: Editor) {}
|
||||
|
||||
enter(): void {
|
||||
@ -440,6 +467,7 @@ class Moving implements State {
|
||||
this.cx.transitionTo(new Selecting(this.cx));
|
||||
break;
|
||||
case "MouseMove":
|
||||
this.hasMoved = true;
|
||||
this.cx.selection?.move(ev.deltaPos);
|
||||
break;
|
||||
}
|
||||
@ -448,6 +476,9 @@ class Moving implements State {
|
||||
}
|
||||
|
||||
leave(): void {
|
||||
if (this.hasMoved) {
|
||||
this.cx.events.send({ tag: "SaveRequest" });
|
||||
}
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
@ -514,11 +545,11 @@ class Wiring implements State {
|
||||
|
||||
enter(): void {
|
||||
this.unsubscribe = this.cx.events.subscribe(
|
||||
["MouseDownOffset", "MouseMoveOffset", "KeyDown"],
|
||||
["MouseClickOffset", "MouseMoveOffset", "KeyDown"],
|
||||
(ev) => {
|
||||
switch (ev.tag) {
|
||||
case "MouseDownOffset":
|
||||
this.onMouseDown(ev.pos);
|
||||
case "MouseClickOffset":
|
||||
this.onMouseClick(ev.pos);
|
||||
break;
|
||||
case "MouseMoveOffset": {
|
||||
if (!this.cx.connectingWire) {
|
||||
@ -545,21 +576,25 @@ class Wiring implements State {
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
private onMouseDown(pos: V2): void {
|
||||
private onMouseClick(pos: V2): void {
|
||||
this.cx.connectingWire?.move(pos);
|
||||
if (
|
||||
this.cx.board.handleMouseClick(pos, {
|
||||
onInputPinClicked: (comp, i) => {
|
||||
this.cx.connectingWire!.connectToInput(this.cx.board, comp, i);
|
||||
this.cx.events.send({ tag: "SaveRequest" });
|
||||
this.cx.connectingWire = null;
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
},
|
||||
onOutputPinClicked: (comp, i) => {
|
||||
this.cx.connectingWire!.connectToOutput(this.cx.board, comp, i);
|
||||
this.cx.events.send({ tag: "SaveRequest" });
|
||||
this.cx.connectingWire = null;
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
},
|
||||
onJointClicked: (joint) => {
|
||||
this.cx.connectingWire!.connectToJoint(this.cx.board, joint);
|
||||
this.cx.events.send({ tag: "SaveRequest" });
|
||||
this.cx.connectingWire = null;
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { EventBus, EventUnsub } from "./events";
|
||||
import { v2, type V2 } from "./V2";
|
||||
|
||||
const doubleClickDelay = 200;
|
||||
const doubleClickDelay = 100;
|
||||
|
||||
export class Mouse {
|
||||
private state: State;
|
||||
@ -103,6 +103,7 @@ class FirstRelease implements State {
|
||||
private unsubscribe: EventUnsub;
|
||||
|
||||
private timeout: ReturnType<typeof setTimeout>;
|
||||
private totalDelta = v2(0, 0);
|
||||
|
||||
constructor(
|
||||
private cx: Mouse,
|
||||
@ -116,6 +117,11 @@ class FirstRelease implements State {
|
||||
this.cx.transitionTo(new SecondPress(this.cx, this.pos));
|
||||
break;
|
||||
case "MouseMove":
|
||||
this.totalDelta = this.totalDelta.add(ev.deltaPos);
|
||||
if (this.totalDelta.len() > 5) {
|
||||
this.cx.eventBus.send({ tag: "MouseClick", pos: this.pos });
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
}
|
||||
break;
|
||||
case "MouseLeave":
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
@ -127,6 +133,7 @@ class FirstRelease implements State {
|
||||
);
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.cx.eventBus.send({ tag: "MouseClick", pos: this.pos });
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
}, doubleClickDelay);
|
||||
}
|
||||
@ -205,8 +212,6 @@ class Dragging implements State {
|
||||
case "MouseLeave":
|
||||
this.cx.transitionTo(new Normal(this.cx));
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unexpected event ${ev.tag}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Board, ComponentRepo, type Component } from "./Board";
|
||||
import { Board, Component, ComponentRepo } from "./Board";
|
||||
import { type EventBus } from "./events";
|
||||
import * as ser from "./serialize";
|
||||
|
||||
export class Project {
|
||||
private current: BoardEditor;
|
||||
@ -8,15 +9,16 @@ export class Project {
|
||||
private constructor(
|
||||
private events: EventBus,
|
||||
private boardEditors: BoardEditor[],
|
||||
private components: Component[],
|
||||
public componentRepo: ComponentRepo,
|
||||
private savedBoards: Map<string, ser.Board>,
|
||||
) {
|
||||
this.current = boardEditors[this.selectedIdx];
|
||||
}
|
||||
|
||||
static loadLocalStoreOrInitNew(events: EventBus): Project {
|
||||
// globalThis.localStorage.removeItem("nandsim");
|
||||
if (globalThis.localStorage.getItem("nandsim")) {
|
||||
return this.loadLocalStorage();
|
||||
return this.loadLocalStorage(events);
|
||||
} else {
|
||||
return this.initNew(events);
|
||||
}
|
||||
@ -31,13 +33,57 @@ export class Project {
|
||||
board: Board.withExample(repo),
|
||||
},
|
||||
],
|
||||
[],
|
||||
repo,
|
||||
new Map(),
|
||||
);
|
||||
}
|
||||
|
||||
static loadLocalStorage(): Project {
|
||||
throw new Error("not implemented");
|
||||
static loadLocalStorage(events: EventBus): Project {
|
||||
const data = JSON.parse(
|
||||
globalThis.localStorage.getItem("nandsim")!,
|
||||
) as ser.Project;
|
||||
return Project.fromSerialized(data, events);
|
||||
}
|
||||
|
||||
save() {
|
||||
console.log("Saving");
|
||||
const data = this.serialize();
|
||||
globalThis.localStorage.setItem(
|
||||
"nandsim",
|
||||
JSON.stringify(this.serialize()),
|
||||
);
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
private static fromSerialized(data: ser.Project, events: EventBus): Project {
|
||||
const repo = ComponentRepo.fromSerialized(data.componentRepo);
|
||||
const project = new Project(
|
||||
events,
|
||||
data.boardEditors.map(
|
||||
(data): BoardEditor => ({
|
||||
name: data.name,
|
||||
board: Board.fromSerialized(data.board, repo.defs),
|
||||
}),
|
||||
),
|
||||
repo,
|
||||
new Map(data.savedBoards),
|
||||
);
|
||||
return project;
|
||||
}
|
||||
|
||||
private serialize(): ser.Project {
|
||||
const componentRepo = this.componentRepo.serialize();
|
||||
return {
|
||||
boardEditors: this.boardEditors.map(
|
||||
(b): ser.BoardEditor => ({
|
||||
name: b.name,
|
||||
board: b.board.serialize(),
|
||||
}),
|
||||
),
|
||||
currentBoardEditorIdx: this.selectedIdx,
|
||||
componentRepo,
|
||||
savedBoards: [...this.savedBoards],
|
||||
};
|
||||
}
|
||||
|
||||
currentBoard(): Board {
|
||||
@ -70,19 +116,21 @@ export class Project {
|
||||
this.events.send({ tag: "ShowSelectedTool", tool: this.current.name });
|
||||
}
|
||||
|
||||
closeTab(idx: number) {
|
||||
this.boardEditors.splice(idx, 1);
|
||||
closeTab(): number {
|
||||
const [removed] = this.boardEditors.splice(this.selectedIdx, 1);
|
||||
this.savedBoards.set(removed.name, removed.board.serialize());
|
||||
this.events.send({ tag: "SaveRequest" });
|
||||
|
||||
if (this.boardEditors.length === 0) {
|
||||
this.newTab();
|
||||
}
|
||||
this.selectedIdx = 0;
|
||||
this.current = this.boardEditors[this.selectedIdx];
|
||||
this.events.send({ tag: "ShowSelectedTab", idx: this.selectedIdx });
|
||||
return 0;
|
||||
}
|
||||
|
||||
renameComponent(newName: string) {
|
||||
this.current.name = newName;
|
||||
this.events.send({ tag: "ShowSelectedTab", idx: this.selectedIdx });
|
||||
this.events.send({ tag: "ShowSelectedTool", tool: this.current.name });
|
||||
}
|
||||
|
||||
saveComponent() {
|
||||
@ -92,6 +140,24 @@ export class Project {
|
||||
);
|
||||
this.events.send({ tag: "ShowSelectedTool", tool: this.current.name });
|
||||
}
|
||||
|
||||
tabWithTool(name: string): number {
|
||||
const foundIdx = this.boardEditors.findIndex((b) => b.name === name);
|
||||
|
||||
if (foundIdx != -1) {
|
||||
return foundIdx;
|
||||
}
|
||||
|
||||
const saved = this.savedBoards.get(name);
|
||||
if (!saved) throw new Error(`cannot open '${name}'`);
|
||||
|
||||
this.boardEditors.push({
|
||||
name: name,
|
||||
board: Board.fromSerialized(saved, this.componentRepo.defs),
|
||||
});
|
||||
this.events.send({ tag: "ShowSelectedTab", idx: this.selectedIdx });
|
||||
return this.boardEditors.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
type BoardEditor = {
|
||||
|
||||
@ -64,7 +64,11 @@ export class Renderer {
|
||||
c.strokeRect(x, y, w, h);
|
||||
}
|
||||
|
||||
drawComponentBody(pos: V2, kind: ComponentKind) {
|
||||
private drawComponentBodyInternal(
|
||||
pos: V2,
|
||||
kind: ComponentKind,
|
||||
label: string,
|
||||
) {
|
||||
const { c, offset } = this;
|
||||
const { x, y } = pos.add(offset);
|
||||
const { x: w, y: h } = kind.size;
|
||||
@ -77,14 +81,18 @@ export class Renderer {
|
||||
|
||||
c.fillStyle = `#333333`;
|
||||
c.font = "bold 16px monospace";
|
||||
const textMetrix = c.measureText(kind.label);
|
||||
const textMetrix = c.measureText(label);
|
||||
c.fillText(
|
||||
kind.label,
|
||||
label,
|
||||
x + w / 2 - textMetrix.width / 2,
|
||||
y + 13 + h / 2 - 16 / 2,
|
||||
);
|
||||
}
|
||||
drawComponentBodySelected(pos: V2, kind: ComponentKind) {
|
||||
private drawComponentBodySelectedInternal(
|
||||
pos: V2,
|
||||
kind: ComponentKind,
|
||||
label: string,
|
||||
) {
|
||||
const { c, offset } = this;
|
||||
const { x, y } = pos.add(offset);
|
||||
const { x: w, y: h } = kind.size;
|
||||
@ -97,14 +105,58 @@ export class Renderer {
|
||||
|
||||
c.fillStyle = `#333333`;
|
||||
c.font = "bold 16px monospace";
|
||||
const textMetrix = c.measureText(kind.label);
|
||||
const textMetrix = c.measureText(label);
|
||||
c.fillText(
|
||||
kind.label,
|
||||
label,
|
||||
x + w / 2 - textMetrix.width / 2,
|
||||
y + 13 + h / 2 - 16 / 2,
|
||||
);
|
||||
}
|
||||
|
||||
drawComponentBody(pos: V2, kind: ComponentKind) {
|
||||
this.drawComponentBodyInternal(pos, kind, kind.label);
|
||||
}
|
||||
drawComponentBodySelected(pos: V2, kind: ComponentKind) {
|
||||
this.drawComponentBodySelectedInternal(pos, kind, kind.label);
|
||||
}
|
||||
|
||||
drawInputComponentBody(pos: V2, kind: ComponentKind, active: boolean) {
|
||||
this.drawComponentBodyInternal(
|
||||
pos,
|
||||
kind,
|
||||
`input (${active ? "on" : "off"})`,
|
||||
);
|
||||
}
|
||||
drawInputComponentBodySelected(
|
||||
pos: V2,
|
||||
kind: ComponentKind,
|
||||
active: boolean,
|
||||
) {
|
||||
this.drawComponentBodySelectedInternal(
|
||||
pos,
|
||||
kind,
|
||||
`input (${active ? "on" : "off"})`,
|
||||
);
|
||||
}
|
||||
drawOutputComponentBody(pos: V2, kind: ComponentKind, active: boolean) {
|
||||
this.drawComponentBodyInternal(
|
||||
pos,
|
||||
kind,
|
||||
`output (${active ? "on" : "off"})`,
|
||||
);
|
||||
}
|
||||
drawOutputComponentBodySelected(
|
||||
pos: V2,
|
||||
kind: ComponentKind,
|
||||
active: boolean,
|
||||
) {
|
||||
this.drawComponentBodySelectedInternal(
|
||||
pos,
|
||||
kind,
|
||||
`output (${active ? "on" : "off"})`,
|
||||
);
|
||||
}
|
||||
|
||||
drawComponentInputPin(pos: V2, pinOffset: number) {
|
||||
const { c, offset } = this;
|
||||
const { x, y } = pos.add(offset);
|
||||
@ -153,12 +205,12 @@ export class Renderer {
|
||||
c.stroke();
|
||||
}
|
||||
|
||||
drawWire(begin: V2, end: V2) {
|
||||
drawWire(begin: V2, end: V2, active: boolean) {
|
||||
const { c, offset } = this;
|
||||
const { x: x0, y: y0 } = begin.add(offset);
|
||||
const { x: x1, y: y1 } = end.add(offset);
|
||||
|
||||
c.strokeStyle = `#333333`;
|
||||
c.strokeStyle = active ? `#bb3333` : `#333333`;
|
||||
c.lineWidth = 3;
|
||||
c.beginPath();
|
||||
c.moveTo(x0, y0);
|
||||
|
||||
@ -4,6 +4,14 @@ export class V2 {
|
||||
public y: number,
|
||||
) {}
|
||||
|
||||
static fromSerialized(data: [number, number]): V2 {
|
||||
return new V2(data[0], data[1]);
|
||||
}
|
||||
|
||||
serialize(): [number, number] {
|
||||
return [this.x, this.y];
|
||||
}
|
||||
|
||||
add(other: V2): V2 {
|
||||
return new V2(this.x + other.x, this.y + other.y);
|
||||
}
|
||||
|
||||
@ -5,7 +5,9 @@ export class ViewPos {
|
||||
public offset = v2(0, 0);
|
||||
|
||||
constructor(private events: EventBus) {
|
||||
this.events.subscribe(["MouseDown", "MouseMove"], (ev) => {
|
||||
this.events.subscribe(
|
||||
["MouseDown", "MouseMove", "MouseClick", "MouseDoubleClick"],
|
||||
(ev) => {
|
||||
const absPos = ev.pos;
|
||||
const pos = this.canvasToBoard(absPos);
|
||||
switch (ev.tag) {
|
||||
@ -19,8 +21,15 @@ export class ViewPos {
|
||||
deltaPos: ev.deltaPos,
|
||||
});
|
||||
break;
|
||||
case "MouseClick":
|
||||
this.events.send({ tag: "MouseClickOffset", pos, absPos });
|
||||
break;
|
||||
case "MouseDoubleClick":
|
||||
this.events.send({ tag: "MouseDoubleClickOffset", pos, absPos });
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
canvasToBoard(pos: V2): V2 {
|
||||
|
||||
@ -15,12 +15,13 @@ export type Event =
|
||||
deltaPos: V2;
|
||||
}
|
||||
| { tag: "KeyDown" | "KeyUp"; key: string }
|
||||
| { tag: "SelectTool" | "ShowSelectedTool"; tool: string }
|
||||
| { tag: "SelectTool" | "ShowSelectedTool" | "OpenTabWithTool"; tool: string }
|
||||
| { tag: "CreateTab" }
|
||||
| { tag: "SelectTab" | "ShowSelectedTab"; idx: number }
|
||||
| { tag: "MouseDownOffset"; pos: V2; absPos: V2 }
|
||||
| { tag: "MouseMoveOffset"; pos: V2; deltaPos: V2 }
|
||||
| { tag: "RenderRequest" }
|
||||
| { tag: "MouseClickOffset" | "MouseDoubleClickOffset"; pos: V2; absPos: V2 }
|
||||
| { tag: "RenderRequest" | "SimulateRequest" | "SaveRequest" }
|
||||
| { tag: "SaveComponent" | "CloseComponent" }
|
||||
| { tag: "RenameComponent"; newName: string };
|
||||
|
||||
|
||||
@ -142,7 +142,10 @@ export class ComponentBuilder {
|
||||
}
|
||||
|
||||
class StmtsMutater {
|
||||
constructor(private comp: Component) {}
|
||||
constructor(
|
||||
private comp: Component,
|
||||
private replacedStates: [State, State][],
|
||||
) {}
|
||||
|
||||
[Symbol.iterator](): Iterator<Stmt> {
|
||||
return this.comp.stmts[Symbol.iterator]();
|
||||
@ -155,6 +158,7 @@ class StmtsMutater {
|
||||
}
|
||||
|
||||
replaceState(oldState: State, newState: State) {
|
||||
this.replacedStates.push([oldState, newState]);
|
||||
for (const stmt of this.comp.stmts) {
|
||||
stmt.replaceState(oldState, newState);
|
||||
}
|
||||
@ -177,7 +181,10 @@ class StmtsMutater {
|
||||
}
|
||||
|
||||
export class ComponentOptimizer {
|
||||
constructor(private comp: Component) {}
|
||||
constructor(
|
||||
private comp: Component,
|
||||
private replacedStates: [State, State][],
|
||||
) {}
|
||||
|
||||
optimize() {
|
||||
const score = () => this.comp.stmts.length * 100 + this.comp.states.length;
|
||||
@ -196,7 +203,7 @@ export class ComponentOptimizer {
|
||||
}
|
||||
|
||||
eliminateRedundantState() {
|
||||
const mut = new StmtsMutater(this.comp);
|
||||
const mut = new StmtsMutater(this.comp, this.replacedStates);
|
||||
const immediatelyReadStateStmt = new Map<State, Stmt>();
|
||||
|
||||
for (const [i, stmt] of this.comp.stmts.entries()) {
|
||||
@ -226,7 +233,7 @@ export class ComponentOptimizer {
|
||||
}
|
||||
|
||||
moveSetStateToSource() {
|
||||
const mut = new StmtsMutater(this.comp);
|
||||
const mut = new StmtsMutater(this.comp, this.replacedStates);
|
||||
|
||||
for (const [baseIdx, stmt] of this.comp.stmts.entries()) {
|
||||
const indices = this.indexMap();
|
||||
@ -249,7 +256,7 @@ export class ComponentOptimizer {
|
||||
}
|
||||
|
||||
collapseStates() {
|
||||
const mut = new StmtsMutater(this.comp);
|
||||
const mut = new StmtsMutater(this.comp, this.replacedStates);
|
||||
|
||||
const sourceStates = new MultiMap<Stmt, State>();
|
||||
for (const stmt of this.comp.stmts) {
|
||||
@ -266,7 +273,7 @@ export class ComponentOptimizer {
|
||||
}
|
||||
|
||||
eliminateUnusedStates() {
|
||||
const mut = new StmtsMutater(this.comp);
|
||||
const mut = new StmtsMutater(this.comp, this.replacedStates);
|
||||
|
||||
const usedStates = new Set<State>();
|
||||
for (const stmt of mut) {
|
||||
@ -287,7 +294,7 @@ export class ComponentOptimizer {
|
||||
}
|
||||
|
||||
eliminateRedundantSetState() {
|
||||
const mut = new StmtsMutater(this.comp);
|
||||
const mut = new StmtsMutater(this.comp, this.replacedStates);
|
||||
|
||||
for (let i = this.comp.stmts.length - 1; i > 0; --i) {
|
||||
const [first, second] = this.comp.stmts.slice(i - 1, i + 1);
|
||||
|
||||
49
editor/src/editor/serialize.ts
Normal file
49
editor/src/editor/serialize.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export type Project = {
|
||||
boardEditors: BoardEditor[];
|
||||
currentBoardEditorIdx: number;
|
||||
componentRepo: ComponentRepo;
|
||||
savedBoards: [string, Board][];
|
||||
};
|
||||
|
||||
export type BoardEditor = {
|
||||
name: string;
|
||||
board: Board;
|
||||
};
|
||||
|
||||
export type Board = {
|
||||
components: Component[];
|
||||
joints: Joint[];
|
||||
wires: Wire[];
|
||||
};
|
||||
|
||||
export type Component = {
|
||||
kindKey: string;
|
||||
pos: V2;
|
||||
};
|
||||
|
||||
export type Joint = {
|
||||
pos: V2;
|
||||
};
|
||||
|
||||
export type Wire = {
|
||||
begin: WireConnection;
|
||||
end: WireConnection;
|
||||
};
|
||||
|
||||
export type WireConnection =
|
||||
| { tag: "InputPin"; compIdx: number; i: number }
|
||||
| { tag: "OutputPin"; compIdx: number; i: number }
|
||||
| { tag: "Joint"; jointIdx: number };
|
||||
|
||||
export type ComponentRepo = {
|
||||
defs: [string, ComponentKind][];
|
||||
};
|
||||
|
||||
export type ComponentKind = {
|
||||
size: V2;
|
||||
label: string;
|
||||
inputs: (string | null)[];
|
||||
outputs: (string | null)[];
|
||||
};
|
||||
|
||||
export type V2 = [number, number];
|
||||
@ -5,22 +5,25 @@ export class Sim {
|
||||
private comp: ir.Component,
|
||||
private inputs: boolean[],
|
||||
private outputs: boolean[],
|
||||
private state: Map<ir.State, boolean>,
|
||||
) {}
|
||||
|
||||
simulate() {
|
||||
const { comp, inputs, outputs } = this;
|
||||
|
||||
const stmtIdcs = new Map(comp.stmts.map((stmt, i) => [stmt, i]));
|
||||
const state = new Map(comp.states.map((state) => [state, false]));
|
||||
|
||||
const regs = new Array<boolean>(comp.stmts.length).fill(false);
|
||||
|
||||
const stateDependents = new Map<ir.State, number>();
|
||||
|
||||
const operation = <Ops extends ir.Stmt[]>(
|
||||
action: (...ops: boolean[]) => boolean,
|
||||
...ops: Ops
|
||||
) => action(...ops.map((op) => regs[stmtIdcs.get(op)!]));
|
||||
|
||||
for (const [i, stmt] of comp.stmts.entries()) {
|
||||
for (let i = 0; i < comp.stmts.length; ++i) {
|
||||
const stmt = comp.stmts[i];
|
||||
const k = stmt.kind;
|
||||
switch (k.tag) {
|
||||
case "Null":
|
||||
@ -30,14 +33,29 @@ export class Sim {
|
||||
regs[i] = inputs[k.i];
|
||||
break;
|
||||
case "Output":
|
||||
outputs[k.i] = regs[i];
|
||||
outputs[k.i] = regs[stmtIdcs.get(k.src)!];
|
||||
break;
|
||||
case "GetState":
|
||||
regs[i] = state.get(k.state)!;
|
||||
regs[i] = this.state.get(k.state)! ?? false;
|
||||
stateDependents.set(
|
||||
k.state,
|
||||
Math.min(
|
||||
i,
|
||||
stateDependents.get(k.state) ?? Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "SetState":
|
||||
state.set(k.state, regs[i]);
|
||||
case "SetState": {
|
||||
const prev = this.state.get(k.state) ?? false;
|
||||
const val = regs[stmtIdcs.get(k.src)!];
|
||||
this.state.set(k.state, val);
|
||||
if (val !== prev) {
|
||||
if (stateDependents.has(k.state)) {
|
||||
i = stateDependents.get(k.state)! - 1;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Not":
|
||||
regs[i] = operation((v) => !v, k.op);
|
||||
break;
|
||||
@ -50,6 +68,12 @@ export class Sim {
|
||||
case "Component":
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
// console.log("Sim:", i, stmt.kind.tag, inputs, outputs, this.state);
|
||||
}
|
||||
}
|
||||
|
||||
activatedState(): ir.State[] {
|
||||
return [...this.state].filter(([_s, v]) => v).map(([s, _v]) => s);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@
|
||||
padding-bottom: 5px;
|
||||
|
||||
font-size: 1rem;
|
||||
text-transform: capitalize;
|
||||
font-weight: bold;
|
||||
|
||||
min-width: 200px;
|
||||
@ -61,7 +60,6 @@
|
||||
padding-bottom: 5px;
|
||||
|
||||
font-size: 1rem;
|
||||
text-transform: capitalize;
|
||||
|
||||
max-width: 200px;
|
||||
text-align: left;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user