import h, { type CreateElement } 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";
export class NotiveGridElement extends HTMLElement {
#internals: ElementInternals = this.attachInternals();
grid?: RenderedGrid;
#selection?: Selection;
get selection() {
return this.#selection;
}
set selection(selection: Selection | undefined) {
this.#selection = selection;
this.drawSelection();
}
#ongridselectionchange?: ((event: GridSelectionEvent) => any) | undefined;
get ongridselectionchange() {
return this.#ongridselectionchange;
}
set ongridselectionchange(
handler: ((event: GridSelectionEvent) => any) | undefined,
) {
if (this.#ongridselectionchange) {
this.removeEventListener(
"ntv:grid:selectionchange",
this.#ongridselectionchange,
);
}
this.#ongridselectionchange = handler;
if (handler) {
this.addEventListener("ntv:grid:selectionchange", handler);
}
}
canvas: HTMLCanvasElement = h.canvas({
onmousedown: (event) => {
if (!this.grid) return;
const cellRef = this.#mouseEventCellRef(event);
if (!cellRef) return;
this.startSelecting(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;
#editInputEl: HTMLInputElement = h.input({
dataset: { editCell: "true" },
onblur: () => {
this.#finishEditing();
},
onkeydown: (event) => {
switch (event.key) {
case "Enter":
this.#finishEditing();
break;
case "Escape":
this.#cancelEditing();
break;
}
},
});
#canvasDoubleClickCallback(this: NotiveGridElement, event: MouseEvent) {
if (!this.grid) return;
const cellRef = this.#mouseEventCellRef(event);
if (!cellRef) return;
const cell = getRenderedCell(this.grid, cellRef);
if (!cell) return;
this.#editingCellRef = cellRef;
this.append(this.#editInputEl);
this.#editInputEl.value = cell.value || "";
this.#editInputEl.style.left = cell.rect.topLeft.x + 2 + "px";
this.#editInputEl.style.top = cell.rect.topLeft.y + 2 + "px";
this.#editInputEl.style.width = cell.rect.width - 3 + "px";
this.#editInputEl.style.height = cell.rect.height - 3 + "px";
this.#editInputEl.focus();
}
#cancelEditing() {
this.#editInputEl.remove();
}
#finishEditing() {
this.#editInputEl.remove();
if (!this.grid) return;
window.notive.setCellValue(
this.grid.id,
this.#editingCellRef!,
this.#editInputEl.value,
);
}
}
customElements.define("ntv-grid", NotiveGridElement);
export default ((...args: any[]): NotiveGridElement =>
(h as any)["ntv-grid"](...args)) as CreateElement;
export class GridSelectionEvent extends Event {
selection: Selection;
constructor(type: string, selection: Selection) {
super(type);
this.selection = selection;
}
}
declare global {
interface HTMLElementEventMap {
"ntv:grid:selectionchange": GridSelectionEvent;
}
}