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/index.ts | |
| parent | 7c966e105cd9f65853de1aba0ecce63aa56aca0b (diff) | |
chore: improve dev tasks
Diffstat (limited to 'apps/web/src/components/grid/index.ts')
| -rw-r--r-- | apps/web/src/components/grid/index.ts | 276 |
1 files changed, 276 insertions, 0 deletions
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; + } +} |
