diff options
| author | Josh Kingsley <josh@joshkingsley.me> | 2025-11-23 19:27:57 +0200 |
|---|---|---|
| committer | Josh Kingsley <josh@joshkingsley.me> | 2025-11-23 19:27:57 +0200 |
| commit | 602145c956bb594ca0d0e10601cc4ad1a71cf70d (patch) | |
| tree | d9f9980bd2054cff5819d01379f5c1c55f8eb66d /packages/web/src | |
| parent | c2a6efb1b761014a90d90373cad47a14054af40b (diff) | |
feat: integrate web and doc packages
Diffstat (limited to 'packages/web/src')
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 Binary files differnew file mode 100644 index 0000000..c10cfe9 --- /dev/null +++ b/packages/web/src/favicon.ico 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 }; +} |
