summaryrefslogtreecommitdiff
path: root/apps/web/src/components/grid/index.ts
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 /apps/web/src/components/grid/index.ts
parent7c966e105cd9f65853de1aba0ecce63aa56aca0b (diff)
chore: improve dev tasks
Diffstat (limited to 'apps/web/src/components/grid/index.ts')
-rw-r--r--apps/web/src/components/grid/index.ts276
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;
+ }
+}