summaryrefslogtreecommitdiff
path: root/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'web/src')
-rw-r--r--web/src/components/app/index.ts47
-rw-r--r--web/src/components/grid/index.ts11
-rw-r--r--web/src/components/grid/selection.ts5
-rw-r--r--web/src/components/toolbar/index.css2
-rw-r--r--web/src/components/toolbar/index.ts62
-rw-r--r--web/src/defaultDoc.ts4
-rw-r--r--web/src/grid.test.ts45
-rw-r--r--web/src/grid.ts109
-rw-r--r--web/src/math/Ratio.test.ts27
-rw-r--r--web/src/math/Ratio.ts28
-rw-r--r--web/src/math/index.ts3
-rw-r--r--web/src/types.ts50
12 files changed, 336 insertions, 57 deletions
diff --git a/web/src/components/app/index.ts b/web/src/components/app/index.ts
index aa7c738..a2c0c9d 100644
--- a/web/src/components/app/index.ts
+++ b/web/src/components/app/index.ts
@@ -1,5 +1,10 @@
+import { produce } from "immer";
import defaultDoc from "../../defaultDoc";
import NotiveElement, { customElement } from "../../element";
+import {
+ changeSelectedSubdivisions,
+ getSelectedSubdivisionsCount,
+} from "../../grid";
import { Doc } from "../../types";
import ntvGrid, { NotiveGridElement } from "../grid";
import renderGrid from "../grid/renderGrid";
@@ -15,9 +20,21 @@ export class NotiveAppElement extends NotiveElement {
#selection?: GridSelection;
setSelection(gridId: string, selection: GridSelection) {
+ const grid = this.doc.grids.find((grid) => grid.id === gridId);
+ if (!grid) throw new Error("Invalid grid ID");
+
this.#selectedGridId = gridId;
this.#selection = selection;
this.#updateGridSelections();
+
+ this.#toolbar.subdivisions = getSelectedSubdivisionsCount(grid, selection);
+ }
+
+ clearSelection() {
+ this.#selectedGridId = undefined;
+ this.#selection = undefined;
+ this.#updateGridSelections();
+ this.#toolbar.subdivisions = undefined;
}
#updateGridSelections() {
@@ -27,10 +44,36 @@ export class NotiveAppElement extends NotiveElement {
});
}
+ #toolbar = ntvToolbar({
+ onsubdivisionschange: ({ subdivisions }) => {
+ if (!subdivisions) return;
+
+ const gridId = this.#selectedGridId;
+ const selection = this.#selection;
+
+ if (!gridId || !selection) return;
+
+ const gridIndex = this.doc.grids.findIndex((grid) => grid.id === gridId);
+
+ this.doc = produce(this.doc, (doc) => {
+ doc.grids[gridIndex] = changeSelectedSubdivisions(
+ this.doc.grids[gridIndex],
+ selection,
+ subdivisions,
+ );
+ });
+
+ this.querySelector<NotiveGridElement>(
+ `ntv-grid[data-grid-id="${gridId}"]`,
+ )!.grid = renderGrid(this.doc.grids[gridIndex]);
+
+ this.clearSelection();
+ },
+ });
+
connectedCallback() {
this.append(
- ntvToolbar(),
-
+ this.#toolbar,
...this.doc.grids.map((grid) =>
ntvGrid({
grid: renderGrid(grid),
diff --git a/web/src/components/grid/index.ts b/web/src/components/grid/index.ts
index 78bb14e..3189409 100644
--- a/web/src/components/grid/index.ts
+++ b/web/src/components/grid/index.ts
@@ -12,7 +12,16 @@ import { extendSelection, GridSelection } from "./selection";
export class NotiveGridElement extends NotiveElement {
#internals: ElementInternals = this.attachInternals();
- grid?: RenderedGrid;
+ #grid?: RenderedGrid;
+
+ get grid(): RenderedGrid | undefined {
+ return this.#grid;
+ }
+
+ set grid(grid: RenderedGrid | undefined) {
+ this.#grid = grid;
+ this.draw();
+ }
#selection?: GridSelection;
diff --git a/web/src/components/grid/selection.ts b/web/src/components/grid/selection.ts
index a24bbf5..517f8ae 100644
--- a/web/src/components/grid/selection.ts
+++ b/web/src/components/grid/selection.ts
@@ -1,4 +1,5 @@
import { CellRef, cellRefEquals } from "../../types";
+import { RenderedGrid } from "./renderGrid";
export type CellRange = [start: CellRef, end: CellRef];
@@ -21,3 +22,7 @@ export function extendSelection(
return { ...selection, range: [selection.activeCellRef, cellRef] };
}
+
+export function getSelectionRange(selection: GridSelection): CellRange {
+ return selection.range ?? [selection.activeCellRef, selection.activeCellRef];
+}
diff --git a/web/src/components/toolbar/index.css b/web/src/components/toolbar/index.css
index e082f7d..653c326 100644
--- a/web/src/components/toolbar/index.css
+++ b/web/src/components/toolbar/index.css
@@ -18,11 +18,13 @@
padding: 0 0.5rem;
height: 1.25rem;
color: white;
+ font-weight: 600;
font-size: 0.75rem;
}
ntv-toolbar button:hover {
background: var(--color-green-400);
+ color: var(--color-neutral-900);
}
ntv-toolbar button[data-icon] {
diff --git a/web/src/components/toolbar/index.ts b/web/src/components/toolbar/index.ts
index da4b69d..b8a383d 100644
--- a/web/src/components/toolbar/index.ts
+++ b/web/src/components/toolbar/index.ts
@@ -1,24 +1,66 @@
-import NotiveElement, { customElement } from "../../element";
-import h, { fragment } from "../../html";
+import NotiveElement, { customElement, eventHandler } from "../../element";
+import h from "../../html";
+import { minus16Icon, plus16Icon } from "../icons";
import "./index.css";
-@customElement("ntv-toolbar")
-class NotiveToolbarElement extends NotiveElement {
- connectedCallback() {
- this.append(this.#view());
+export class SubdivisionsChangeEvent extends Event {
+ static readonly TYPE = "ntv:toolbar:subdivisionschange";
+
+ constructor(public subdivisions: number | undefined) {
+ super(SubdivisionsChangeEvent.TYPE);
}
+}
+@customElement("ntv-toolbar")
+class NotiveToolbarElement extends NotiveElement {
#subdivisionsInputEl: HTMLInputElement = h.input({
title: "Subdivisions",
disabled: true,
});
- #view() {
- return fragment(
+ get subdivisions(): number | undefined {
+ if (this.#subdivisionsInputEl.value === "") return;
+ return parseInt(this.#subdivisionsInputEl.value);
+ }
+
+ set subdivisions(n: number | undefined) {
+ const m = n && Math.max(n, 1);
+ this.#subdivisionsInputEl.value = m === undefined ? "" : m.toString();
+ }
+
+ @eventHandler(SubdivisionsChangeEvent.TYPE)
+ onsubdivisionschange?: (event: SubdivisionsChangeEvent) => any;
+
+ connectedCallback() {
+ this.append(
h.section(
- h.button({ dataset: { icon: "" } }, "-"),
+ h.button(
+ {
+ dataset: { icon: "" },
+ onclick: () => {
+ if (!this.subdivisions) return;
+ this.subdivisions = this.subdivisions - 1;
+ this.dispatchEvent(
+ new SubdivisionsChangeEvent(this.subdivisions),
+ );
+ },
+ },
+ h.span(minus16Icon()),
+ ),
this.#subdivisionsInputEl,
- h.button({ dataset: { icon: "" } }, "+"),
+ h.button(
+ {
+ dataset: { icon: "" },
+ onclick: () => {
+ if (!this.subdivisions) return;
+ this.subdivisions = this.subdivisions + 1;
+ this.dispatchEvent(
+ new SubdivisionsChangeEvent(this.subdivisions),
+ );
+ },
+ },
+ h.span(plus16Icon()),
+ ),
),
h.section(h.button("Play")),
);
diff --git a/web/src/defaultDoc.ts b/web/src/defaultDoc.ts
index 7409c1a..0a3fbfb 100644
--- a/web/src/defaultDoc.ts
+++ b/web/src/defaultDoc.ts
@@ -9,7 +9,7 @@ export default function defaultDoc(): Doc {
return {
grids: [
{
- id: window.crypto.randomUUID(),
+ id: globalThis.crypto.randomUUID(),
baseCellSize: 42,
baseCellWidthRatio: new Ratio(1, 16),
parts: [
@@ -21,7 +21,7 @@ export default function defaultDoc(): Doc {
],
},
{
- id: window.crypto.randomUUID(),
+ id: globalThis.crypto.randomUUID(),
baseCellSize: 42,
baseCellWidthRatio: new Ratio(1, 16),
parts: [
diff --git a/web/src/grid.test.ts b/web/src/grid.test.ts
new file mode 100644
index 0000000..50c0626
--- /dev/null
+++ b/web/src/grid.test.ts
@@ -0,0 +1,45 @@
+import { expect, test } from "vitest";
+import defaultDoc from "./defaultDoc";
+import renderGrid from "./components/grid/renderGrid";
+import { changeSelectedSubdivisions } from "./grid";
+import { GridSelection } from "./components/grid/selection";
+
+test("foo", () => {
+ const doc = defaultDoc();
+ const grid = doc.grids[1];
+
+ const selection: GridSelection = {
+ activeCellRef: { partIndex: 0, rowIndex: 0, cellIndex: 0 },
+ range: [
+ { partIndex: 0, rowIndex: 0, cellIndex: 0 },
+ { partIndex: 0, rowIndex: 0, cellIndex: 3 },
+ ],
+ };
+
+ const newGrid = changeSelectedSubdivisions(grid, selection, 3);
+ const renderedGrid = renderGrid(newGrid);
+
+ expect(
+ renderedGrid.renderedRows.map((row) => row.renderedCells.length),
+ ).toStrictEqual([15, 16, 16, 16]);
+
+ expect(
+ newGrid.parts[0].rows[0].cells.map((cell) => cell.widthRatio.toData()),
+ ).toStrictEqual([
+ [1, 12],
+ [1, 12],
+ [1, 12],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ [1, 16],
+ ]);
+});
diff --git a/web/src/grid.ts b/web/src/grid.ts
new file mode 100644
index 0000000..e849803
--- /dev/null
+++ b/web/src/grid.ts
@@ -0,0 +1,109 @@
+import { produce } from "immer";
+import renderGrid, { getRenderedCell } from "./components/grid/renderGrid";
+import { getSelectionRange, GridSelection } from "./components/grid/selection";
+import Ratio from "./math/Ratio";
+import { Cell, Grid, renderedRowIndexToRef } from "./types";
+
+export function getSelectedSubdivisionsCount(
+ grid: Grid,
+ selection: GridSelection,
+): number | undefined {
+ const renderedGrid = renderGrid(grid);
+
+ const [startCellRef, endCellRef] = getSelectionRange(selection);
+ const startCell = getRenderedCell(renderedGrid, startCellRef);
+ const endCell = getRenderedCell(renderedGrid, endCellRef);
+
+ if (!startCell || !endCell) throw new Error("Invalid cell refs");
+
+ const startRenderedRowIndex = Math.min(
+ startCell.renderedRowIndex,
+ endCell.renderedRowIndex,
+ );
+
+ const endRenderedRowIndex = Math.max(
+ startCell.renderedRowIndex,
+ endCell.renderedRowIndex,
+ );
+
+ const startRatio = Ratio.min(startCell.startRatio, endCell.startRatio);
+ const endRatio = Ratio.max(startCell.endRatio, endCell.endRatio);
+
+ return Math.min(
+ ...renderedGrid.renderedRows
+ .slice(startRenderedRowIndex, endRenderedRowIndex + 1)
+ .map((row) => {
+ const startCellIndex = row.renderedCells.findIndex((cell) =>
+ cell.startRatio.equals(startRatio),
+ );
+
+ const endCellIndex = row.renderedCells.findLastIndex((cell) =>
+ cell.endRatio.equals(endRatio),
+ );
+
+ return endCellIndex - startCellIndex + 1;
+ }),
+ );
+}
+
+export function changeSelectedSubdivisions(
+ grid: Grid,
+ selection: GridSelection,
+ subdivisions: number,
+): Grid {
+ const renderedGrid = renderGrid(grid);
+ const [startCellRef, endCellRef] = getSelectionRange(selection);
+ const startCell = getRenderedCell(renderedGrid, startCellRef);
+ const endCell = getRenderedCell(renderedGrid, endCellRef);
+ if (!startCell || !endCell) throw new Error("Invalid cell refs");
+
+ const startRenderedRowIndex = Math.min(
+ startCell.renderedRowIndex,
+ endCell.renderedRowIndex,
+ );
+
+ const endRenderedRowIndex = Math.max(
+ startCell.renderedRowIndex,
+ endCell.renderedRowIndex,
+ );
+
+ const startRatio = Ratio.min(startCell.startRatio, endCell.startRatio);
+ const endRatio = Ratio.max(startCell.endRatio, endCell.endRatio);
+ const selectedWidthRatio = endRatio.subtract(startRatio);
+ const widthRatio = selectedWidthRatio.divideRatio(
+ Ratio.fromInteger(subdivisions),
+ );
+
+ return produce(grid, (draft) => {
+ for (
+ let renderedRowIndex = startRenderedRowIndex;
+ renderedRowIndex <= endRenderedRowIndex;
+ renderedRowIndex++
+ ) {
+ const renderedRow = renderedGrid.renderedRows[renderedRowIndex];
+
+ const startCellIndex = renderedRow.renderedCells.findIndex((cell) =>
+ cell.startRatio.equals(startRatio),
+ );
+
+ const endCellIndex = renderedRow.renderedCells.findLastIndex((cell) =>
+ cell.endRatio.equals(endRatio),
+ );
+
+ const { partIndex, rowIndex } = renderedRowIndexToRef(
+ grid,
+ renderedRowIndex,
+ );
+
+ const row = draft.parts[partIndex].rows[rowIndex];
+ const previousCells = row.cells.slice(0, startCellIndex);
+ const nextCells = row.cells.slice(endCellIndex + 1);
+
+ const newCells: Cell[] = Array.from({ length: subdivisions }, () => ({
+ widthRatio,
+ }));
+
+ row.cells = [...previousCells, ...newCells, ...nextCells];
+ }
+ });
+}
diff --git a/web/src/math/Ratio.test.ts b/web/src/math/Ratio.test.ts
new file mode 100644
index 0000000..da6fef2
--- /dev/null
+++ b/web/src/math/Ratio.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "vitest";
+import Ratio from "./Ratio";
+
+describe(Ratio, () => {
+ describe(Ratio.prototype.add, () => {
+ test("returns fractions in simplest form", () => {
+ const a = Ratio.fromInteger(0);
+ const b = new Ratio(1, 4);
+
+ const c = a.add(b);
+ expect(c.numerator).toBe(1);
+ expect(c.denominator).toBe(4);
+
+ const d = c.add(b);
+ expect(d.numerator).toBe(1);
+ expect(d.denominator).toBe(2);
+
+ const e = d.add(b);
+ expect(e.numerator).toBe(3);
+ expect(e.denominator).toBe(4);
+
+ const f = e.add(b);
+ expect(f.numerator).toBe(1);
+ expect(f.denominator).toBe(1);
+ });
+ });
+});
diff --git a/web/src/math/Ratio.ts b/web/src/math/Ratio.ts
index 0cca966..e2a1fbf 100644
--- a/web/src/math/Ratio.ts
+++ b/web/src/math/Ratio.ts
@@ -1,3 +1,5 @@
+import { gcd } from ".";
+
/** Serializable representation of a ratio. */
export type RatioData = [numerator: number, denominator: number];
@@ -25,8 +27,10 @@ export default class Ratio {
throw new RangeError("Ratio demnominator cannot be zero");
}
- this.#numerator = numerator;
- this.#denominator = denominator;
+ const divisor = gcd(numerator, denominator);
+
+ this.#numerator = numerator / divisor;
+ this.#denominator = denominator / divisor;
}
multiplyRatio(other: Ratio): Ratio {
@@ -63,10 +67,22 @@ export default class Ratio {
return left < right ? -1 : left > right ? 1 : 0;
}
+ equals(other: Ratio): boolean {
+ return this.compare(other) === 0;
+ }
+
toNumber(): number {
return this.numerator / this.denominator;
}
+ toString(): string {
+ return `${this.numerator}/${this.denominator}`;
+ }
+
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
+ return `Ratio { ${this.numerator}/${this.denominator} }`;
+ }
+
static fromInteger(n: number): Ratio {
return new Ratio(n, 1);
}
@@ -78,4 +94,12 @@ export default class Ratio {
static fromData(ratio: RatioData): Ratio {
return new Ratio(ratio[0], ratio[1]);
}
+
+ static min(...ratios: Ratio[]): Ratio {
+ return ratios.reduce((a, b) => (a.compare(b) <= 0 ? a : b));
+ }
+
+ static max(...ratios: Ratio[]): Ratio {
+ return ratios.reduce((a, b) => (a.compare(b) >= 0 ? a : b));
+ }
}
diff --git a/web/src/math/index.ts b/web/src/math/index.ts
new file mode 100644
index 0000000..70dbb67
--- /dev/null
+++ b/web/src/math/index.ts
@@ -0,0 +1,3 @@
+export function gcd(a: number, b: number): number {
+ return b === 0 ? Math.abs(a) : gcd(b, a % b);
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index b41bb9a..dc26c89 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1,9 +1,10 @@
+import { Immutable } from "immer";
import Ratio from "./math/Ratio";
-export interface Cell {
+export type Cell = Immutable<{
value?: string;
widthRatio: Ratio;
-}
+}>;
export interface Row {
cells: Cell[];
@@ -44,42 +45,11 @@ export function cellRefEquals(a: CellRef, b: CellRef): boolean {
);
}
-export function mapRowsInRange(
- doc: Doc,
- gridId: string,
- startRef: CellRef,
- endRef: CellRef,
- mapFn: (row: Row, ref: RowRef) => Row,
-): Doc {
- const firstPartIndex = Math.min(startRef.partIndex, endRef.partIndex);
- const lastPartIndex = Math.max(startRef.partIndex, endRef.partIndex);
- const firstRowIndex = Math.min(startRef.rowIndex, endRef.rowIndex);
- const lastRowIndex = Math.max(startRef.rowIndex, endRef.rowIndex);
-
- return {
- ...doc,
- grids: doc.grids.map((grid) => {
- if (grid.id !== gridId) return grid;
-
- return {
- ...grid,
- parts: grid.parts.map((part, partIndex) => {
- if (partIndex < firstPartIndex || partIndex > lastPartIndex) {
- return part;
- }
-
- return {
- ...part,
- rows: part.rows.map((row, rowIndex) => {
- if (rowIndex < firstRowIndex || rowIndex > lastRowIndex) {
- return row;
- }
-
- return mapFn(row, { partIndex, rowIndex });
- }),
- };
- }),
- };
- }),
- };
+export function renderedRowIndexToRef(
+ grid: Grid,
+ renderedRowIndex: number,
+): RowRef {
+ const partIndex = renderedRowIndex % grid.parts.length;
+ const rowIndex = Math.floor(renderedRowIndex / grid.parts.length);
+ return { partIndex, rowIndex };
}