From 602145c956bb594ca0d0e10601cc4ad1a71cf70d Mon Sep 17 00:00:00 2001 From: Josh Kingsley Date: Sun, 23 Nov 2025 19:27:57 +0200 Subject: feat: integrate web and doc packages --- packages/web/src/components/app/index.css | 12 + packages/web/src/components/app/index.ts | 93 +++++++ packages/web/src/components/grid/cellAtCoord.ts | 40 +++ packages/web/src/components/grid/drawGrid.ts | 91 +++++++ packages/web/src/components/grid/drawSelection.ts | 97 ++++++++ packages/web/src/components/grid/excursion.ts | 8 + packages/web/src/components/grid/index.css | 49 ++++ packages/web/src/components/grid/index.ts | 276 +++++++++++++++++++++ packages/web/src/components/grid/renderGrid.ts | 144 +++++++++++ packages/web/src/components/grid/selection.ts | 28 +++ packages/web/src/components/icons/index.ts | 19 ++ packages/web/src/components/icons/svgs/minus16.svg | 3 + packages/web/src/components/icons/svgs/plus16.svg | 3 + packages/web/src/components/index.ts | 3 + packages/web/src/components/toolbar/index.css | 48 ++++ packages/web/src/components/toolbar/index.ts | 70 ++++++ 16 files changed, 984 insertions(+) create mode 100644 packages/web/src/components/app/index.css create mode 100644 packages/web/src/components/app/index.ts create mode 100644 packages/web/src/components/grid/cellAtCoord.ts create mode 100644 packages/web/src/components/grid/drawGrid.ts create mode 100644 packages/web/src/components/grid/drawSelection.ts create mode 100644 packages/web/src/components/grid/excursion.ts create mode 100644 packages/web/src/components/grid/index.css create mode 100644 packages/web/src/components/grid/index.ts create mode 100644 packages/web/src/components/grid/renderGrid.ts create mode 100644 packages/web/src/components/grid/selection.ts create mode 100644 packages/web/src/components/icons/index.ts create mode 100644 packages/web/src/components/icons/svgs/minus16.svg create mode 100644 packages/web/src/components/icons/svgs/plus16.svg create mode 100644 packages/web/src/components/index.ts create mode 100644 packages/web/src/components/toolbar/index.css create mode 100644 packages/web/src/components/toolbar/index.ts (limited to 'packages/web/src/components') 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("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( + `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 @@ + + + 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 @@ + + + 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(); -- cgit v1.2.3