import NotiveElement, { customElement, eventHandler } from "../../element"; import h from "../../html"; import { ActiveCellSelection, Selection } from "../../selection"; 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"; @customElement("ntv-grid") export class NotiveGridElement extends NotiveElement { #internals: ElementInternals = this.attachInternals(); grid?: RenderedGrid; #selection?: Selection; get selection() { return this.#selection; } set selection(selection: Selection | undefined) { this.#selection = selection; this.drawSelection(); } @eventHandler("ntv:grid:selectionchange") ongridselectionchange?: (event: GridSelectionEvent) => any; @eventHandler("ntv:grid:cellchange") oncellchange?: (event: GridCellChangeEvent) => any; canvas: HTMLCanvasElement = h.canvas({ onmousedown: (event) => { 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?: Selection; #selectionAbortController?: AbortController; startSelecting(cellRef: CellRef) { if (!this.grid || this.#pendingSelection) return; this.#internals.states.add("selecting"); this.#selectionAbortController = new AbortController(); this.#selectionAbortController.signal.addEventListener("abort", () => { this.#internals.states.delete("selecting"); this.#selectionAbortController = undefined; }); const { signal } = this.#selectionAbortController; window.addEventListener( "mousemove", (event) => { const cellRef = this.#mouseEventCellRef(event); if (!cellRef) return; this.#pendingSelection = this.#pendingSelection?.extend(cellRef); this.drawSelection(); }, { signal }, ); window.addEventListener( "mouseup", () => { this.#selectionAbortController?.abort(); if (!this.#pendingSelection) return; this.dispatchEvent( new GridSelectionEvent( "ntv:grid:selectionchange", this.#pendingSelection, ), ); this.#pendingSelection = undefined; this.drawSelection(); }, { signal }, ); this.#pendingSelection = new ActiveCellSelection(this.grid.id, cellRef); 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 GridSelectionEvent extends Event { selection: Selection; constructor(type: string, selection: Selection) { super(type); this.selection = selection; } } export class GridCellChangeEvent extends Event { cellRef: CellRef; value?: string; constructor(cellRef: CellRef, value: string | undefined) { super("ntv:grid:cellchange"); this.cellRef = cellRef; this.value = value; } } declare global { interface HTMLElementEventMap { "ntv:grid:selectionchange": GridSelectionEvent; "ntv:grid:cellchange": GridCellChangeEvent; } }