summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
authorJosh Kingsley <josh@joshkingsley.me>2025-11-24 15:46:22 +0200
committerJosh Kingsley <josh@joshkingsley.me>2025-11-24 15:46:22 +0200
commitd724cc0bf6ff6d351319e6fb00f5184a04e16ac0 (patch)
treecb43253479df5db8f4844e17e68a48ea5a212df4 /apps/web/src/components
parent7c966e105cd9f65853de1aba0ecce63aa56aca0b (diff)
chore: improve dev tasks
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/app/index.css12
-rw-r--r--apps/web/src/components/app/index.ts93
-rw-r--r--apps/web/src/components/grid/cellAtCoord.ts40
-rw-r--r--apps/web/src/components/grid/drawGrid.ts91
-rw-r--r--apps/web/src/components/grid/drawSelection.ts97
-rw-r--r--apps/web/src/components/grid/excursion.ts8
-rw-r--r--apps/web/src/components/grid/index.css49
-rw-r--r--apps/web/src/components/grid/index.ts276
-rw-r--r--apps/web/src/components/grid/renderGrid.ts144
-rw-r--r--apps/web/src/components/grid/selection.ts28
-rw-r--r--apps/web/src/components/icons/index.ts19
-rw-r--r--apps/web/src/components/icons/svgs/minus16.svg3
-rw-r--r--apps/web/src/components/icons/svgs/plus16.svg3
-rw-r--r--apps/web/src/components/index.ts3
-rw-r--r--apps/web/src/components/toolbar/index.css48
-rw-r--r--apps/web/src/components/toolbar/index.ts70
16 files changed, 984 insertions, 0 deletions
diff --git a/apps/web/src/components/app/index.css b/apps/web/src/components/app/index.css
new file mode 100644
index 0000000..aaf2ced
--- /dev/null
+++ b/apps/web/src/components/app/index.css
@@ -0,0 +1,12 @@
+ntv-app {
+ display: block;
+ padding: 1.5rem;
+}
+
+ntv-app > ntv-toolbar {
+ margin-bottom: 1.5rem;
+}
+
+ntv-app > ntv-grid + ntv-grid {
+ margin-top: 1.5rem;
+}
diff --git a/apps/web/src/components/app/index.ts b/apps/web/src/components/app/index.ts
new file mode 100644
index 0000000..a2c0c9d
--- /dev/null
+++ b/apps/web/src/components/app/index.ts
@@ -0,0 +1,93 @@
+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";
+import { GridSelection } from "../grid/selection";
+import ntvToolbar from "../toolbar";
+import "./index.css";
+
+@customElement("ntv-app")
+export class NotiveAppElement extends NotiveElement {
+ doc: Doc = defaultDoc();
+
+ #selectedGridId?: string;
+ #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() {
+ this.querySelectorAll<NotiveGridElement>("ntv-grid").forEach((grid) => {
+ grid.selection =
+ this.#selectedGridId === grid.grid?.id ? this.#selection : undefined;
+ });
+ }
+
+ #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(
+ this.#toolbar,
+ ...this.doc.grids.map((grid) =>
+ ntvGrid({
+ grid: renderGrid(grid),
+ dataset: { gridId: grid.id },
+ ongridselectionchange: (event) => {
+ this.setSelection(grid.id, event.selection);
+ },
+ oncellchange: (event) => {
+ console.log(event);
+ },
+ }),
+ ),
+ );
+ }
+}
+
+export default NotiveAppElement.makeFactory();
diff --git a/apps/web/src/components/grid/cellAtCoord.ts b/apps/web/src/components/grid/cellAtCoord.ts
new file mode 100644
index 0000000..dd594a4
--- /dev/null
+++ b/apps/web/src/components/grid/cellAtCoord.ts
@@ -0,0 +1,40 @@
+import Coord from "../../math/Coord";
+import { CellRef } from "../../types";
+import { RenderedGrid, RenderedRow } from "./renderGrid";
+
+function rowAtCoord(grid: RenderedGrid, coord: Coord): RenderedRow | undefined {
+ if (coord.y <= grid.rect.topLeft.y) {
+ return grid.renderedRows[0];
+ }
+
+ if (coord.y >= grid.rect.bottomRight.y) {
+ return grid.renderedRows.at(-1);
+ }
+
+ return grid.renderedRows.find((row) =>
+ row.rect.verticallyContainsCoord(coord),
+ );
+}
+
+export default function cellAtCoord(
+ grid: RenderedGrid,
+ x: number,
+ y: number,
+): CellRef | undefined {
+ const coord = new Coord(x, y);
+ const row = rowAtCoord(grid, coord);
+
+ if (!row) return;
+
+ if (x <= row.rect.topLeft.x) {
+ return row.renderedCells[0]?.cellRef;
+ }
+
+ if (x >= row.rect.bottomRight.x) {
+ return row.renderedCells.at(-1)?.cellRef;
+ }
+
+ return row.renderedCells.find((cell) =>
+ cell.rect.horizontallyContainsCoord(coord),
+ )?.cellRef;
+}
diff --git a/apps/web/src/components/grid/drawGrid.ts b/apps/web/src/components/grid/drawGrid.ts
new file mode 100644
index 0000000..da83c8e
--- /dev/null
+++ b/apps/web/src/components/grid/drawGrid.ts
@@ -0,0 +1,91 @@
+import { RangeSelection, Selection } from "../../selection";
+import { CellRef } from "../../types";
+import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid";
+
+export interface GridStyles {
+ bgFill: string;
+ borderStroke: string;
+ cellStroke: string;
+ cellValueFont: string;
+ cellValueLineHeight: string;
+}
+
+function excursion(ctx: CanvasRenderingContext2D, f: () => void) {
+ ctx.save();
+ f();
+ ctx.restore();
+}
+
+function fillBackground(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ ctx.clearRect(0, 0, grid.rect.width, grid.rect.height);
+ ctx.fillStyle = styles.bgFill;
+ ctx.fillRect(0, 0, grid.rect.width, grid.rect.height);
+}
+
+function strokeGrid(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ ctx.strokeStyle = styles.borderStroke;
+ ctx.strokeRect(0.5, 0.5, grid.rect.width - 1, grid.rect.height - 1);
+}
+
+function strokeGridLines(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ ctx.strokeStyle = styles.cellStroke;
+
+ grid.renderedRows.forEach((row, renderedRowIndex) => {
+ const isLastRow = renderedRowIndex === grid.renderedRows.length - 1;
+
+ row.renderedCells.forEach((cell, cellIndex) => {
+ const { topLeft, width, height } = cell.rect;
+ const isLastCell = cellIndex === row.renderedCells.length - 1;
+
+ ctx.strokeRect(
+ topLeft.x + 0.5,
+ topLeft.y + 0.5,
+ isLastCell ? width - 1 : width,
+ isLastRow ? height - 1 : height,
+ );
+ });
+ });
+}
+
+function drawCellValues(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ grid.renderedRows.forEach((row) =>
+ row.renderedCells.forEach((cell) => {
+ if (!cell.value) return;
+ ctx.fillStyle = "white";
+ ctx.textAlign = "center";
+ ctx.font = styles.cellValueFont;
+ ctx.fillText(
+ cell.value,
+ cell.rect.center.x,
+ cell.rect.center.y + parseInt(styles.cellValueLineHeight) / 4,
+ );
+ }),
+ );
+}
+
+export default function drawGrid(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ excursion(ctx, () => fillBackground(ctx, styles, grid));
+ excursion(ctx, () => strokeGridLines(ctx, styles, grid));
+ excursion(ctx, () => strokeGrid(ctx, styles, grid));
+ excursion(ctx, () => drawCellValues(ctx, styles, grid));
+}
diff --git a/apps/web/src/components/grid/drawSelection.ts b/apps/web/src/components/grid/drawSelection.ts
new file mode 100644
index 0000000..1b8c2ed
--- /dev/null
+++ b/apps/web/src/components/grid/drawSelection.ts
@@ -0,0 +1,97 @@
+import { CellRef } from "../../types";
+import excursion from "./excursion";
+import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid";
+import { GridSelection } from "./selection";
+
+export interface SelectionStyles {
+ activeCellStroke: string;
+ selectionRangeFill: string;
+ selectionRangeStroke: string;
+}
+
+function strokeActiveCell(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ cell: RenderedCell,
+) {
+ excursion(ctx, () => {
+ const isLastCell = cell.rect.bottomRight.x === grid.rect.bottomRight.x;
+ const isLastRow = cell.rect.bottomRight.y === grid.rect.bottomRight.y;
+
+ ctx.strokeStyle = styles.activeCellStroke;
+ ctx.lineWidth = 2;
+
+ ctx.strokeRect(
+ cell.rect.topLeft.x + 1,
+ cell.rect.topLeft.y + 1,
+ isLastCell ? cell.rect.width - 2 : cell.rect.width - 1,
+ isLastRow ? cell.rect.height - 2 : cell.rect.height - 1,
+ );
+ });
+}
+
+function drawCellRange(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ start: CellRef,
+ end: CellRef,
+ { stroke }: { stroke: boolean },
+) {
+ excursion(ctx, () => {
+ const startCell = getRenderedCell(grid, start);
+ const endCell = getRenderedCell(grid, end);
+
+ if (!startCell || !endCell) return;
+
+ const rect = startCell.rect.extend(endCell.rect);
+
+ const isRightEdge = rect.bottomRight.x === grid.rect.bottomRight.x;
+ const isBottomEdge = rect.bottomRight.y === grid.rect.bottomRight.y;
+
+ ctx.fillStyle = styles.selectionRangeFill;
+
+ ctx.fillRect(
+ rect.topLeft.x + 1,
+ rect.topLeft.y + 1,
+ isRightEdge ? rect.width - 2 : rect.width - 1,
+ isBottomEdge ? rect.height - 2 : rect.height - 1,
+ );
+
+ if (!stroke) return;
+
+ ctx.strokeStyle = styles.selectionRangeStroke;
+
+ ctx.strokeRect(
+ rect.topLeft.x + 0.5,
+ rect.topLeft.y + 0.5,
+ isRightEdge ? rect.width - 1 : rect.width,
+ isBottomEdge ? rect.height - 1 : rect.height,
+ );
+ });
+}
+
+export default function drawSelection(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ selection: GridSelection | undefined,
+ { pending }: { pending: boolean },
+) {
+ ctx.clearRect(0, 0, grid.rect.width, grid.rect.height);
+
+ if (!selection) return;
+
+ const activeCell = getRenderedCell(grid, selection.activeCellRef);
+
+ if (!activeCell) return;
+
+ if (selection.range) {
+ drawCellRange(ctx, styles, grid, selection.range[0], selection.range[1], {
+ stroke: !pending,
+ });
+ }
+
+ strokeActiveCell(ctx, styles, grid, activeCell);
+}
diff --git a/apps/web/src/components/grid/excursion.ts b/apps/web/src/components/grid/excursion.ts
new file mode 100644
index 0000000..7752df1
--- /dev/null
+++ b/apps/web/src/components/grid/excursion.ts
@@ -0,0 +1,8 @@
+export default function excursion(
+ ctx: CanvasRenderingContext2D,
+ f: () => void,
+) {
+ ctx.save();
+ f();
+ ctx.restore();
+}
diff --git a/apps/web/src/components/grid/index.css b/apps/web/src/components/grid/index.css
new file mode 100644
index 0000000..c29f55d
--- /dev/null
+++ b/apps/web/src/components/grid/index.css
@@ -0,0 +1,49 @@
+@layer components {
+ ntv-grid {
+ display: block;
+ position: relative;
+
+ --grid-bg-fill: var(--color-neutral-900);
+ --grid-border-stroke: var(--color-neutral-700);
+ --grid-cell-stroke: var(--color-neutral-800);
+ --grid-active-cell-stroke: var(--color-green-400);
+ --grid-selection-range-fill: color-mix(
+ in oklab,
+ var(--color-green-400) 10%,
+ transparent
+ );
+ --grid-selection-range-stroke: var(--color-green-400);
+ font-size: 14px;
+ }
+
+ ntv-grid > canvas {
+ display: block;
+ }
+
+ ntv-grid > canvas[data-selection] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ }
+
+ :has(ntv-grid:state(selecting))
+ > ntv-grid:not(:state(selecting))
+ > canvas[data-selection] {
+ display: none;
+ }
+
+ ntv-grid input[data-edit] {
+ position: absolute;
+ vertical-align: baseline;
+ background: var(--color-neutral-800);
+ padding-right: 1px;
+ padding-bottom: 1px;
+ color: white;
+ text-align: center;
+ }
+
+ ntv-grid input[data-edit]:focus-visible {
+ outline: none;
+ }
+}
diff --git a/apps/web/src/components/grid/index.ts b/apps/web/src/components/grid/index.ts
new file mode 100644
index 0000000..3189409
--- /dev/null
+++ b/apps/web/src/components/grid/index.ts
@@ -0,0 +1,276 @@
+import NotiveElement, { customElement, eventHandler } from "../../element";
+import h from "../../html";
+import { CellRef } from "../../types";
+import cellAtCoord from "./cellAtCoord";
+import drawGrid, { GridStyles } from "./drawGrid";
+import drawSelection, { SelectionStyles } from "./drawSelection";
+import "./index.css";
+import { getRenderedCell, RenderedGrid } from "./renderGrid";
+import { extendSelection, GridSelection } from "./selection";
+
+@customElement("ntv-grid")
+export class NotiveGridElement extends NotiveElement {
+ #internals: ElementInternals = this.attachInternals();
+
+ #grid?: RenderedGrid;
+
+ get grid(): RenderedGrid | undefined {
+ return this.#grid;
+ }
+
+ set grid(grid: RenderedGrid | undefined) {
+ this.#grid = grid;
+ this.draw();
+ }
+
+ #selection?: GridSelection;
+
+ get selection() {
+ return this.#selection;
+ }
+
+ set selection(selection: GridSelection | undefined) {
+ this.#selection = selection;
+ this.drawSelection();
+ }
+
+ @eventHandler("ntv:grid:selectionchange")
+ ongridselectionchange?: (event: GridSelectionChangeEvent) => any;
+
+ @eventHandler("ntv:grid:cellchange")
+ oncellchange?: (event: GridCellChangeEvent) => any;
+
+ canvas: HTMLCanvasElement = h.canvas({
+ onmousedown: (event) => {
+ if (event.button !== 0) return;
+ if (!this.grid) return;
+ const cellRef = this.#mouseEventCellRef(event);
+ if (!cellRef) return;
+ this.startSelecting(cellRef);
+ },
+ ondblclick: (event) => {
+ if (!this.grid) return;
+ const cellRef = this.#mouseEventCellRef(event);
+ if (!cellRef) return;
+ this.startEditing(cellRef);
+ },
+ });
+
+ selectionCanvas: HTMLCanvasElement = h.canvas({
+ dataset: { selection: "true" },
+ });
+
+ connectedCallback() {
+ this.append(this.canvas, this.selectionCanvas);
+ this.draw();
+ this.drawSelection();
+ }
+
+ draw() {
+ if (!this.grid) return;
+
+ const ctx = this.canvas.getContext("2d");
+
+ if (!ctx) throw new Error("Unable to get canvas context");
+
+ this.canvas.setAttribute("width", this.grid.rect.width + "px");
+ this.canvas.setAttribute("height", this.grid.rect.height + "px");
+
+ drawGrid(ctx, this.getGridStyles(), this.grid);
+ }
+
+ drawSelection() {
+ if (!this.grid) return;
+
+ const ctx = this.selectionCanvas.getContext("2d");
+
+ if (!ctx) throw new Error("Unable to get canvas context");
+
+ this.selectionCanvas.setAttribute("width", this.grid.rect.width + "px");
+ this.selectionCanvas.setAttribute("height", this.grid.rect.height + "px");
+
+ drawSelection(
+ ctx,
+ this.getSelectionStyles(),
+ this.grid,
+ this.#pendingSelection ?? this.selection,
+ {
+ pending: !!this.#pendingSelection,
+ },
+ );
+ }
+
+ getGridStyles(): GridStyles {
+ const style = window.getComputedStyle(this);
+ const val = (k: string) => style.getPropertyValue(k);
+
+ return {
+ bgFill: val("--grid-bg-fill"),
+ borderStroke: val("--grid-border-stroke"),
+ cellStroke: val("--grid-cell-stroke"),
+ cellValueFont: val("font"),
+ cellValueLineHeight: val("line-height"),
+ };
+ }
+
+ getSelectionStyles(): SelectionStyles {
+ const style = window.getComputedStyle(this);
+ const val = (k: string) => style.getPropertyValue(k);
+
+ return {
+ activeCellStroke: val("--grid-active-cell-stroke"),
+ selectionRangeFill: val("--grid-selection-range-fill"),
+ selectionRangeStroke: val("--grid-selection-range-stroke"),
+ };
+ }
+
+ #pendingSelection?: GridSelection;
+ #selectionAbortController?: AbortController;
+
+ startSelecting(cellRef: CellRef) {
+ if (!this.grid || this.#pendingSelection) return;
+
+ this.#internals.states.add("selecting");
+
+ this.#selectionAbortController = new AbortController();
+ const { signal } = this.#selectionAbortController;
+
+ window.addEventListener(
+ "mousemove",
+ (event) => {
+ const cellRef = this.#mouseEventCellRef(event);
+ if (!cellRef) return;
+ this.#pendingSelection = extendSelection(
+ this.#pendingSelection,
+ cellRef,
+ );
+ this.drawSelection();
+ },
+ { signal },
+ );
+
+ window.addEventListener("mouseup", () => this.#finishSelecting(), {
+ signal,
+ });
+
+ window.addEventListener(
+ "keydown",
+ (event) => {
+ event.preventDefault();
+ if (event.key === "Escape") {
+ this.#pendingSelection = undefined;
+ this.#finishSelecting();
+ }
+ },
+ { signal },
+ );
+
+ this.#pendingSelection = extendSelection(undefined, cellRef);
+ this.drawSelection();
+ }
+
+ #finishSelecting() {
+ this.#selectionAbortController?.abort();
+ this.#selectionAbortController = undefined;
+ this.#internals.states.delete("selecting");
+ if (this.#pendingSelection) {
+ this.dispatchEvent(new GridSelectionChangeEvent(this.#pendingSelection));
+ }
+ this.#pendingSelection = undefined;
+ this.drawSelection();
+ }
+
+ #mouseEventCellRef(
+ this: NotiveGridElement,
+ event: MouseEvent,
+ ): CellRef | undefined {
+ if (!this.grid) return;
+ const clientRect = this.canvas.getBoundingClientRect();
+ const x = event.x - clientRect.x;
+ const y = event.y - clientRect.y;
+ return cellAtCoord(this.grid, x, y);
+ }
+
+ #editingCellRef?: CellRef;
+
+ #editInput: HTMLInputElement = h.input({
+ dataset: { edit: "true" },
+ onblur: () => this.#finishEditing(),
+ onkeydown: (event) => {
+ switch (event.key) {
+ case "Enter":
+ this.#finishEditing();
+ break;
+
+ case "Escape":
+ this.#cancelEditing();
+ break;
+ }
+ },
+ });
+
+ startEditing(cellRef: CellRef) {
+ if (!this.grid) return;
+
+ const cell = getRenderedCell(this.grid, cellRef);
+
+ if (!cell) return;
+
+ this.#editingCellRef = cellRef;
+
+ this.append(this.#editInput);
+
+ this.#editInput.value = cell.value || "";
+
+ Object.assign(this.#editInput.style, {
+ left: cell.rect.topLeft.x + 2 + "px",
+ top: cell.rect.topLeft.y + 2 + "px",
+ width: cell.rect.width - 3 + "px",
+ height: cell.rect.height - 3 + "px",
+ });
+
+ this.#editInput.focus();
+ }
+
+ #cancelEditing() {
+ this.#editInput.remove();
+ }
+
+ #finishEditing() {
+ this.#editInput.remove();
+
+ if (!this.grid || !this.#editingCellRef) return;
+
+ this.dispatchEvent(
+ new GridCellChangeEvent(this.#editingCellRef, this.#editInput.value),
+ );
+ }
+}
+
+export default NotiveGridElement.makeFactory();
+
+export class GridSelectionChangeEvent extends Event {
+ static readonly TYPE = "ntv:grid:selectionchange";
+
+ constructor(public selection: GridSelection) {
+ super(GridSelectionChangeEvent.TYPE);
+ }
+}
+
+export class GridCellChangeEvent extends Event {
+ static readonly TYPE = "ntv:grid:cellchange";
+
+ constructor(
+ public cellRef: CellRef,
+ public value: string | undefined,
+ ) {
+ super(GridCellChangeEvent.TYPE);
+ }
+}
+
+declare global {
+ interface HTMLElementEventMap {
+ [GridSelectionChangeEvent.TYPE]: GridSelectionChangeEvent;
+ [GridCellChangeEvent.TYPE]: GridCellChangeEvent;
+ }
+}
diff --git a/apps/web/src/components/grid/renderGrid.ts b/apps/web/src/components/grid/renderGrid.ts
new file mode 100644
index 0000000..89938ec
--- /dev/null
+++ b/apps/web/src/components/grid/renderGrid.ts
@@ -0,0 +1,144 @@
+import Ratio from "../../math/Ratio";
+import Rect from "../../math/Rect";
+import { Cell, CellRef, Grid, Row, RowRef } from "../../types";
+
+export interface RenderedCell extends Cell {
+ cellRef: CellRef;
+ renderedRowIndex: number;
+ rect: Rect;
+ startRatio: Ratio;
+ endRatio: Ratio;
+}
+
+export interface RenderedRow {
+ rowRef: RowRef;
+ rect: Rect;
+ renderedCells: RenderedCell[];
+}
+
+export interface RenderedGrid extends Grid {
+ rect: Rect;
+ renderedRows: RenderedRow[];
+}
+
+function renderCell(
+ grid: Grid,
+ cell: Cell,
+ cellRef: CellRef,
+ renderedRowIndex: number,
+ topLeftX: number,
+ topLeftY: number,
+ startRatio: Ratio,
+): RenderedCell {
+ const width = cell.widthRatio
+ .divideRatio(grid.baseCellWidthRatio)
+ .multiplyRatio(Ratio.fromInteger(grid.baseCellSize))
+ .toNumber();
+
+ const rect = new Rect(topLeftX, topLeftY, width, grid.baseCellSize);
+
+ const endRatio = startRatio.add(cell.widthRatio);
+
+ return { ...cell, cellRef, rect, renderedRowIndex, startRatio, endRatio };
+}
+
+function renderRow(
+ grid: Grid,
+ row: Row,
+ rowRef: RowRef,
+ renderedRowIndex: number,
+ topLeftY: number,
+): RenderedRow {
+ if (row.cells.length === 0) {
+ return {
+ ...row,
+ rowRef,
+ rect: new Rect(0, topLeftY, 0, 0),
+ renderedCells: [],
+ };
+ }
+
+ let topLeftX = 0;
+ let startRatio = Ratio.fromInteger(0);
+
+ const renderedCells = row.cells.map((cell, cellIndex) => {
+ const cellRef = { ...rowRef, cellIndex };
+
+ const renderedCell = renderCell(
+ grid,
+ cell,
+ cellRef,
+ renderedRowIndex,
+ topLeftX,
+ topLeftY,
+ startRatio,
+ );
+
+ topLeftX = renderedCell.rect.bottomRight.x;
+ startRatio = renderedCell.endRatio;
+
+ return renderedCell;
+ });
+
+ const { topLeft } = renderedCells[0].rect;
+ const { bottomRight } = renderedCells.at(-1)!.rect;
+
+ const rect = new Rect(
+ topLeft.x,
+ topLeft.y,
+ bottomRight.x - topLeft.x,
+ bottomRight.y - topLeft.y,
+ );
+
+ return { ...row, renderedCells, rect, rowRef };
+}
+
+function renderRows(grid: Grid): RenderedRow[] {
+ const renderedRows: RenderedRow[] = [];
+
+ let partIndex = 0;
+ let rowIndex = 0;
+ let topLeftY = 0;
+ let renderedRowIndex = 0;
+
+ while (true) {
+ if (!grid.parts[partIndex]?.rows[rowIndex]) break;
+
+ const row = grid.parts[partIndex].rows[rowIndex];
+ const rowRef = { partIndex, rowIndex };
+ const renderedRow = renderRow(
+ grid,
+ row,
+ rowRef,
+ renderedRowIndex,
+ topLeftY,
+ );
+
+ topLeftY = renderedRow.rect.bottomRight.y;
+ renderedRows.push(renderedRow);
+
+ if (!grid.parts[++partIndex]) {
+ partIndex = 0;
+ rowIndex++;
+ }
+
+ renderedRowIndex++;
+ }
+
+ return renderedRows;
+}
+
+export default function renderGrid(grid: Grid) {
+ const renderedRows = renderRows(grid);
+ const rect = renderedRows[0].rect.extend(renderedRows.at(-1)!.rect);
+ return { ...grid, rect, renderedRows };
+}
+
+export function getRenderedCell(
+ grid: RenderedGrid,
+ cellRef: CellRef,
+): RenderedCell | undefined {
+ const renderedRowIndex =
+ cellRef.rowIndex * grid.parts.length + cellRef.partIndex;
+ return grid.renderedRows[renderedRowIndex]?.renderedCells[cellRef.cellIndex];
+}
diff --git a/apps/web/src/components/grid/selection.ts b/apps/web/src/components/grid/selection.ts
new file mode 100644
index 0000000..517f8ae
--- /dev/null
+++ b/apps/web/src/components/grid/selection.ts
@@ -0,0 +1,28 @@
+import { CellRef, cellRefEquals } from "../../types";
+import { RenderedGrid } from "./renderGrid";
+
+export type CellRange = [start: CellRef, end: CellRef];
+
+export interface GridSelection {
+ activeCellRef: CellRef;
+ range?: CellRange;
+}
+
+export function extendSelection(
+ selection: GridSelection | undefined,
+ cellRef: CellRef,
+): GridSelection {
+ if (!selection || cellRefEquals(selection.activeCellRef, cellRef)) {
+ return { activeCellRef: cellRef };
+ }
+
+ if (selection.range) {
+ return { ...selection, range: [selection.range[0], cellRef] };
+ }
+
+ return { ...selection, range: [selection.activeCellRef, cellRef] };
+}
+
+export function getSelectionRange(selection: GridSelection): CellRange {
+ return selection.range ?? [selection.activeCellRef, selection.activeCellRef];
+}
diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts
new file mode 100644
index 0000000..5731026
--- /dev/null
+++ b/apps/web/src/components/icons/index.ts
@@ -0,0 +1,19 @@
+import plus16 from "./svgs/plus16.svg?raw";
+import minus16 from "./svgs/minus16.svg?raw";
+
+function makeIconFactory(source: string) {
+ return (attrs?: object): SVGElement => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(source, "image/svg+xml");
+ const svg = doc.documentElement as unknown as SVGElement;
+
+ if (attrs) {
+ Object.entries(attrs).forEach(([k, v]) => svg.setAttribute(k, v));
+ }
+
+ return svg;
+ };
+}
+
+export const plus16Icon = makeIconFactory(plus16);
+export const minus16Icon = makeIconFactory(minus16);
diff --git a/apps/web/src/components/icons/svgs/minus16.svg b/apps/web/src/components/icons/svgs/minus16.svg
new file mode 100644
index 0000000..d77dcfc
--- /dev/null
+++ b/apps/web/src/components/icons/svgs/minus16.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-4">
+ <path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
+</svg>
diff --git a/apps/web/src/components/icons/svgs/plus16.svg b/apps/web/src/components/icons/svgs/plus16.svg
new file mode 100644
index 0000000..1d7b023
--- /dev/null
+++ b/apps/web/src/components/icons/svgs/plus16.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-4">
+ <path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
+</svg>
diff --git a/apps/web/src/components/index.ts b/apps/web/src/components/index.ts
new file mode 100644
index 0000000..b7f6f55
--- /dev/null
+++ b/apps/web/src/components/index.ts
@@ -0,0 +1,3 @@
+import "./app";
+import "./grid";
+import "./toolbar";
diff --git a/apps/web/src/components/toolbar/index.css b/apps/web/src/components/toolbar/index.css
new file mode 100644
index 0000000..653c326
--- /dev/null
+++ b/apps/web/src/components/toolbar/index.css
@@ -0,0 +1,48 @@
+@layer components {
+ ntv-toolbar {
+ display: flex;
+ border-radius: 99999px;
+ background: var(--color-neutral-900);
+ width: min-content;
+ }
+
+ ntv-toolbar > section {
+ display: flex;
+ gap: 0.25rem;
+ padding: 0.325rem;
+ }
+
+ ntv-toolbar button {
+ border-radius: 99999px;
+ background: var(--color-neutral-800);
+ 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] {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ aspect-ratio: 1;
+ height: 1.25rem;
+ }
+
+ ntv-toolbar input {
+ border: 1px solid var(--color-neutral-700);
+ border-radius: 4px;
+ background: var(--color-neutral-900);
+ width: 2.5rem;
+ height: 1.25rem;
+ color: white;
+ font-size: 0.75rem;
+ text-align: center;
+ }
+}
diff --git a/apps/web/src/components/toolbar/index.ts b/apps/web/src/components/toolbar/index.ts
new file mode 100644
index 0000000..b8a383d
--- /dev/null
+++ b/apps/web/src/components/toolbar/index.ts
@@ -0,0 +1,70 @@
+import NotiveElement, { customElement, eventHandler } from "../../element";
+import h from "../../html";
+import { minus16Icon, plus16Icon } from "../icons";
+import "./index.css";
+
+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,
+ });
+
+ 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: "" },
+ 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: "" },
+ onclick: () => {
+ if (!this.subdivisions) return;
+ this.subdivisions = this.subdivisions + 1;
+ this.dispatchEvent(
+ new SubdivisionsChangeEvent(this.subdivisions),
+ );
+ },
+ },
+ h.span(plus16Icon()),
+ ),
+ ),
+ h.section(h.button("Play")),
+ );
+ }
+}
+
+export default NotiveToolbarElement.makeFactory();