summaryrefslogtreecommitdiff
path: root/packages/web/src/components/grid
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web/src/components/grid')
-rw-r--r--packages/web/src/components/grid/cellAtCoord.ts40
-rw-r--r--packages/web/src/components/grid/drawGrid.ts91
-rw-r--r--packages/web/src/components/grid/drawSelection.ts97
-rw-r--r--packages/web/src/components/grid/excursion.ts8
-rw-r--r--packages/web/src/components/grid/index.css49
-rw-r--r--packages/web/src/components/grid/index.ts276
-rw-r--r--packages/web/src/components/grid/renderGrid.ts144
-rw-r--r--packages/web/src/components/grid/selection.ts28
8 files changed, 733 insertions, 0 deletions
diff --git a/packages/web/src/components/grid/cellAtCoord.ts b/packages/web/src/components/grid/cellAtCoord.ts
new file mode 100644
index 0000000..dd594a4
--- /dev/null
+++ b/packages/web/src/components/grid/cellAtCoord.ts
@@ -0,0 +1,40 @@
+import Coord from "../../math/Coord";
+import { CellRef } from "../../types";
+import { RenderedGrid, RenderedRow } from "./renderGrid";
+
+function rowAtCoord(grid: RenderedGrid, coord: Coord): RenderedRow | undefined {
+ if (coord.y <= grid.rect.topLeft.y) {
+ return grid.renderedRows[0];
+ }
+
+ if (coord.y >= grid.rect.bottomRight.y) {
+ return grid.renderedRows.at(-1);
+ }
+
+ return grid.renderedRows.find((row) =>
+ row.rect.verticallyContainsCoord(coord),
+ );
+}
+
+export default function cellAtCoord(
+ grid: RenderedGrid,
+ x: number,
+ y: number,
+): CellRef | undefined {
+ const coord = new Coord(x, y);
+ const row = rowAtCoord(grid, coord);
+
+ if (!row) return;
+
+ if (x <= row.rect.topLeft.x) {
+ return row.renderedCells[0]?.cellRef;
+ }
+
+ if (x >= row.rect.bottomRight.x) {
+ return row.renderedCells.at(-1)?.cellRef;
+ }
+
+ return row.renderedCells.find((cell) =>
+ cell.rect.horizontallyContainsCoord(coord),
+ )?.cellRef;
+}
diff --git a/packages/web/src/components/grid/drawGrid.ts b/packages/web/src/components/grid/drawGrid.ts
new file mode 100644
index 0000000..da83c8e
--- /dev/null
+++ b/packages/web/src/components/grid/drawGrid.ts
@@ -0,0 +1,91 @@
+import { RangeSelection, Selection } from "../../selection";
+import { CellRef } from "../../types";
+import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid";
+
+export interface GridStyles {
+ bgFill: string;
+ borderStroke: string;
+ cellStroke: string;
+ cellValueFont: string;
+ cellValueLineHeight: string;
+}
+
+function excursion(ctx: CanvasRenderingContext2D, f: () => void) {
+ ctx.save();
+ f();
+ ctx.restore();
+}
+
+function fillBackground(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ ctx.clearRect(0, 0, grid.rect.width, grid.rect.height);
+ ctx.fillStyle = styles.bgFill;
+ ctx.fillRect(0, 0, grid.rect.width, grid.rect.height);
+}
+
+function strokeGrid(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ ctx.strokeStyle = styles.borderStroke;
+ ctx.strokeRect(0.5, 0.5, grid.rect.width - 1, grid.rect.height - 1);
+}
+
+function strokeGridLines(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ ctx.strokeStyle = styles.cellStroke;
+
+ grid.renderedRows.forEach((row, renderedRowIndex) => {
+ const isLastRow = renderedRowIndex === grid.renderedRows.length - 1;
+
+ row.renderedCells.forEach((cell, cellIndex) => {
+ const { topLeft, width, height } = cell.rect;
+ const isLastCell = cellIndex === row.renderedCells.length - 1;
+
+ ctx.strokeRect(
+ topLeft.x + 0.5,
+ topLeft.y + 0.5,
+ isLastCell ? width - 1 : width,
+ isLastRow ? height - 1 : height,
+ );
+ });
+ });
+}
+
+function drawCellValues(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ grid.renderedRows.forEach((row) =>
+ row.renderedCells.forEach((cell) => {
+ if (!cell.value) return;
+ ctx.fillStyle = "white";
+ ctx.textAlign = "center";
+ ctx.font = styles.cellValueFont;
+ ctx.fillText(
+ cell.value,
+ cell.rect.center.x,
+ cell.rect.center.y + parseInt(styles.cellValueLineHeight) / 4,
+ );
+ }),
+ );
+}
+
+export default function drawGrid(
+ ctx: CanvasRenderingContext2D,
+ styles: GridStyles,
+ grid: RenderedGrid,
+) {
+ excursion(ctx, () => fillBackground(ctx, styles, grid));
+ excursion(ctx, () => strokeGridLines(ctx, styles, grid));
+ excursion(ctx, () => strokeGrid(ctx, styles, grid));
+ excursion(ctx, () => drawCellValues(ctx, styles, grid));
+}
diff --git a/packages/web/src/components/grid/drawSelection.ts b/packages/web/src/components/grid/drawSelection.ts
new file mode 100644
index 0000000..1b8c2ed
--- /dev/null
+++ b/packages/web/src/components/grid/drawSelection.ts
@@ -0,0 +1,97 @@
+import { CellRef } from "../../types";
+import excursion from "./excursion";
+import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid";
+import { GridSelection } from "./selection";
+
+export interface SelectionStyles {
+ activeCellStroke: string;
+ selectionRangeFill: string;
+ selectionRangeStroke: string;
+}
+
+function strokeActiveCell(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ cell: RenderedCell,
+) {
+ excursion(ctx, () => {
+ const isLastCell = cell.rect.bottomRight.x === grid.rect.bottomRight.x;
+ const isLastRow = cell.rect.bottomRight.y === grid.rect.bottomRight.y;
+
+ ctx.strokeStyle = styles.activeCellStroke;
+ ctx.lineWidth = 2;
+
+ ctx.strokeRect(
+ cell.rect.topLeft.x + 1,
+ cell.rect.topLeft.y + 1,
+ isLastCell ? cell.rect.width - 2 : cell.rect.width - 1,
+ isLastRow ? cell.rect.height - 2 : cell.rect.height - 1,
+ );
+ });
+}
+
+function drawCellRange(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ start: CellRef,
+ end: CellRef,
+ { stroke }: { stroke: boolean },
+) {
+ excursion(ctx, () => {
+ const startCell = getRenderedCell(grid, start);
+ const endCell = getRenderedCell(grid, end);
+
+ if (!startCell || !endCell) return;
+
+ const rect = startCell.rect.extend(endCell.rect);
+
+ const isRightEdge = rect.bottomRight.x === grid.rect.bottomRight.x;
+ const isBottomEdge = rect.bottomRight.y === grid.rect.bottomRight.y;
+
+ ctx.fillStyle = styles.selectionRangeFill;
+
+ ctx.fillRect(
+ rect.topLeft.x + 1,
+ rect.topLeft.y + 1,
+ isRightEdge ? rect.width - 2 : rect.width - 1,
+ isBottomEdge ? rect.height - 2 : rect.height - 1,
+ );
+
+ if (!stroke) return;
+
+ ctx.strokeStyle = styles.selectionRangeStroke;
+
+ ctx.strokeRect(
+ rect.topLeft.x + 0.5,
+ rect.topLeft.y + 0.5,
+ isRightEdge ? rect.width - 1 : rect.width,
+ isBottomEdge ? rect.height - 1 : rect.height,
+ );
+ });
+}
+
+export default function drawSelection(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ selection: GridSelection | undefined,
+ { pending }: { pending: boolean },
+) {
+ ctx.clearRect(0, 0, grid.rect.width, grid.rect.height);
+
+ if (!selection) return;
+
+ const activeCell = getRenderedCell(grid, selection.activeCellRef);
+
+ if (!activeCell) return;
+
+ if (selection.range) {
+ drawCellRange(ctx, styles, grid, selection.range[0], selection.range[1], {
+ stroke: !pending,
+ });
+ }
+
+ strokeActiveCell(ctx, styles, grid, activeCell);
+}
diff --git a/packages/web/src/components/grid/excursion.ts b/packages/web/src/components/grid/excursion.ts
new file mode 100644
index 0000000..7752df1
--- /dev/null
+++ b/packages/web/src/components/grid/excursion.ts
@@ -0,0 +1,8 @@
+export default function excursion(
+ ctx: CanvasRenderingContext2D,
+ f: () => void,
+) {
+ ctx.save();
+ f();
+ ctx.restore();
+}
diff --git a/packages/web/src/components/grid/index.css b/packages/web/src/components/grid/index.css
new file mode 100644
index 0000000..c29f55d
--- /dev/null
+++ b/packages/web/src/components/grid/index.css
@@ -0,0 +1,49 @@
+@layer components {
+ ntv-grid {
+ display: block;
+ position: relative;
+
+ --grid-bg-fill: var(--color-neutral-900);
+ --grid-border-stroke: var(--color-neutral-700);
+ --grid-cell-stroke: var(--color-neutral-800);
+ --grid-active-cell-stroke: var(--color-green-400);
+ --grid-selection-range-fill: color-mix(
+ in oklab,
+ var(--color-green-400) 10%,
+ transparent
+ );
+ --grid-selection-range-stroke: var(--color-green-400);
+ font-size: 14px;
+ }
+
+ ntv-grid > canvas {
+ display: block;
+ }
+
+ ntv-grid > canvas[data-selection] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ }
+
+ :has(ntv-grid:state(selecting))
+ > ntv-grid:not(:state(selecting))
+ > canvas[data-selection] {
+ display: none;
+ }
+
+ ntv-grid input[data-edit] {
+ position: absolute;
+ vertical-align: baseline;
+ background: var(--color-neutral-800);
+ padding-right: 1px;
+ padding-bottom: 1px;
+ color: white;
+ text-align: center;
+ }
+
+ ntv-grid input[data-edit]:focus-visible {
+ outline: none;
+ }
+}
diff --git a/packages/web/src/components/grid/index.ts b/packages/web/src/components/grid/index.ts
new file mode 100644
index 0000000..3189409
--- /dev/null
+++ b/packages/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;
+ }
+}
diff --git a/packages/web/src/components/grid/renderGrid.ts b/packages/web/src/components/grid/renderGrid.ts
new file mode 100644
index 0000000..89938ec
--- /dev/null
+++ b/packages/web/src/components/grid/renderGrid.ts
@@ -0,0 +1,144 @@
+import Ratio from "../../math/Ratio";
+import Rect from "../../math/Rect";
+import { Cell, CellRef, Grid, Row, RowRef } from "../../types";
+
+export interface RenderedCell extends Cell {
+ cellRef: CellRef;
+ renderedRowIndex: number;
+ rect: Rect;
+ startRatio: Ratio;
+ endRatio: Ratio;
+}
+
+export interface RenderedRow {
+ rowRef: RowRef;
+ rect: Rect;
+ renderedCells: RenderedCell[];
+}
+
+export interface RenderedGrid extends Grid {
+ rect: Rect;
+ renderedRows: RenderedRow[];
+}
+
+function renderCell(
+ grid: Grid,
+ cell: Cell,
+ cellRef: CellRef,
+ renderedRowIndex: number,
+ topLeftX: number,
+ topLeftY: number,
+ startRatio: Ratio,
+): RenderedCell {
+ const width = cell.widthRatio
+ .divideRatio(grid.baseCellWidthRatio)
+ .multiplyRatio(Ratio.fromInteger(grid.baseCellSize))
+ .toNumber();
+
+ const rect = new Rect(topLeftX, topLeftY, width, grid.baseCellSize);
+
+ const endRatio = startRatio.add(cell.widthRatio);
+
+ return { ...cell, cellRef, rect, renderedRowIndex, startRatio, endRatio };
+}
+
+function renderRow(
+ grid: Grid,
+ row: Row,
+ rowRef: RowRef,
+ renderedRowIndex: number,
+ topLeftY: number,
+): RenderedRow {
+ if (row.cells.length === 0) {
+ return {
+ ...row,
+ rowRef,
+ rect: new Rect(0, topLeftY, 0, 0),
+ renderedCells: [],
+ };
+ }
+
+ let topLeftX = 0;
+ let startRatio = Ratio.fromInteger(0);
+
+ const renderedCells = row.cells.map((cell, cellIndex) => {
+ const cellRef = { ...rowRef, cellIndex };
+
+ const renderedCell = renderCell(
+ grid,
+ cell,
+ cellRef,
+ renderedRowIndex,
+ topLeftX,
+ topLeftY,
+ startRatio,
+ );
+
+ topLeftX = renderedCell.rect.bottomRight.x;
+ startRatio = renderedCell.endRatio;
+
+ return renderedCell;
+ });
+
+ const { topLeft } = renderedCells[0].rect;
+ const { bottomRight } = renderedCells.at(-1)!.rect;
+
+ const rect = new Rect(
+ topLeft.x,
+ topLeft.y,
+ bottomRight.x - topLeft.x,
+ bottomRight.y - topLeft.y,
+ );
+
+ return { ...row, renderedCells, rect, rowRef };
+}
+
+function renderRows(grid: Grid): RenderedRow[] {
+ const renderedRows: RenderedRow[] = [];
+
+ let partIndex = 0;
+ let rowIndex = 0;
+ let topLeftY = 0;
+ let renderedRowIndex = 0;
+
+ while (true) {
+ if (!grid.parts[partIndex]?.rows[rowIndex]) break;
+
+ const row = grid.parts[partIndex].rows[rowIndex];
+ const rowRef = { partIndex, rowIndex };
+ const renderedRow = renderRow(
+ grid,
+ row,
+ rowRef,
+ renderedRowIndex,
+ topLeftY,
+ );
+
+ topLeftY = renderedRow.rect.bottomRight.y;
+ renderedRows.push(renderedRow);
+
+ if (!grid.parts[++partIndex]) {
+ partIndex = 0;
+ rowIndex++;
+ }
+
+ renderedRowIndex++;
+ }
+
+ return renderedRows;
+}
+
+export default function renderGrid(grid: Grid) {
+ const renderedRows = renderRows(grid);
+ const rect = renderedRows[0].rect.extend(renderedRows.at(-1)!.rect);
+ return { ...grid, rect, renderedRows };
+}
+
+export function getRenderedCell(
+ grid: RenderedGrid,
+ cellRef: CellRef,
+): RenderedCell | undefined {
+ const renderedRowIndex =
+ cellRef.rowIndex * grid.parts.length + cellRef.partIndex;
+ return grid.renderedRows[renderedRowIndex]?.renderedCells[cellRef.cellIndex];
+}
diff --git a/packages/web/src/components/grid/selection.ts b/packages/web/src/components/grid/selection.ts
new file mode 100644
index 0000000..517f8ae
--- /dev/null
+++ b/packages/web/src/components/grid/selection.ts
@@ -0,0 +1,28 @@
+import { CellRef, cellRefEquals } from "../../types";
+import { RenderedGrid } from "./renderGrid";
+
+export type CellRange = [start: CellRef, end: CellRef];
+
+export interface GridSelection {
+ activeCellRef: CellRef;
+ range?: CellRange;
+}
+
+export function extendSelection(
+ selection: GridSelection | undefined,
+ cellRef: CellRef,
+): GridSelection {
+ if (!selection || cellRefEquals(selection.activeCellRef, cellRef)) {
+ return { activeCellRef: cellRef };
+ }
+
+ if (selection.range) {
+ return { ...selection, range: [selection.range[0], cellRef] };
+ }
+
+ return { ...selection, range: [selection.activeCellRef, cellRef] };
+}
+
+export function getSelectionRange(selection: GridSelection): CellRange {
+ return selection.range ?? [selection.activeCellRef, selection.activeCellRef];
+}