diff options
| author | Josh Kingsley <josh@joshkingsley.me> | 2025-10-26 18:31:23 +0200 |
|---|---|---|
| committer | Josh Kingsley <josh@joshkingsley.me> | 2025-10-26 18:31:23 +0200 |
| commit | a984c69e3cca4bcf822989bb78a8befe2397e487 (patch) | |
| tree | b7599295851dcc87a89e696f32cdc211e77eab18 /web/src | |
| parent | 6ae3218e2dd130c085074a0d13d156bdba99716f (diff) | |
feat(web): draw selection
Diffstat (limited to 'web/src')
| -rw-r--r-- | web/src/components/app/index.ts | 1 | ||||
| -rw-r--r-- | web/src/components/grid/drawGrid.ts | 188 | ||||
| -rw-r--r-- | web/src/components/grid/index.css | 11 | ||||
| -rw-r--r-- | web/src/components/grid/index.ts | 69 | ||||
| -rw-r--r-- | web/src/index.ts | 67 | ||||
| -rw-r--r-- | web/src/selection.ts | 43 | ||||
| -rw-r--r-- | web/src/types.ts | 8 |
7 files changed, 308 insertions, 79 deletions
diff --git a/web/src/components/app/index.ts b/web/src/components/app/index.ts index 910aa52..ec40754 100644 --- a/web/src/components/app/index.ts +++ b/web/src/components/app/index.ts @@ -1,4 +1,3 @@ -import h from "../../html"; import ntvGrid from "../grid"; import ntvToolbar from "../toolbar"; import "./index.css"; diff --git a/web/src/components/grid/drawGrid.ts b/web/src/components/grid/drawGrid.ts index 8e64479..5ea17b6 100644 --- a/web/src/components/grid/drawGrid.ts +++ b/web/src/components/grid/drawGrid.ts @@ -1,21 +1,61 @@ -import colors from "tailwindcss/colors"; -import { PendingSelection, Selection } from "../../selection"; +import { RangeSelection, Selection } from "../../selection"; import { CellRef } from "../../types"; import { RenderedCell, RenderedGrid } from "./renderGrid"; -function fillBackground(ctx: CanvasRenderingContext2D, grid: RenderedGrid) { +interface GridColors { + bgFill: string; + borderStroke: string; + cellStroke: string; + selelectionRangeFill: string; + selectionRangeStroke: string; + activeCellStroke: string; +} + +export function getGridColors(el: HTMLElement): GridColors { + const style = window.getComputedStyle(el); + const color = (k: string) => style.getPropertyValue(k); + + return { + bgFill: color("--grid-bg-fill"), + borderStroke: color("--grid-border-stroke"), + cellStroke: color("--grid-cell-stroke"), + selelectionRangeFill: color("--grid-selection-range-fill"), + selectionRangeStroke: color("--grid-selection-range-stroke"), + activeCellStroke: color("--grid-active-cell-stroke"), + }; +} + +function excursion(ctx: CanvasRenderingContext2D, f: () => void) { + ctx.save(); + f(); + ctx.restore(); +} + +function fillBackground( + ctx: CanvasRenderingContext2D, + colors: GridColors, + grid: RenderedGrid, +) { ctx.clearRect(0, 0, grid.rect.width, grid.rect.height); - ctx.fillStyle = colors.neutral[900]; + ctx.fillStyle = colors.bgFill; ctx.fillRect(0, 0, grid.rect.width, grid.rect.height); } -function strokeGrid(ctx: CanvasRenderingContext2D, grid: RenderedGrid) { - ctx.strokeStyle = colors.neutral[700]; +function strokeGrid( + ctx: CanvasRenderingContext2D, + colors: GridColors, + grid: RenderedGrid, +) { + ctx.strokeStyle = colors.borderStroke; ctx.strokeRect(0.5, 0.5, grid.rect.width - 1, grid.rect.height - 1); } -function strokeGridLines(ctx: CanvasRenderingContext2D, grid: RenderedGrid) { - ctx.strokeStyle = colors.neutral[800]; +function strokeGridLines( + ctx: CanvasRenderingContext2D, + colors: GridColors, + grid: RenderedGrid, +) { + ctx.strokeStyle = colors.cellStroke; grid.renderedRows.forEach((row, renderedRowIndex) => { const isLastRow = renderedRowIndex === grid.renderedRows.length - 1; @@ -43,65 +83,127 @@ function getRenderedCell( return grid.renderedRows[renderedRowIndex]?.renderedCells[cellRef.cellIndex]; } -function drawPendingSelection( +function strokeActiveCell( ctx: CanvasRenderingContext2D, + colors: GridColors, grid: RenderedGrid, - selection: PendingSelection, -) {} + cell: RenderedCell, +) { + const isLastCell = cell.rect.bottomRight.x === grid.rect.bottomRight.x; + const isLastRow = cell.rect.bottomRight.y === grid.rect.bottomRight.y; -function drawSelection( + ctx.strokeStyle = colors.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, + colors: GridColors, grid: RenderedGrid, - selection: Selection, + start: CellRef, + end: CellRef, + { stroke }: { stroke: boolean }, ) { - if (selection.gridId !== grid.id) return; + const startCell = getRenderedCell(grid, start); + const endCell = getRenderedCell(grid, end); - const cell = getRenderedCell(grid, selection.activeCellRef); + if (!startCell || !endCell) return; - if (!cell) return; + const rect = startCell.rect.extend(endCell.rect); - const isLastCell = cell.rect.bottomRight.x === grid.rect.bottomRight.x; - const isLastRow = cell.rect.bottomRight.y === grid.rect.bottomRight.y; + const isRightEdge = rect.bottomRight.x === grid.rect.bottomRight.x; + const isBottomEdge = rect.bottomRight.y === grid.rect.bottomRight.y; + + ctx.fillStyle = colors.selelectionRangeFill; - // ctx.fillStyle = colors.green[4] + "30"; + ctx.fillRect( + rect.topLeft.x + 1, + rect.topLeft.y + 1, + isRightEdge ? rect.width - 2 : rect.width - 1, + isBottomEdge ? rect.height - 2 : rect.height - 1, + ); - // ctx.fillRect( - // cell.rect.topLeft.x + 1, - // cell.rect.topLeft.y + 1, - // cell.rect.width - 1, - // cell.rect.height - 1, - // ); + if (!stroke) return; - ctx.strokeStyle = colors.green[400]; - ctx.lineWidth = 2; + ctx.strokeStyle = colors.selectionRangeStroke; 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, + rect.topLeft.x + 0.5, + rect.topLeft.y + 0.5, + isRightEdge ? rect.width - 1 : rect.width, + isBottomEdge ? rect.height - 1 : rect.height, ); } +function drawPendingSelection( + ctx: CanvasRenderingContext2D, + colors: GridColors, + grid: RenderedGrid, + selection: Selection, +) { + if (selection.gridId !== grid.id) return; + + const activeCell = getRenderedCell(grid, selection.activeCellRef); + + if (!activeCell) return; + + if (selection instanceof RangeSelection) { + excursion(ctx, () => { + drawCellRange(ctx, colors, grid, selection.range[0], selection.range[1], { + stroke: false, + }); + }); + } + + excursion(ctx, () => strokeActiveCell(ctx, colors, grid, activeCell)); +} + +function drawSelection( + ctx: CanvasRenderingContext2D, + colors: GridColors, + grid: RenderedGrid, + selection: Selection, +) { + if (selection.gridId !== grid.id) return; + + const activeCell = getRenderedCell(grid, selection.activeCellRef); + + if (!activeCell) return; + + if (selection instanceof RangeSelection) { + excursion(ctx, () => { + drawCellRange(ctx, colors, grid, selection.range[0], selection.range[1], { + stroke: true, + }); + }); + } + + excursion(ctx, () => strokeActiveCell(ctx, colors, grid, activeCell)); +} + export default function drawGrid( ctx: CanvasRenderingContext2D, + colors: GridColors, grid: RenderedGrid, selection?: Selection, - pendingSelection?: PendingSelection, + pendingSelection?: Selection, ) { - const excursion = (f: () => void) => { - ctx.save(); - f(); - ctx.restore(); - }; - - excursion(() => fillBackground(ctx, grid)); - excursion(() => strokeGridLines(ctx, grid)); - excursion(() => strokeGrid(ctx, grid)); + excursion(ctx, () => fillBackground(ctx, colors, grid)); + excursion(ctx, () => strokeGridLines(ctx, colors, grid)); + excursion(ctx, () => strokeGrid(ctx, colors, grid)); if (pendingSelection) { - excursion(() => drawPendingSelection(ctx, grid, pendingSelection)); + excursion(ctx, () => + drawPendingSelection(ctx, colors, grid, pendingSelection), + ); } else if (selection) { - excursion(() => drawSelection(ctx, grid, selection)); + excursion(ctx, () => drawSelection(ctx, colors, grid, selection)); } } diff --git a/web/src/components/grid/index.css b/web/src/components/grid/index.css index a733015..825a74a 100644 --- a/web/src/components/grid/index.css +++ b/web/src/components/grid/index.css @@ -1,5 +1,16 @@ ntv-grid { display: block; + + --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); } ntv-grid > canvas { diff --git a/web/src/components/grid/index.ts b/web/src/components/grid/index.ts index 0acace4..fbb9c60 100644 --- a/web/src/components/grid/index.ts +++ b/web/src/components/grid/index.ts @@ -1,6 +1,7 @@ import h, { type CreateElement } from "../../html"; +import { CellRef } from "../../types"; import cellAtCoord from "./cellAtCoord"; -import drawGrid from "./drawGrid"; +import drawGrid, { getGridColors } from "./drawGrid"; import "./index.css"; class NotiveGridElement extends HTMLElement { @@ -26,18 +27,12 @@ class NotiveGridElement extends HTMLElement { throw new Error("ntv-grid requries gridId attribute"); } - this.canvasEl.addEventListener("mousedown", (event) => { - const clientRect = this.canvasEl.getBoundingClientRect(); - const x = event.x - clientRect.x; - const y = event.y - clientRect.y; - const cellRef = cellAtCoord(this.renderedGrid, x, y); - if (!cellRef) return; - window.notive.selectCell(this.#gridId, cellRef); - }); + window.addEventListener("ntv:selection-changed", () => this.draw()); - window.addEventListener("ntv:selection-changed", () => { - this.draw(); - }); + this.canvasEl.addEventListener( + "mousedown", + this.#canvasMouseDownCallback.bind(this), + ); this.append(this.canvasEl); this.draw(); @@ -55,13 +50,63 @@ class NotiveGridElement extends HTMLElement { this.canvasEl.setAttribute("width", grid.rect.width + "px"); this.canvasEl.setAttribute("height", grid.rect.height + "px"); + const colors = getGridColors(this); + drawGrid( ctx, + colors, grid, window.notive.selection, window.notive.pendingSelection, ); } + + #mouseEventCellRef( + this: NotiveGridElement, + event: MouseEvent, + ): CellRef | undefined { + const clientRect = this.canvasEl.getBoundingClientRect(); + const x = event.x - clientRect.x; + const y = event.y - clientRect.y; + return cellAtCoord(this.renderedGrid, x, y); + } + + #canvasMouseDownCallback(this: NotiveGridElement, event: MouseEvent) { + const cellRef = this.#mouseEventCellRef(event); + + if (!cellRef) return; + + window.notive.startSelecting(this.gridId, cellRef); + + this.#selectionAbortController = new AbortController(); + const { signal } = this.#selectionAbortController; + + window.addEventListener( + "mousemove", + this.#selectionMouseMoveCallback.bind(this), + { signal }, + ); + + window.addEventListener( + "mouseup", + this.#selectionMouseUpCallback.bind(this), + { signal }, + ); + } + + #selectionAbortController?: AbortController; + + #selectionMouseMoveCallback(this: NotiveGridElement, event: MouseEvent) { + const cellRef = this.#mouseEventCellRef(event); + if (!cellRef) return; + window.notive.extendSelection(cellRef); + } + + #selectionMouseUpCallback(this: NotiveGridElement, event: MouseEvent) { + this.#selectionAbortController?.abort(); + this.#selectionAbortController = undefined; + window.notive.finishSelecting(); + } } customElements.define("ntv-grid", NotiveGridElement); diff --git a/web/src/index.ts b/web/src/index.ts index ac4870c..6ab61c9 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -1,7 +1,8 @@ import Ratio from "./math/Ratio"; import { Cell, CellRef, Doc, Grid } from "./types"; -import { ActiveCellSelection, PendingSelection, Selection } from "./selection"; +import { ActiveCellSelection, Selection } from "./selection"; import renderGrid, { RenderedGrid } from "./components/grid/renderGrid"; +import cellAtCoord from "./components/grid/cellAtCoord"; function defaultDoc(): Doc { const defaultCells: Cell[] = Array.from({ length: 16 }, () => ({ @@ -12,7 +13,7 @@ function defaultDoc(): Doc { grids: [ { id: window.crypto.randomUUID(), - baseCellSize: 48, + baseCellSize: 42, baseCellWidthRatio: new Ratio(1, 16), parts: [ { @@ -24,7 +25,7 @@ function defaultDoc(): Doc { }, { id: window.crypto.randomUUID(), - baseCellSize: 48, + baseCellSize: 42, baseCellWidthRatio: new Ratio(1, 16), parts: [ { @@ -39,29 +40,59 @@ function defaultDoc(): Doc { } export default class Notive { - doc: Doc = defaultDoc(); + #doc: Doc = defaultDoc(); - gridsById = Object.fromEntries( - this.doc.grids.map((grid) => [grid.id, renderGrid(grid)]), + get doc() { + return this.#doc; + } + + #gridsById = Object.fromEntries( + this.#doc.grids.map((grid) => [grid.id, renderGrid(grid)]), ); - selection?: Selection; + getGrid(id: string): RenderedGrid | undefined { + return this.#gridsById[id]; + } - pendingSelection?: Selection; + #selection?: Selection; - getGrid(id: string): RenderedGrid | undefined { - return this.gridsById[id]; + get selection() { + return this.#selection; + } + + #pendingSelection?: Selection; + + get pendingSelection() { + return this.#pendingSelection; } selectCell(gridId: string, cellRef: CellRef) { - const previousSelection = this.selection; - this.selection = new ActiveCellSelection(gridId, cellRef); - - window.dispatchEvent( - new CustomEvent("ntv:selection-changed", { - detail: { selection: this.selection, previousSelection }, - }), - ); + this.#selection = new ActiveCellSelection(gridId, cellRef); + this.#dispatchSelectionChanged(); + } + + startSelecting(gridId: string, cellRef: CellRef) { + this.#pendingSelection = new ActiveCellSelection(gridId, cellRef); + this.#dispatchSelectionChanged(); + } + + extendSelection(cellRef: CellRef) { + const newSelection = this.pendingSelection?.extend(cellRef); + + if (newSelection !== this.pendingSelection) { + this.#pendingSelection = newSelection; + this.#dispatchSelectionChanged(); + } + } + + finishSelecting() { + this.#selection = this.pendingSelection; + this.#pendingSelection = undefined; + this.#dispatchSelectionChanged(); + } + + #dispatchSelectionChanged() { + window.dispatchEvent(new CustomEvent("ntv:selection-changed")); } } diff --git a/web/src/selection.ts b/web/src/selection.ts index 88d394b..3d18417 100644 --- a/web/src/selection.ts +++ b/web/src/selection.ts @@ -1,4 +1,4 @@ -import { CellRef } from "./types"; +import { CellRef, cellRefEquals } from "./types"; export abstract class Selection { readonly gridId: string; @@ -8,12 +8,45 @@ export abstract class Selection { this.gridId = gridId; this.activeCellRef = activeCellRef; } + + abstract extend(cellRef: CellRef): Selection; +} + +export class ActiveCellSelection extends Selection { + extend(cellRef: CellRef): Selection { + if (cellRefEquals(cellRef, this.activeCellRef)) { + return this; + } + + return new RangeSelection(this.gridId, this.activeCellRef, [ + this.activeCellRef, + cellRef, + ]); + } } -export class ActiveCellSelection extends Selection {} +export type CellRange = [CellRef, CellRef]; -export class RangeSelection extends Selection {} +export class RangeSelection extends Selection { + #range: CellRange; -export class AllSelection extends Selection {} + get range() { + return this.#range; + } -export class PendingSelection extends Selection {} + constructor(gridId: string, activeCellRef: CellRef, range: CellRange) { + super(gridId, activeCellRef); + this.#range = range; + } + + extend(cellRef: CellRef): Selection { + if (cellRefEquals(cellRef, this.activeCellRef)) { + return new ActiveCellSelection(this.gridId, cellRef); + } + + return new RangeSelection(this.gridId, this.activeCellRef, [ + this.#range[0], + cellRef, + ]); + } +} diff --git a/web/src/types.ts b/web/src/types.ts index 008d1ee..9b7a51a 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -35,3 +35,11 @@ export interface CellRef { rowIndex: number; cellIndex: number; } + +export function cellRefEquals(a: CellRef, b: CellRef): boolean { + return ( + a.partIndex === b.partIndex && + a.rowIndex === b.rowIndex && + a.cellIndex === b.cellIndex + ); +} |
