diff options
| -rw-r--r-- | web/src/components/app/index.ts | 40 | ||||
| -rw-r--r-- | web/src/components/grid/drawGrid.ts | 136 | ||||
| -rw-r--r-- | web/src/components/grid/drawSelection.ts | 97 | ||||
| -rw-r--r-- | web/src/components/grid/excursion.ts | 8 | ||||
| -rw-r--r-- | web/src/components/grid/index.css | 73 | ||||
| -rw-r--r-- | web/src/components/grid/index.ts | 223 | ||||
| -rw-r--r-- | web/src/defaultDoc.ts | 42 |
7 files changed, 381 insertions, 238 deletions
diff --git a/web/src/components/app/index.ts b/web/src/components/app/index.ts index ec40754..195011f 100644 --- a/web/src/components/app/index.ts +++ b/web/src/components/app/index.ts @@ -1,14 +1,44 @@ -import ntvGrid from "../grid"; +import defaultDoc from "../../defaultDoc"; +import { Selection } from "../../selection"; +import { Doc } from "../../types"; +import ntvGrid, { NotiveGridElement } from "../grid"; +import renderGrid from "../grid/renderGrid"; import ntvToolbar from "../toolbar"; import "./index.css"; -class NotiveAppElement extends HTMLElement { +export class NotiveAppElement extends HTMLElement { + doc: Doc = defaultDoc(); + #selection?: Selection; + + get selection() { + return this.#selection; + } + + set selection(selection: Selection | undefined) { + this.#selection = selection; + this.#updateGridSelections(); + } + + #updateGridSelections() { + this.querySelectorAll<NotiveGridElement>("ntv-grid").forEach((grid) => { + grid.selection = + this.#selection?.gridId === grid.grid?.id ? this.#selection : undefined; + }); + } + connectedCallback() { this.append( ntvToolbar(), - ...window.notive.doc.grids.map((grid) => { - return ntvGrid({ gridId: grid.id }); - }), + + ...this.doc.grids.map((grid) => + ntvGrid({ + grid: renderGrid(grid), + dataset: { gridId: grid.id }, + ongridselectionchange: (event) => { + this.selection = event.selection; + }, + }), + ), ); } } diff --git a/web/src/components/grid/drawGrid.ts b/web/src/components/grid/drawGrid.ts index 498abd5..da83c8e 100644 --- a/web/src/components/grid/drawGrid.ts +++ b/web/src/components/grid/drawGrid.ts @@ -2,33 +2,14 @@ import { RangeSelection, Selection } from "../../selection"; import { CellRef } from "../../types"; import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid"; -interface GridStyles { +export interface GridStyles { bgFill: string; borderStroke: string; cellStroke: string; - selelectionRangeFill: string; - selectionRangeStroke: string; - activeCellStroke: string; cellValueFont: string; cellValueLineHeight: string; } -export function getGridStyles(el: HTMLElement): GridStyles { - const style = window.getComputedStyle(el); - const prop = (k: string) => style.getPropertyValue(k); - - return { - bgFill: prop("--grid-bg-fill"), - borderStroke: prop("--grid-border-stroke"), - cellStroke: prop("--grid-cell-stroke"), - selelectionRangeFill: prop("--grid-selection-range-fill"), - selectionRangeStroke: prop("--grid-selection-range-stroke"), - activeCellStroke: prop("--grid-active-cell-stroke"), - cellValueFont: prop("font"), - cellValueLineHeight: prop("line-height"), - }; -} - function excursion(ctx: CanvasRenderingContext2D, f: () => void) { ctx.save(); f(); @@ -78,111 +59,6 @@ function strokeGridLines( }); } -function strokeActiveCell( - ctx: CanvasRenderingContext2D, - styles: GridStyles, - grid: RenderedGrid, - cell: RenderedCell, -) { - 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: GridStyles, - grid: RenderedGrid, - start: CellRef, - end: CellRef, - { stroke }: { stroke: boolean }, -) { - 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.selelectionRangeFill; - - 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, - ); -} - -function drawPendingSelection( - ctx: CanvasRenderingContext2D, - styles: GridStyles, - 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, styles, grid, selection.range[0], selection.range[1], { - stroke: false, - }); - }); - } - - excursion(ctx, () => strokeActiveCell(ctx, styles, grid, activeCell)); -} - -function drawSelection( - ctx: CanvasRenderingContext2D, - styles: GridStyles, - 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, styles, grid, selection.range[0], selection.range[1], { - stroke: true, - }); - }); - } - - excursion(ctx, () => strokeActiveCell(ctx, styles, grid, activeCell)); -} - function drawCellValues( ctx: CanvasRenderingContext2D, styles: GridStyles, @@ -207,19 +83,9 @@ export default function drawGrid( ctx: CanvasRenderingContext2D, styles: GridStyles, grid: RenderedGrid, - selection?: Selection, - pendingSelection?: Selection, ) { excursion(ctx, () => fillBackground(ctx, styles, grid)); excursion(ctx, () => strokeGridLines(ctx, styles, grid)); excursion(ctx, () => strokeGrid(ctx, styles, grid)); excursion(ctx, () => drawCellValues(ctx, styles, grid)); - - if (pendingSelection) { - excursion(ctx, () => - drawPendingSelection(ctx, styles, grid, pendingSelection), - ); - } else if (selection) { - excursion(ctx, () => drawSelection(ctx, styles, grid, selection)); - } } diff --git a/web/src/components/grid/drawSelection.ts b/web/src/components/grid/drawSelection.ts new file mode 100644 index 0000000..e1024a8 --- /dev/null +++ b/web/src/components/grid/drawSelection.ts @@ -0,0 +1,97 @@ +import { RangeSelection, Selection } from "../../selection"; +import { CellRef } from "../../types"; +import excursion from "./excursion"; +import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid"; + +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: Selection | 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 instanceof RangeSelection) { + drawCellRange(ctx, styles, grid, selection.range[0], selection.range[1], { + stroke: !pending, + }); + } + + strokeActiveCell(ctx, styles, grid, activeCell); +} diff --git a/web/src/components/grid/excursion.ts b/web/src/components/grid/excursion.ts new file mode 100644 index 0000000..7752df1 --- /dev/null +++ b/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/web/src/components/grid/index.css b/web/src/components/grid/index.css index 93d6f6f..64153ed 100644 --- a/web/src/components/grid/index.css +++ b/web/src/components/grid/index.css @@ -1,34 +1,49 @@ -ntv-grid { - display: block; - position: relative; +@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; -} + --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 { + display: block; + } -ntv-grid input[data-edit-cell] { - position: absolute; - vertical-align: baseline; - background: var(--color-neutral-800); - padding-right: 1px; - padding-bottom: 1px; - color: white; - text-align: center; -} + 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-cell] { + 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-cell]:focus-visible { - outline: none; + ntv-grid input[data-edit-cell]:focus-visible { + outline: none; + } } diff --git a/web/src/components/grid/index.ts b/web/src/components/grid/index.ts index 204ebea..2c00eb8 100644 --- a/web/src/components/grid/index.ts +++ b/web/src/components/grid/index.ts @@ -1,119 +1,189 @@ import h, { type CreateElement } from "../../html"; +import { ActiveCellSelection, Selection } from "../../selection"; import { CellRef } from "../../types"; import cellAtCoord from "./cellAtCoord"; -import drawGrid, { getGridStyles } from "./drawGrid"; +import drawGrid, { GridStyles } from "./drawGrid"; +import drawSelection, { SelectionStyles } from "./drawSelection"; import "./index.css"; -import { getRenderedCell } from "./renderGrid"; +import { getRenderedCell, RenderedGrid } from "./renderGrid"; -class NotiveGridElement extends HTMLElement { - #gridId!: string; +export class NotiveGridElement extends HTMLElement { + #internals: ElementInternals = this.attachInternals(); - get gridId() { - return this.#gridId; - } + grid?: RenderedGrid; + + #selection?: Selection; - set gridId(val: string) { - this.#gridId = val; - this.setAttribute("grid-id", val); + get selection() { + return this.#selection; } - get renderedGrid() { - return window.notive.getGrid(this.#gridId)!; + set selection(selection: Selection | undefined) { + this.#selection = selection; + this.drawSelection(); } - #canvasEl: HTMLCanvasElement = h.canvas(); + #ongridselectionchange?: ((event: GridSelectionEvent) => any) | undefined; - connectedCallback() { - if (!this.gridId) { - throw new Error("ntv-grid requries gridId attribute"); + get ongridselectionchange() { + return this.#ongridselectionchange; + } + + set ongridselectionchange( + handler: ((event: GridSelectionEvent) => any) | undefined, + ) { + if (this.#ongridselectionchange) { + this.removeEventListener( + "ntv:grid:selectionchange", + this.#ongridselectionchange, + ); } - window.addEventListener("ntv:selectionchange", () => this.draw()); - window.addEventListener("ntv:grid:change", () => this.draw()); + this.#ongridselectionchange = handler; - this.#canvasEl.addEventListener( - "mousedown", - this.#canvasMouseDownCallback.bind(this), - ); + if (handler) { + this.addEventListener("ntv:grid:selectionchange", handler); + } + } - this.#canvasEl.addEventListener( - "dblclick", - this.#canvasDoubleClickCallback.bind(this), - ); + canvas: HTMLCanvasElement = h.canvas({ + onmousedown: (event) => { + if (!this.grid) return; + const cellRef = this.#mouseEventCellRef(event); + if (!cellRef) return; + this.startSelecting(cellRef); + }, + }); - this.append(this.#canvasEl); + selectionCanvas: HTMLCanvasElement = h.canvas({ + dataset: { selection: "true" }, + }); + connectedCallback() { + this.append(this.canvas, this.selectionCanvas); this.draw(); + this.drawSelection(); } draw() { - const ctx = this.#canvasEl.getContext("2d"); + if (!this.grid) return; + + const ctx = this.canvas.getContext("2d"); if (!ctx) throw new Error("Unable to get canvas context"); - const grid = window.notive.getGrid(this.gridId); + this.canvas.setAttribute("width", this.grid.rect.width + "px"); + this.canvas.setAttribute("height", this.grid.rect.height + "px"); - if (!grid) return; + drawGrid(ctx, this.getGridStyles(), this.grid); + } - this.#canvasEl.setAttribute("width", grid.rect.width + "px"); - this.#canvasEl.setAttribute("height", grid.rect.height + "px"); + drawSelection() { + if (!this.grid) return; - const styles = getGridStyles(this); + const ctx = this.selectionCanvas.getContext("2d"); - drawGrid( + 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, - styles, - grid, - window.notive.selection, - window.notive.pendingSelection, + this.getSelectionStyles(), + this.grid, + this.#pendingSelection ?? this.selection, + { + pending: !!this.#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); + 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"), + }; } - #canvasMouseDownCallback(this: NotiveGridElement, event: MouseEvent) { - const cellRef = this.#mouseEventCellRef(event); + getSelectionStyles(): SelectionStyles { + const style = window.getComputedStyle(this); + const val = (k: string) => style.getPropertyValue(k); - if (!cellRef) return; + return { + activeCellStroke: val("--grid-active-cell-stroke"), + selectionRangeFill: val("--grid-selection-range-fill"), + selectionRangeStroke: val("--grid-selection-range-stroke"), + }; + } + + #pendingSelection?: Selection; + #selectionAbortController?: AbortController; - window.notive.startSelecting(this.gridId, cellRef); + 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", - this.#selectionMouseMoveCallback.bind(this), + (event) => { + const cellRef = this.#mouseEventCellRef(event); + if (!cellRef) return; + this.#pendingSelection = this.#pendingSelection?.extend(cellRef); + this.drawSelection(); + }, { signal }, ); window.addEventListener( "mouseup", - this.#selectionMouseUpCallback.bind(this), + () => { + this.#selectionAbortController?.abort(); + + if (!this.#pendingSelection) return; + + this.dispatchEvent( + new GridSelectionEvent( + "ntv:grid:selectionchange", + this.#pendingSelection, + ), + ); + + this.#pendingSelection = undefined; + this.drawSelection(); + }, { signal }, ); - } - #selectionAbortController?: AbortController; - - #selectionMouseMoveCallback(this: NotiveGridElement, event: MouseEvent) { - const cellRef = this.#mouseEventCellRef(event); - if (!cellRef) return; - window.notive.extendSelection(cellRef); + this.#pendingSelection = new ActiveCellSelection(this.grid.id, cellRef); + this.drawSelection(); } - #selectionMouseUpCallback(this: NotiveGridElement, event: MouseEvent) { - this.#selectionAbortController?.abort(); - this.#selectionAbortController = undefined; - window.notive.finishSelecting(); + #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; @@ -137,15 +207,13 @@ class NotiveGridElement extends HTMLElement { }); #canvasDoubleClickCallback(this: NotiveGridElement, event: MouseEvent) { + if (!this.grid) return; + const cellRef = this.#mouseEventCellRef(event); if (!cellRef) return; - const grid = window.notive.getGrid(this.gridId); - - if (!grid) return; - - const cell = getRenderedCell(grid, cellRef); + const cell = getRenderedCell(this.grid, cellRef); if (!cell) return; @@ -168,8 +236,10 @@ class NotiveGridElement extends HTMLElement { #finishEditing() { this.#editInputEl.remove(); + if (!this.grid) return; + window.notive.setCellValue( - this.gridId, + this.grid.id, this.#editingCellRef!, this.#editInputEl.value, ); @@ -180,3 +250,18 @@ customElements.define("ntv-grid", NotiveGridElement); export default ((...args: any[]): NotiveGridElement => (h as any)["ntv-grid"](...args)) as CreateElement<NotiveGridElement>; + +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; + } +} diff --git a/web/src/defaultDoc.ts b/web/src/defaultDoc.ts new file mode 100644 index 0000000..7409c1a --- /dev/null +++ b/web/src/defaultDoc.ts @@ -0,0 +1,42 @@ +import Ratio from "./math/Ratio"; +import { Cell, Doc } from "./types"; + +export default function defaultDoc(): Doc { + const defaultCells: Cell[] = Array.from({ length: 16 }, () => ({ + widthRatio: new Ratio(1, 16), + })); + + return { + grids: [ + { + id: window.crypto.randomUUID(), + baseCellSize: 42, + baseCellWidthRatio: new Ratio(1, 16), + parts: [ + { + rows: Array.from({ length: 4 }, () => ({ + cells: [...defaultCells], + })), + }, + ], + }, + { + id: window.crypto.randomUUID(), + baseCellSize: 42, + baseCellWidthRatio: new Ratio(1, 16), + parts: [ + { + rows: Array.from({ length: 2 }, () => ({ + cells: [...defaultCells], + })), + }, + { + rows: Array.from({ length: 2 }, () => ({ + cells: [...defaultCells], + })), + }, + ], + }, + ], + }; +} |
