diff options
| author | Josh Kingsley <josh@joshkingsley.me> | 2025-11-24 15:46:22 +0200 |
|---|---|---|
| committer | Josh Kingsley <josh@joshkingsley.me> | 2025-11-24 15:46:22 +0200 |
| commit | d724cc0bf6ff6d351319e6fb00f5184a04e16ac0 (patch) | |
| tree | cb43253479df5db8f4844e17e68a48ea5a212df4 /apps/web/src/components/grid | |
| parent | 7c966e105cd9f65853de1aba0ecce63aa56aca0b (diff) | |
chore: improve dev tasks
Diffstat (limited to 'apps/web/src/components/grid')
| -rw-r--r-- | apps/web/src/components/grid/cellAtCoord.ts | 40 | ||||
| -rw-r--r-- | apps/web/src/components/grid/drawGrid.ts | 91 | ||||
| -rw-r--r-- | apps/web/src/components/grid/drawSelection.ts | 97 | ||||
| -rw-r--r-- | apps/web/src/components/grid/excursion.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/components/grid/index.css | 49 | ||||
| -rw-r--r-- | apps/web/src/components/grid/index.ts | 276 | ||||
| -rw-r--r-- | apps/web/src/components/grid/renderGrid.ts | 144 | ||||
| -rw-r--r-- | apps/web/src/components/grid/selection.ts | 28 |
8 files changed, 733 insertions, 0 deletions
diff --git a/apps/web/src/components/grid/cellAtCoord.ts b/apps/web/src/components/grid/cellAtCoord.ts new file mode 100644 index 0000000..dd594a4 --- /dev/null +++ b/apps/web/src/components/grid/cellAtCoord.ts @@ -0,0 +1,40 @@ +import Coord from "../../math/Coord"; +import { CellRef } from "../../types"; +import { RenderedGrid, RenderedRow } from "./renderGrid"; + +function rowAtCoord(grid: RenderedGrid, coord: Coord): RenderedRow | undefined { + if (coord.y <= grid.rect.topLeft.y) { + return grid.renderedRows[0]; + } + + if (coord.y >= grid.rect.bottomRight.y) { + return grid.renderedRows.at(-1); + } + + return grid.renderedRows.find((row) => + row.rect.verticallyContainsCoord(coord), + ); +} + +export default function cellAtCoord( + grid: RenderedGrid, + x: number, + y: number, +): CellRef | undefined { + const coord = new Coord(x, y); + const row = rowAtCoord(grid, coord); + + if (!row) return; + + if (x <= row.rect.topLeft.x) { + return row.renderedCells[0]?.cellRef; + } + + if (x >= row.rect.bottomRight.x) { + return row.renderedCells.at(-1)?.cellRef; + } + + return row.renderedCells.find((cell) => + cell.rect.horizontallyContainsCoord(coord), + )?.cellRef; +} diff --git a/apps/web/src/components/grid/drawGrid.ts b/apps/web/src/components/grid/drawGrid.ts new file mode 100644 index 0000000..da83c8e --- /dev/null +++ b/apps/web/src/components/grid/drawGrid.ts @@ -0,0 +1,91 @@ +import { RangeSelection, Selection } from "../../selection"; +import { CellRef } from "../../types"; +import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid"; + +export interface GridStyles { + bgFill: string; + borderStroke: string; + cellStroke: string; + cellValueFont: string; + cellValueLineHeight: string; +} + +function excursion(ctx: CanvasRenderingContext2D, f: () => void) { + ctx.save(); + f(); + ctx.restore(); +} + +function fillBackground( + ctx: CanvasRenderingContext2D, + styles: GridStyles, + grid: RenderedGrid, +) { + ctx.clearRect(0, 0, grid.rect.width, grid.rect.height); + ctx.fillStyle = styles.bgFill; + ctx.fillRect(0, 0, grid.rect.width, grid.rect.height); +} + +function strokeGrid( + ctx: CanvasRenderingContext2D, + styles: GridStyles, + grid: RenderedGrid, +) { + ctx.strokeStyle = styles.borderStroke; + ctx.strokeRect(0.5, 0.5, grid.rect.width - 1, grid.rect.height - 1); +} + +function strokeGridLines( + ctx: CanvasRenderingContext2D, + styles: GridStyles, + grid: RenderedGrid, +) { + ctx.strokeStyle = styles.cellStroke; + + grid.renderedRows.forEach((row, renderedRowIndex) => { + const isLastRow = renderedRowIndex === grid.renderedRows.length - 1; + + row.renderedCells.forEach((cell, cellIndex) => { + const { topLeft, width, height } = cell.rect; + const isLastCell = cellIndex === row.renderedCells.length - 1; + + ctx.strokeRect( + topLeft.x + 0.5, + topLeft.y + 0.5, + isLastCell ? width - 1 : width, + isLastRow ? height - 1 : height, + ); + }); + }); +} + +function drawCellValues( + ctx: CanvasRenderingContext2D, + styles: GridStyles, + grid: RenderedGrid, +) { + grid.renderedRows.forEach((row) => + row.renderedCells.forEach((cell) => { + if (!cell.value) return; + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.font = styles.cellValueFont; + ctx.fillText( + cell.value, + cell.rect.center.x, + cell.rect.center.y + parseInt(styles.cellValueLineHeight) / 4, + ); + }), + ); +} + +export default function drawGrid( + ctx: CanvasRenderingContext2D, + styles: GridStyles, + grid: RenderedGrid, +) { + excursion(ctx, () => fillBackground(ctx, styles, grid)); + excursion(ctx, () => strokeGridLines(ctx, styles, grid)); + excursion(ctx, () => strokeGrid(ctx, styles, grid)); + excursion(ctx, () => drawCellValues(ctx, styles, grid)); +} diff --git a/apps/web/src/components/grid/drawSelection.ts b/apps/web/src/components/grid/drawSelection.ts new file mode 100644 index 0000000..1b8c2ed --- /dev/null +++ b/apps/web/src/components/grid/drawSelection.ts @@ -0,0 +1,97 @@ +import { CellRef } from "../../types"; +import excursion from "./excursion"; +import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid"; +import { GridSelection } from "./selection"; + +export interface SelectionStyles { + activeCellStroke: string; + selectionRangeFill: string; + selectionRangeStroke: string; +} + +function strokeActiveCell( + ctx: CanvasRenderingContext2D, + styles: SelectionStyles, + grid: RenderedGrid, + cell: RenderedCell, +) { + excursion(ctx, () => { + const isLastCell = cell.rect.bottomRight.x === grid.rect.bottomRight.x; + const isLastRow = cell.rect.bottomRight.y === grid.rect.bottomRight.y; + + ctx.strokeStyle = styles.activeCellStroke; + ctx.lineWidth = 2; + + ctx.strokeRect( + cell.rect.topLeft.x + 1, + cell.rect.topLeft.y + 1, + isLastCell ? cell.rect.width - 2 : cell.rect.width - 1, + isLastRow ? cell.rect.height - 2 : cell.rect.height - 1, + ); + }); +} + +function drawCellRange( + ctx: CanvasRenderingContext2D, + styles: SelectionStyles, + grid: RenderedGrid, + start: CellRef, + end: CellRef, + { stroke }: { stroke: boolean }, +) { + excursion(ctx, () => { + const startCell = getRenderedCell(grid, start); + const endCell = getRenderedCell(grid, end); + + if (!startCell || !endCell) return; + + const rect = startCell.rect.extend(endCell.rect); + + const isRightEdge = rect.bottomRight.x === grid.rect.bottomRight.x; + const isBottomEdge = rect.bottomRight.y === grid.rect.bottomRight.y; + + ctx.fillStyle = styles.selectionRangeFill; + + ctx.fillRect( + rect.topLeft.x + 1, + rect.topLeft.y + 1, + isRightEdge ? rect.width - 2 : rect.width - 1, + isBottomEdge ? rect.height - 2 : rect.height - 1, + ); + + if (!stroke) return; + + ctx.strokeStyle = styles.selectionRangeStroke; + + ctx.strokeRect( + rect.topLeft.x + 0.5, + rect.topLeft.y + 0.5, + isRightEdge ? rect.width - 1 : rect.width, + isBottomEdge ? rect.height - 1 : rect.height, + ); + }); +} + +export default function drawSelection( + ctx: CanvasRenderingContext2D, + styles: SelectionStyles, + grid: RenderedGrid, + selection: GridSelection | undefined, + { pending }: { pending: boolean }, +) { + ctx.clearRect(0, 0, grid.rect.width, grid.rect.height); + + if (!selection) return; + + const activeCell = getRenderedCell(grid, selection.activeCellRef); + + if (!activeCell) return; + + if (selection.range) { + drawCellRange(ctx, styles, grid, selection.range[0], selection.range[1], { + stroke: !pending, + }); + } + + strokeActiveCell(ctx, styles, grid, activeCell); +} diff --git a/apps/web/src/components/grid/excursion.ts b/apps/web/src/components/grid/excursion.ts new file mode 100644 index 0000000..7752df1 --- /dev/null +++ b/apps/web/src/components/grid/excursion.ts @@ -0,0 +1,8 @@ +export default function excursion( + ctx: CanvasRenderingContext2D, + f: () => void, +) { + ctx.save(); + f(); + ctx.restore(); +} diff --git a/apps/web/src/components/grid/index.css b/apps/web/src/components/grid/index.css new file mode 100644 index 0000000..c29f55d --- /dev/null +++ b/apps/web/src/components/grid/index.css @@ -0,0 +1,49 @@ +@layer components { + ntv-grid { + display: block; + position: relative; + + --grid-bg-fill: var(--color-neutral-900); + --grid-border-stroke: var(--color-neutral-700); + --grid-cell-stroke: var(--color-neutral-800); + --grid-active-cell-stroke: var(--color-green-400); + --grid-selection-range-fill: color-mix( + in oklab, + var(--color-green-400) 10%, + transparent + ); + --grid-selection-range-stroke: var(--color-green-400); + font-size: 14px; + } + + ntv-grid > canvas { + display: block; + } + + ntv-grid > canvas[data-selection] { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + } + + :has(ntv-grid:state(selecting)) + > ntv-grid:not(:state(selecting)) + > canvas[data-selection] { + display: none; + } + + ntv-grid input[data-edit] { + position: absolute; + vertical-align: baseline; + background: var(--color-neutral-800); + padding-right: 1px; + padding-bottom: 1px; + color: white; + text-align: center; + } + + ntv-grid input[data-edit]:focus-visible { + outline: none; + } +} diff --git a/apps/web/src/components/grid/index.ts b/apps/web/src/components/grid/index.ts new file mode 100644 index 0000000..3189409 --- /dev/null +++ b/apps/web/src/components/grid/index.ts @@ -0,0 +1,276 @@ +import NotiveElement, { customElement, eventHandler } from "../../element"; +import h from "../../html"; +import { CellRef } from "../../types"; +import cellAtCoord from "./cellAtCoord"; +import drawGrid, { GridStyles } from "./drawGrid"; +import drawSelection, { SelectionStyles } from "./drawSelection"; +import "./index.css"; +import { getRenderedCell, RenderedGrid } from "./renderGrid"; +import { extendSelection, GridSelection } from "./selection"; + +@customElement("ntv-grid") +export class NotiveGridElement extends NotiveElement { + #internals: ElementInternals = this.attachInternals(); + + #grid?: RenderedGrid; + + get grid(): RenderedGrid | undefined { + return this.#grid; + } + + set grid(grid: RenderedGrid | undefined) { + this.#grid = grid; + this.draw(); + } + + #selection?: GridSelection; + + get selection() { + return this.#selection; + } + + set selection(selection: GridSelection | undefined) { + this.#selection = selection; + this.drawSelection(); + } + + @eventHandler("ntv:grid:selectionchange") + ongridselectionchange?: (event: GridSelectionChangeEvent) => any; + + @eventHandler("ntv:grid:cellchange") + oncellchange?: (event: GridCellChangeEvent) => any; + + canvas: HTMLCanvasElement = h.canvas({ + onmousedown: (event) => { + if (event.button !== 0) return; + if (!this.grid) return; + const cellRef = this.#mouseEventCellRef(event); + if (!cellRef) return; + this.startSelecting(cellRef); + }, + ondblclick: (event) => { + if (!this.grid) return; + const cellRef = this.#mouseEventCellRef(event); + if (!cellRef) return; + this.startEditing(cellRef); + }, + }); + + selectionCanvas: HTMLCanvasElement = h.canvas({ + dataset: { selection: "true" }, + }); + + connectedCallback() { + this.append(this.canvas, this.selectionCanvas); + this.draw(); + this.drawSelection(); + } + + draw() { + if (!this.grid) return; + + const ctx = this.canvas.getContext("2d"); + + if (!ctx) throw new Error("Unable to get canvas context"); + + this.canvas.setAttribute("width", this.grid.rect.width + "px"); + this.canvas.setAttribute("height", this.grid.rect.height + "px"); + + drawGrid(ctx, this.getGridStyles(), this.grid); + } + + drawSelection() { + if (!this.grid) return; + + const ctx = this.selectionCanvas.getContext("2d"); + + if (!ctx) throw new Error("Unable to get canvas context"); + + this.selectionCanvas.setAttribute("width", this.grid.rect.width + "px"); + this.selectionCanvas.setAttribute("height", this.grid.rect.height + "px"); + + drawSelection( + ctx, + this.getSelectionStyles(), + this.grid, + this.#pendingSelection ?? this.selection, + { + pending: !!this.#pendingSelection, + }, + ); + } + + getGridStyles(): GridStyles { + const style = window.getComputedStyle(this); + const val = (k: string) => style.getPropertyValue(k); + + return { + bgFill: val("--grid-bg-fill"), + borderStroke: val("--grid-border-stroke"), + cellStroke: val("--grid-cell-stroke"), + cellValueFont: val("font"), + cellValueLineHeight: val("line-height"), + }; + } + + getSelectionStyles(): SelectionStyles { + const style = window.getComputedStyle(this); + const val = (k: string) => style.getPropertyValue(k); + + return { + activeCellStroke: val("--grid-active-cell-stroke"), + selectionRangeFill: val("--grid-selection-range-fill"), + selectionRangeStroke: val("--grid-selection-range-stroke"), + }; + } + + #pendingSelection?: GridSelection; + #selectionAbortController?: AbortController; + + startSelecting(cellRef: CellRef) { + if (!this.grid || this.#pendingSelection) return; + + this.#internals.states.add("selecting"); + + this.#selectionAbortController = new AbortController(); + const { signal } = this.#selectionAbortController; + + window.addEventListener( + "mousemove", + (event) => { + const cellRef = this.#mouseEventCellRef(event); + if (!cellRef) return; + this.#pendingSelection = extendSelection( + this.#pendingSelection, + cellRef, + ); + this.drawSelection(); + }, + { signal }, + ); + + window.addEventListener("mouseup", () => this.#finishSelecting(), { + signal, + }); + + window.addEventListener( + "keydown", + (event) => { + event.preventDefault(); + if (event.key === "Escape") { + this.#pendingSelection = undefined; + this.#finishSelecting(); + } + }, + { signal }, + ); + + this.#pendingSelection = extendSelection(undefined, cellRef); + this.drawSelection(); + } + + #finishSelecting() { + this.#selectionAbortController?.abort(); + this.#selectionAbortController = undefined; + this.#internals.states.delete("selecting"); + if (this.#pendingSelection) { + this.dispatchEvent(new GridSelectionChangeEvent(this.#pendingSelection)); + } + this.#pendingSelection = undefined; + this.drawSelection(); + } + + #mouseEventCellRef( + this: NotiveGridElement, + event: MouseEvent, + ): CellRef | undefined { + if (!this.grid) return; + const clientRect = this.canvas.getBoundingClientRect(); + const x = event.x - clientRect.x; + const y = event.y - clientRect.y; + return cellAtCoord(this.grid, x, y); + } + + #editingCellRef?: CellRef; + + #editInput: HTMLInputElement = h.input({ + dataset: { edit: "true" }, + onblur: () => this.#finishEditing(), + onkeydown: (event) => { + switch (event.key) { + case "Enter": + this.#finishEditing(); + break; + + case "Escape": + this.#cancelEditing(); + break; + } + }, + }); + + startEditing(cellRef: CellRef) { + if (!this.grid) return; + + const cell = getRenderedCell(this.grid, cellRef); + + if (!cell) return; + + this.#editingCellRef = cellRef; + + this.append(this.#editInput); + + this.#editInput.value = cell.value || ""; + + Object.assign(this.#editInput.style, { + left: cell.rect.topLeft.x + 2 + "px", + top: cell.rect.topLeft.y + 2 + "px", + width: cell.rect.width - 3 + "px", + height: cell.rect.height - 3 + "px", + }); + + this.#editInput.focus(); + } + + #cancelEditing() { + this.#editInput.remove(); + } + + #finishEditing() { + this.#editInput.remove(); + + if (!this.grid || !this.#editingCellRef) return; + + this.dispatchEvent( + new GridCellChangeEvent(this.#editingCellRef, this.#editInput.value), + ); + } +} + +export default NotiveGridElement.makeFactory(); + +export class GridSelectionChangeEvent extends Event { + static readonly TYPE = "ntv:grid:selectionchange"; + + constructor(public selection: GridSelection) { + super(GridSelectionChangeEvent.TYPE); + } +} + +export class GridCellChangeEvent extends Event { + static readonly TYPE = "ntv:grid:cellchange"; + + constructor( + public cellRef: CellRef, + public value: string | undefined, + ) { + super(GridCellChangeEvent.TYPE); + } +} + +declare global { + interface HTMLElementEventMap { + [GridSelectionChangeEvent.TYPE]: GridSelectionChangeEvent; + [GridCellChangeEvent.TYPE]: GridCellChangeEvent; + } +} diff --git a/apps/web/src/components/grid/renderGrid.ts b/apps/web/src/components/grid/renderGrid.ts new file mode 100644 index 0000000..89938ec --- /dev/null +++ b/apps/web/src/components/grid/renderGrid.ts @@ -0,0 +1,144 @@ +import Ratio from "../../math/Ratio"; +import Rect from "../../math/Rect"; +import { Cell, CellRef, Grid, Row, RowRef } from "../../types"; + +export interface RenderedCell extends Cell { + cellRef: CellRef; + renderedRowIndex: number; + rect: Rect; + startRatio: Ratio; + endRatio: Ratio; +} + +export interface RenderedRow { + rowRef: RowRef; + rect: Rect; + renderedCells: RenderedCell[]; +} + +export interface RenderedGrid extends Grid { + rect: Rect; + renderedRows: RenderedRow[]; +} + +function renderCell( + grid: Grid, + cell: Cell, + cellRef: CellRef, + renderedRowIndex: number, + topLeftX: number, + topLeftY: number, + startRatio: Ratio, +): RenderedCell { + const width = cell.widthRatio + .divideRatio(grid.baseCellWidthRatio) + .multiplyRatio(Ratio.fromInteger(grid.baseCellSize)) + .toNumber(); + + const rect = new Rect(topLeftX, topLeftY, width, grid.baseCellSize); + + const endRatio = startRatio.add(cell.widthRatio); + + return { ...cell, cellRef, rect, renderedRowIndex, startRatio, endRatio }; +} + +function renderRow( + grid: Grid, + row: Row, + rowRef: RowRef, + renderedRowIndex: number, + topLeftY: number, +): RenderedRow { + if (row.cells.length === 0) { + return { + ...row, + rowRef, + rect: new Rect(0, topLeftY, 0, 0), + renderedCells: [], + }; + } + + let topLeftX = 0; + let startRatio = Ratio.fromInteger(0); + + const renderedCells = row.cells.map((cell, cellIndex) => { + const cellRef = { ...rowRef, cellIndex }; + + const renderedCell = renderCell( + grid, + cell, + cellRef, + renderedRowIndex, + topLeftX, + topLeftY, + startRatio, + ); + + topLeftX = renderedCell.rect.bottomRight.x; + startRatio = renderedCell.endRatio; + + return renderedCell; + }); + + const { topLeft } = renderedCells[0].rect; + const { bottomRight } = renderedCells.at(-1)!.rect; + + const rect = new Rect( + topLeft.x, + topLeft.y, + bottomRight.x - topLeft.x, + bottomRight.y - topLeft.y, + ); + + return { ...row, renderedCells, rect, rowRef }; +} + +function renderRows(grid: Grid): RenderedRow[] { + const renderedRows: RenderedRow[] = []; + + let partIndex = 0; + let rowIndex = 0; + let topLeftY = 0; + let renderedRowIndex = 0; + + while (true) { + if (!grid.parts[partIndex]?.rows[rowIndex]) break; + + const row = grid.parts[partIndex].rows[rowIndex]; + const rowRef = { partIndex, rowIndex }; + const renderedRow = renderRow( + grid, + row, + rowRef, + renderedRowIndex, + topLeftY, + ); + + topLeftY = renderedRow.rect.bottomRight.y; + renderedRows.push(renderedRow); + + if (!grid.parts[++partIndex]) { + partIndex = 0; + rowIndex++; + } + + renderedRowIndex++; + } + + return renderedRows; +} + +export default function renderGrid(grid: Grid) { + const renderedRows = renderRows(grid); + const rect = renderedRows[0].rect.extend(renderedRows.at(-1)!.rect); + return { ...grid, rect, renderedRows }; +} + +export function getRenderedCell( + grid: RenderedGrid, + cellRef: CellRef, +): RenderedCell | undefined { + const renderedRowIndex = + cellRef.rowIndex * grid.parts.length + cellRef.partIndex; + return grid.renderedRows[renderedRowIndex]?.renderedCells[cellRef.cellIndex]; +} diff --git a/apps/web/src/components/grid/selection.ts b/apps/web/src/components/grid/selection.ts new file mode 100644 index 0000000..517f8ae --- /dev/null +++ b/apps/web/src/components/grid/selection.ts @@ -0,0 +1,28 @@ +import { CellRef, cellRefEquals } from "../../types"; +import { RenderedGrid } from "./renderGrid"; + +export type CellRange = [start: CellRef, end: CellRef]; + +export interface GridSelection { + activeCellRef: CellRef; + range?: CellRange; +} + +export function extendSelection( + selection: GridSelection | undefined, + cellRef: CellRef, +): GridSelection { + if (!selection || cellRefEquals(selection.activeCellRef, cellRef)) { + return { activeCellRef: cellRef }; + } + + if (selection.range) { + return { ...selection, range: [selection.range[0], cellRef] }; + } + + return { ...selection, range: [selection.activeCellRef, cellRef] }; +} + +export function getSelectionRange(selection: GridSelection): CellRange { + return selection.range ?? [selection.activeCellRef, selection.activeCellRef]; +} |
