summaryrefslogtreecommitdiff
path: root/packages/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web/src')
-rw-r--r--packages/web/src/components/app/index.css12
-rw-r--r--packages/web/src/components/app/index.ts93
-rw-r--r--packages/web/src/components/grid/cellAtCoord.ts40
-rw-r--r--packages/web/src/components/grid/drawGrid.ts91
-rw-r--r--packages/web/src/components/grid/drawSelection.ts97
-rw-r--r--packages/web/src/components/grid/excursion.ts8
-rw-r--r--packages/web/src/components/grid/index.css49
-rw-r--r--packages/web/src/components/grid/index.ts276
-rw-r--r--packages/web/src/components/grid/renderGrid.ts144
-rw-r--r--packages/web/src/components/grid/selection.ts28
-rw-r--r--packages/web/src/components/icons/index.ts19
-rw-r--r--packages/web/src/components/icons/svgs/minus16.svg3
-rw-r--r--packages/web/src/components/icons/svgs/plus16.svg3
-rw-r--r--packages/web/src/components/index.ts3
-rw-r--r--packages/web/src/components/toolbar/index.css48
-rw-r--r--packages/web/src/components/toolbar/index.ts70
-rw-r--r--packages/web/src/defaultDoc.ts42
-rw-r--r--packages/web/src/doc/index.test.ts16
-rw-r--r--packages/web/src/doc/index.ts125
-rw-r--r--packages/web/src/element.ts45
-rw-r--r--packages/web/src/favicon.icobin0 -> 15406 bytes
-rw-r--r--packages/web/src/grid.test.ts45
-rw-r--r--packages/web/src/grid.ts109
-rw-r--r--packages/web/src/html.ts50
-rw-r--r--packages/web/src/index.css10
-rw-r--r--packages/web/src/index.html11
-rw-r--r--packages/web/src/index.ts8
-rw-r--r--packages/web/src/math/Coord.ts23
-rw-r--r--packages/web/src/math/Ratio.test.ts27
-rw-r--r--packages/web/src/math/Ratio.ts105
-rw-r--r--packages/web/src/math/Rect.ts104
-rw-r--r--packages/web/src/math/index.ts3
-rw-r--r--packages/web/src/types.ts55
33 files changed, 1762 insertions, 0 deletions
diff --git a/packages/web/src/components/app/index.css b/packages/web/src/components/app/index.css
new file mode 100644
index 0000000..aaf2ced
--- /dev/null
+++ b/packages/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/packages/web/src/components/app/index.ts b/packages/web/src/components/app/index.ts
new file mode 100644
index 0000000..a2c0c9d
--- /dev/null
+++ b/packages/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/packages/web/src/components/grid/cellAtCoord.ts b/packages/web/src/components/grid/cellAtCoord.ts
new file mode 100644
index 0000000..dd594a4
--- /dev/null
+++ b/packages/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/packages/web/src/components/grid/drawGrid.ts b/packages/web/src/components/grid/drawGrid.ts
new file mode 100644
index 0000000..da83c8e
--- /dev/null
+++ b/packages/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/packages/web/src/components/grid/drawSelection.ts b/packages/web/src/components/grid/drawSelection.ts
new file mode 100644
index 0000000..1b8c2ed
--- /dev/null
+++ b/packages/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/packages/web/src/components/grid/excursion.ts b/packages/web/src/components/grid/excursion.ts
new file mode 100644
index 0000000..7752df1
--- /dev/null
+++ b/packages/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/packages/web/src/components/grid/index.css b/packages/web/src/components/grid/index.css
new file mode 100644
index 0000000..c29f55d
--- /dev/null
+++ b/packages/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/packages/web/src/components/grid/index.ts b/packages/web/src/components/grid/index.ts
new file mode 100644
index 0000000..3189409
--- /dev/null
+++ b/packages/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/packages/web/src/components/grid/renderGrid.ts b/packages/web/src/components/grid/renderGrid.ts
new file mode 100644
index 0000000..89938ec
--- /dev/null
+++ b/packages/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/packages/web/src/components/grid/selection.ts b/packages/web/src/components/grid/selection.ts
new file mode 100644
index 0000000..517f8ae
--- /dev/null
+++ b/packages/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/packages/web/src/components/icons/index.ts b/packages/web/src/components/icons/index.ts
new file mode 100644
index 0000000..5731026
--- /dev/null
+++ b/packages/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/packages/web/src/components/icons/svgs/minus16.svg b/packages/web/src/components/icons/svgs/minus16.svg
new file mode 100644
index 0000000..d77dcfc
--- /dev/null
+++ b/packages/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/packages/web/src/components/icons/svgs/plus16.svg b/packages/web/src/components/icons/svgs/plus16.svg
new file mode 100644
index 0000000..1d7b023
--- /dev/null
+++ b/packages/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/packages/web/src/components/index.ts b/packages/web/src/components/index.ts
new file mode 100644
index 0000000..b7f6f55
--- /dev/null
+++ b/packages/web/src/components/index.ts
@@ -0,0 +1,3 @@
+import "./app";
+import "./grid";
+import "./toolbar";
diff --git a/packages/web/src/components/toolbar/index.css b/packages/web/src/components/toolbar/index.css
new file mode 100644
index 0000000..653c326
--- /dev/null
+++ b/packages/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/packages/web/src/components/toolbar/index.ts b/packages/web/src/components/toolbar/index.ts
new file mode 100644
index 0000000..b8a383d
--- /dev/null
+++ b/packages/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();
diff --git a/packages/web/src/defaultDoc.ts b/packages/web/src/defaultDoc.ts
new file mode 100644
index 0000000..0a3fbfb
--- /dev/null
+++ b/packages/web/src/defaultDoc.ts
@@ -0,0 +1,42 @@
+import Ratio from "./math/Ratio";
+import { Cell, Doc } from "./types";
+
+export default function defaultDoc(): Doc {
+ const defaultCells: Cell[] = Array.from({ length: 16 }, () => ({
+ widthRatio: new Ratio(1, 16),
+ }));
+
+ return {
+ grids: [
+ {
+ id: globalThis.crypto.randomUUID(),
+ baseCellSize: 42,
+ baseCellWidthRatio: new Ratio(1, 16),
+ parts: [
+ {
+ rows: Array.from({ length: 4 }, () => ({
+ cells: [...defaultCells],
+ })),
+ },
+ ],
+ },
+ {
+ id: globalThis.crypto.randomUUID(),
+ baseCellSize: 42,
+ baseCellWidthRatio: new Ratio(1, 16),
+ parts: [
+ {
+ rows: Array.from({ length: 2 }, () => ({
+ cells: [...defaultCells],
+ })),
+ },
+ {
+ rows: Array.from({ length: 2 }, () => ({
+ cells: [...defaultCells],
+ })),
+ },
+ ],
+ },
+ ],
+ };
+}
diff --git a/packages/web/src/doc/index.test.ts b/packages/web/src/doc/index.test.ts
new file mode 100644
index 0000000..5f61398
--- /dev/null
+++ b/packages/web/src/doc/index.test.ts
@@ -0,0 +1,16 @@
+import { expect, test } from "vitest";
+import { apply, defaultDoc, realizeGrids, subdivide } from ".";
+
+test(realizeGrids, () => {
+ const doc = defaultDoc();
+ const grids = realizeGrids(doc);
+
+ expect(grids.length).toBe(1);
+ expect(grids[0].rows.length).toBe(4);
+ expect(grids[0].rows[0].cells.length).toBe(16);
+
+ const doc2 = apply(doc, subdivide(grids[0].id, 0, 0, 3, 3));
+ const grids2 = realizeGrids(doc2);
+
+ expect(grids2[0].rows[0].cells.length).toBe(15);
+});
diff --git a/packages/web/src/doc/index.ts b/packages/web/src/doc/index.ts
new file mode 100644
index 0000000..ae221f0
--- /dev/null
+++ b/packages/web/src/doc/index.ts
@@ -0,0 +1,125 @@
+import { Immutable, produce } from "immer";
+
+export type Doc = Immutable<{ ops: Op[] }>;
+
+export type Op = CreateGrid | Subdivide;
+
+export type CreateGrid = Immutable<{
+ type: "createGrid";
+ gridId: string;
+ rows: number;
+ baseCellsPerRow: number;
+}>;
+
+export function createGrid(): CreateGrid {
+ return {
+ type: "createGrid",
+ gridId: crypto.randomUUID(),
+ rows: 4,
+ baseCellsPerRow: 16,
+ };
+}
+
+export type Subdivide = Immutable<{
+ type: "subdivide";
+ gridId: string;
+ rowIndex: number;
+ startCellIndex: number;
+ endCellIndex: number;
+ subdivisions: number;
+}>;
+
+export function subdivide(
+ gridId: string,
+ rowIndex: number,
+ startCellIndex: number,
+ endCellIndex: number,
+ subdivisions: number,
+): Subdivide {
+ return {
+ type: "subdivide",
+ gridId,
+ rowIndex,
+ startCellIndex,
+ endCellIndex,
+ subdivisions,
+ };
+}
+
+export function defaultDoc(): Doc {
+ const ops = [createGrid()];
+ return { ops };
+}
+
+export function apply(doc: Doc, ...ops: Op[]): Doc {
+ return produce(doc, (doc) => {
+ doc.ops.push(...ops);
+ });
+}
+
+export type DocIndex = Immutable<{
+ opsByType: Map<Op["type"], Op[]>;
+}>;
+
+export function indexDoc(doc: Doc): DocIndex {
+ const opsByType = new Map();
+
+ for (const op of doc.ops) {
+ opsByType.set(op.type, [...(opsByType.get(op.type) ?? []), op]);
+ }
+
+ return { opsByType };
+}
+
+export function getOpsByType<T extends Op["type"]>(
+ index: DocIndex,
+ type: T,
+): Extract<Op, { type: T }>[] {
+ return (index.opsByType.get(type) ?? []) as Extract<Op, { type: T }>[];
+}
+
+export type Grid = Immutable<{ id: string; rows: Row[] }>;
+
+export type Row = Immutable<{ index: number; cells: Cell[] }>;
+
+export type Cell = Immutable<{}>;
+
+export function realizeGrids(doc: Doc): Grid[] {
+ const index = indexDoc(doc);
+ const createGridOps = getOpsByType(index, "createGrid");
+ return createGridOps.map((op) => realizeGrid(doc, index, op));
+}
+
+function realizeGrid(doc: Doc, index: DocIndex, createOp: CreateGrid): Grid {
+ const rows = [];
+
+ for (let rowIndex = 0; rowIndex < createOp.rows; rowIndex++) {
+ let cells: Cell[] = [];
+
+ for (let cellIndex = 0; cellIndex < createOp.baseCellsPerRow; cellIndex++) {
+ cells.push({ index: cellIndex });
+ }
+
+ const subdivideOps = doc.ops.filter(
+ (op) =>
+ op.type === "subdivide" &&
+ op.gridId === createOp.gridId &&
+ op.rowIndex === rowIndex,
+ ) as Subdivide[];
+
+ subdivideOps.forEach((op) => {
+ cells = [
+ ...cells.slice(0, op.startCellIndex),
+ ...Array.from({ length: op.subdivisions }, () => ({})),
+ ...cells.slice(op.endCellIndex + 1),
+ ];
+ });
+
+ rows.push({ index: rowIndex, cells });
+ }
+
+ return {
+ id: createOp.gridId,
+ rows,
+ };
+}
diff --git a/packages/web/src/element.ts b/packages/web/src/element.ts
new file mode 100644
index 0000000..6299d2f
--- /dev/null
+++ b/packages/web/src/element.ts
@@ -0,0 +1,45 @@
+import { createElement, type CreateElement } from "./html";
+
+export default class NotiveElement extends HTMLElement {
+ static makeFactory<T extends NotiveElement>(this: {
+ new (): T;
+ }): CreateElement<T>;
+
+ static makeFactory(): any {
+ throw new Error(
+ "Missing makeFactory implementation. Did you forget to use @customElement?",
+ );
+ }
+}
+
+export function customElement(tagName: string) {
+ return function (_value: unknown, context: ClassDecoratorContext) {
+ context.addInitializer(function () {
+ window.customElements.define(tagName, this as typeof NotiveElement);
+ (this as typeof NotiveElement).makeFactory = () =>
+ ((...args: any[]) => createElement(tagName, ...args)) as CreateElement<any>;
+ });
+ };
+}
+
+export function eventHandler(eventName: string) {
+ return function (_value: unknown, context: ClassFieldDecoratorContext) {
+ const privateKey = Symbol(context.name.toString());
+
+ context.addInitializer(function () {
+ Object.defineProperty(this, context.name, {
+ get() {
+ return this[privateKey];
+ },
+ set(handler) {
+ const oldHandler = this[privateKey];
+ if (oldHandler) this.removeEventListener(eventName, oldHandler);
+ this[privateKey] = handler;
+ if (handler) this.addEventListener(eventName, handler);
+ },
+ enumerable: true,
+ configurable: true,
+ });
+ });
+ };
+}
diff --git a/packages/web/src/favicon.ico b/packages/web/src/favicon.ico
new file mode 100644
index 0000000..c10cfe9
--- /dev/null
+++ b/packages/web/src/favicon.ico
Binary files differ
diff --git a/packages/web/src/grid.test.ts b/packages/web/src/grid.test.ts
new file mode 100644
index 0000000..50c0626
--- /dev/null
+++ b/packages/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/packages/web/src/grid.ts b/packages/web/src/grid.ts
new file mode 100644
index 0000000..e849803
--- /dev/null
+++ b/packages/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/packages/web/src/html.ts b/packages/web/src/html.ts
new file mode 100644
index 0000000..3fccda3
--- /dev/null
+++ b/packages/web/src/html.ts
@@ -0,0 +1,50 @@
+export function createElement<T extends HTMLElement>(
+ tagName: string,
+ ...children: (Node | string)[]
+): T;
+
+export function createElement<T extends HTMLElement>(
+ tagName: string,
+ attrs: Partial<T>,
+ ...children: (Node | string)[]
+): T;
+
+export function createElement(tagName: string, ...args: any[]) {
+ const el = document.createElement(tagName);
+
+ if (args[0]?.constructor === Object) {
+ const { dataset, style, ...attrs } = args.shift();
+ Object.assign(el, attrs);
+ if (dataset) Object.assign(el.dataset, dataset);
+ if (style) Object.assign(el.style, style);
+ }
+
+ el.append(...args.flat());
+
+ return el;
+}
+
+export type CreateElement<T extends HTMLElement> = {
+ (...children: (Node | string)[]): T;
+ (attrs: Partial<T>, ...children: (Node | string)[]): T;
+};
+
+type ElementCreator = {
+ [K in keyof HTMLElementTagNameMap]: CreateElement<HTMLElementTagNameMap[K]>;
+};
+
+const h = new Proxy({} as ElementCreator, {
+ get:
+ (_, tagName: string) =>
+ (...args: any[]) => {
+ return createElement(tagName, ...args);
+ },
+});
+
+export default h;
+
+export function fragment(...children: (Node | string)[]): DocumentFragment {
+ const fragment = document.createDocumentFragment();
+ fragment.append(...children);
+ return fragment;
+}
diff --git a/packages/web/src/index.css b/packages/web/src/index.css
new file mode 100644
index 0000000..f100378
--- /dev/null
+++ b/packages/web/src/index.css
@@ -0,0 +1,10 @@
+@import "tailwindcss";
+
+body {
+ background: var(--color-neutral-800);
+ user-select: none;
+}
+
+*:focus-visible {
+ outline: 2px solid var(--color-green-400);
+}
diff --git a/packages/web/src/index.html b/packages/web/src/index.html
new file mode 100644
index 0000000..9f8bcbf
--- /dev/null
+++ b/packages/web/src/index.html
@@ -0,0 +1,11 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>Notive</title>
+ <link rel="stylesheet" href="index.css" />
+ <link rel="icon" href="favicon.ico" />
+ <script type="module" src="index.ts"></script>
+ </head>
+ <body></body>
+</html>
diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts
new file mode 100644
index 0000000..857e76a
--- /dev/null
+++ b/packages/web/src/index.ts
@@ -0,0 +1,8 @@
+import ntvApp from "./components/app";
+import { State } from "@notive/doc";
+
+const state = new State();
+state.create_grid();
+console.log(state.to_json());
+
+document.body.append(ntvApp());
diff --git a/packages/web/src/math/Coord.ts b/packages/web/src/math/Coord.ts
new file mode 100644
index 0000000..db7ee6d
--- /dev/null
+++ b/packages/web/src/math/Coord.ts
@@ -0,0 +1,23 @@
+/** A coord on a grid whose origin is in the top left. */
+export default class Coord {
+ private readonly _x: number;
+ private readonly _y: number;
+
+ constructor(x: number, y: number) {
+ this._x = x;
+ this._y = y;
+ }
+
+ get x(): number {
+ return this._x;
+ }
+
+ get y(): number {
+ return this._y;
+ }
+
+ /** Get the squared distance of this point from the origin. */
+ squaredDistanceFromOrigin(): number {
+ return this._x * this._x + this._y * this._y;
+ }
+}
diff --git a/packages/web/src/math/Ratio.test.ts b/packages/web/src/math/Ratio.test.ts
new file mode 100644
index 0000000..da6fef2
--- /dev/null
+++ b/packages/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/packages/web/src/math/Ratio.ts b/packages/web/src/math/Ratio.ts
new file mode 100644
index 0000000..e2a1fbf
--- /dev/null
+++ b/packages/web/src/math/Ratio.ts
@@ -0,0 +1,105 @@
+import { gcd } from ".";
+
+/** Serializable representation of a ratio. */
+export type RatioData = [numerator: number, denominator: number];
+
+/** Representation of a ratio for performing fractional artithmetic. */
+export default class Ratio {
+ readonly #numerator: number;
+ readonly #denominator: number;
+
+ get numerator(): number {
+ return this.#numerator;
+ }
+
+ get denominator(): number {
+ return this.#denominator;
+ }
+
+ constructor(numerator: number, denominator: number) {
+ if (!Number.isInteger(numerator) || !Number.isInteger(denominator)) {
+ throw new TypeError(
+ `Ratio must have integer parts: ${numerator} / ${denominator}`,
+ );
+ }
+
+ if (denominator === 0) {
+ throw new RangeError("Ratio demnominator cannot be zero");
+ }
+
+ const divisor = gcd(numerator, denominator);
+
+ this.#numerator = numerator / divisor;
+ this.#denominator = denominator / divisor;
+ }
+
+ multiplyRatio(other: Ratio): Ratio {
+ return new Ratio(
+ this.numerator * other.numerator,
+ this.denominator * other.denominator,
+ );
+ }
+
+ divideRatio(other: Ratio): Ratio {
+ return new Ratio(
+ this.numerator * other.denominator,
+ this.denominator * other.numerator,
+ );
+ }
+
+ add(other: Ratio): Ratio {
+ return new Ratio(
+ this.numerator * other.denominator + other.numerator * this.denominator,
+ this.denominator * other.denominator,
+ );
+ }
+
+ subtract(other: Ratio): Ratio {
+ return new Ratio(
+ this.numerator * other.denominator - other.numerator * this.denominator,
+ this.denominator * other.denominator,
+ );
+ }
+
+ compare(other: Ratio): number {
+ const left = this.numerator * other.denominator;
+ const right = other.numerator * this.denominator;
+ 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);
+ }
+
+ toData(): RatioData {
+ return [this.numerator, this.denominator];
+ }
+
+ 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/packages/web/src/math/Rect.ts b/packages/web/src/math/Rect.ts
new file mode 100644
index 0000000..f52a2f7
--- /dev/null
+++ b/packages/web/src/math/Rect.ts
@@ -0,0 +1,104 @@
+import Coord from "./Coord";
+
+/** A rectangle on a grid whose origin is in the top left. */
+export default class Rect {
+ private readonly _topLeft: Coord;
+ private readonly _width: number;
+ private readonly _height: number;
+
+ constructor(
+ topLeftX: number,
+ topLeftY: number,
+ width: number,
+ height: number,
+ ) {
+ this._topLeft = new Coord(topLeftX, topLeftY);
+ this._width = width;
+ this._height = height;
+ }
+
+ /** Width of this rectangle. */
+ get width(): number {
+ return this._width;
+ }
+
+ /** Height of this rectangle. */
+ get height(): number {
+ return this._height;
+ }
+
+ /** Coord of the top-left point of this rectangle. */
+ get topLeft(): Coord {
+ return this._topLeft;
+ }
+
+ /** Coord of the bottom-right point of this rectangle. */
+ get bottomRight(): Coord {
+ return new Coord(
+ this._topLeft.x + this._width,
+ this._topLeft.y + this._height,
+ );
+ }
+
+ get center(): Coord {
+ return new Coord(
+ this.topLeft.x + (this.bottomRight.x - this.topLeft.x) / 2,
+ this.topLeft.y + (this.bottomRight.y - this.topLeft.y) / 2,
+ );
+ }
+
+ /** Determine if this rectangle contains the point at `coord`. */
+ containsCoord(coord: Coord): boolean {
+ return (
+ this.topLeft.x <= coord.x &&
+ coord.x <= this.bottomRight.x &&
+ this.topLeft.y <= coord.y &&
+ coord.y <= this.bottomRight.y
+ );
+ }
+
+ verticallyContainsCoord(coord: Coord): boolean {
+ return this.topLeft.y <= coord.y && coord.y <= this.bottomRight.y;
+ }
+
+ horizontallyContainsCoord(coord: Coord): boolean {
+ return this.topLeft.x <= coord.x && coord.x <= this.bottomRight.x;
+ }
+
+ extend(other: Rect): Rect {
+ const topLeftX = Math.min(
+ this.topLeft.x,
+ this.bottomRight.x,
+ other.topLeft.x,
+ other.bottomRight.x,
+ );
+
+ const topLeftY = Math.min(
+ this.topLeft.y,
+ this.bottomRight.y,
+ other.topLeft.y,
+ other.bottomRight.y,
+ );
+
+ const bottomRightX = Math.max(
+ this.topLeft.x,
+ this.bottomRight.x,
+ other.topLeft.x,
+ other.bottomRight.x,
+ );
+
+ const bottomRightY = Math.max(
+ this.topLeft.y,
+ this.bottomRight.y,
+ other.topLeft.y,
+ other.bottomRight.y,
+ );
+
+ return new Rect(
+ topLeftX,
+ topLeftY,
+ bottomRightX - topLeftX,
+ bottomRightY - topLeftY,
+ );
+ }
+}
diff --git a/packages/web/src/math/index.ts b/packages/web/src/math/index.ts
new file mode 100644
index 0000000..70dbb67
--- /dev/null
+++ b/packages/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/packages/web/src/types.ts b/packages/web/src/types.ts
new file mode 100644
index 0000000..dc26c89
--- /dev/null
+++ b/packages/web/src/types.ts
@@ -0,0 +1,55 @@
+import { Immutable } from "immer";
+import Ratio from "./math/Ratio";
+
+export type Cell = Immutable<{
+ value?: string;
+ widthRatio: Ratio;
+}>;
+
+export interface Row {
+ cells: Cell[];
+}
+
+export interface Part {
+ title?: string;
+ rows: Row[];
+}
+
+export interface Grid {
+ id: string;
+ baseCellSize: number;
+ baseCellWidthRatio: Ratio;
+ parts: Part[];
+}
+
+export interface Doc {
+ grids: Grid[];
+}
+
+export interface RowRef {
+ partIndex: number;
+ rowIndex: number;
+}
+
+export interface CellRef {
+ partIndex: number;
+ rowIndex: number;
+ cellIndex: number;
+}
+
+export function cellRefEquals(a: CellRef, b: CellRef): boolean {
+ return (
+ a.partIndex === b.partIndex &&
+ a.rowIndex === b.rowIndex &&
+ a.cellIndex === b.cellIndex
+ );
+}
+
+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 };
+}