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; #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; } }