summaryrefslogtreecommitdiff
path: root/packages/web/src/components/grid
diff options
context:
space:
mode:
authorJosh Kingsley <josh@joshkingsley.me>2025-11-24 15:46:22 +0200
committerJosh Kingsley <josh@joshkingsley.me>2025-11-24 15:46:22 +0200
commitd724cc0bf6ff6d351319e6fb00f5184a04e16ac0 (patch)
treecb43253479df5db8f4844e17e68a48ea5a212df4 /packages/web/src/components/grid
parent7c966e105cd9f65853de1aba0ecce63aa56aca0b (diff)
chore: improve dev tasks
Diffstat (limited to 'packages/web/src/components/grid')
-rw-r--r--packages/web/src/components/grid/cellAtCoord.ts40
-rw-r--r--packages/web/src/components/grid/drawGrid.ts91
-rw-r--r--packages/web/src/components/grid/drawSelection.ts97
-rw-r--r--packages/web/src/components/grid/excursion.ts8
-rw-r--r--packages/web/src/components/grid/index.css49
-rw-r--r--packages/web/src/components/grid/index.ts276
-rw-r--r--packages/web/src/components/grid/renderGrid.ts144
-rw-r--r--packages/web/src/components/grid/selection.ts28
8 files changed, 0 insertions, 733 deletions
diff --git a/packages/web/src/components/grid/cellAtCoord.ts b/packages/web/src/components/grid/cellAtCoord.ts
deleted file mode 100644
index dd594a4..0000000
--- a/packages/web/src/components/grid/cellAtCoord.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-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
deleted file mode 100644
index da83c8e..0000000
--- a/packages/web/src/components/grid/drawGrid.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-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
deleted file mode 100644
index 1b8c2ed..0000000
--- a/packages/web/src/components/grid/drawSelection.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-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
deleted file mode 100644
index 7752df1..0000000
--- a/packages/web/src/components/grid/excursion.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-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
deleted file mode 100644
index c29f55d..0000000
--- a/packages/web/src/components/grid/index.css
+++ /dev/null
@@ -1,49 +0,0 @@
-@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
deleted file mode 100644
index 3189409..0000000
--- a/packages/web/src/components/grid/index.ts
+++ /dev/null
@@ -1,276 +0,0 @@
-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
deleted file mode 100644
index 89938ec..0000000
--- a/packages/web/src/components/grid/renderGrid.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-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
deleted file mode 100644
index 517f8ae..0000000
--- a/packages/web/src/components/grid/selection.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-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];
-}