From 715996aceb4d4dc96410464f60727b98a289be08 Mon Sep 17 00:00:00 2001 From: Josh Kingsley Date: Thu, 30 Oct 2025 13:25:02 +0200 Subject: refactor(web): move selection code --- web/src/components/app/index.ts | 17 +++-- web/src/components/grid/drawSelection.ts | 6 +- web/src/components/grid/index.ts | 87 +++++++++++----------- web/src/components/grid/selection.ts | 23 ++++++ web/src/components/toolbar/index.css | 78 ++++++++++---------- web/src/components/toolbar/index.ts | 123 +++---------------------------- web/src/global.d.ts | 9 --- web/src/html.ts | 14 +++- web/src/index.html | 4 +- web/src/index.ts | 4 +- web/src/selection.ts | 123 ------------------------------- web/src/types.ts | 1 - 12 files changed, 144 insertions(+), 345 deletions(-) create mode 100644 web/src/components/grid/selection.ts delete mode 100644 web/src/global.d.ts delete mode 100644 web/src/selection.ts (limited to 'web/src') diff --git a/web/src/components/app/index.ts b/web/src/components/app/index.ts index c30249f..aa7c738 100644 --- a/web/src/components/app/index.ts +++ b/web/src/components/app/index.ts @@ -1,22 +1,21 @@ import defaultDoc from "../../defaultDoc"; import NotiveElement, { customElement } from "../../element"; -import { Selection } from "../../selection"; import { Doc } from "../../types"; import ntvGrid, { NotiveGridElement } from "../grid"; import renderGrid from "../grid/renderGrid"; +import { GridSelection } from "../grid/selection"; import ntvToolbar from "../toolbar"; import "./index.css"; @customElement("ntv-app") export class NotiveAppElement extends NotiveElement { doc: Doc = defaultDoc(); - #selection?: Selection; - get selection() { - return this.#selection; - } + #selectedGridId?: string; + #selection?: GridSelection; - set selection(selection: Selection | undefined) { + setSelection(gridId: string, selection: GridSelection) { + this.#selectedGridId = gridId; this.#selection = selection; this.#updateGridSelections(); } @@ -24,7 +23,7 @@ export class NotiveAppElement extends NotiveElement { #updateGridSelections() { this.querySelectorAll("ntv-grid").forEach((grid) => { grid.selection = - this.#selection?.gridId === grid.grid?.id ? this.#selection : undefined; + this.#selectedGridId === grid.grid?.id ? this.#selection : undefined; }); } @@ -37,7 +36,7 @@ export class NotiveAppElement extends NotiveElement { grid: renderGrid(grid), dataset: { gridId: grid.id }, ongridselectionchange: (event) => { - this.selection = event.selection; + this.setSelection(grid.id, event.selection); }, oncellchange: (event) => { console.log(event); @@ -47,3 +46,5 @@ export class NotiveAppElement extends NotiveElement { ); } } + +export default NotiveAppElement.makeFactory(); diff --git a/web/src/components/grid/drawSelection.ts b/web/src/components/grid/drawSelection.ts index e1024a8..1b8c2ed 100644 --- a/web/src/components/grid/drawSelection.ts +++ b/web/src/components/grid/drawSelection.ts @@ -1,7 +1,7 @@ -import { RangeSelection, Selection } from "../../selection"; import { CellRef } from "../../types"; import excursion from "./excursion"; import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid"; +import { GridSelection } from "./selection"; export interface SelectionStyles { activeCellStroke: string; @@ -76,7 +76,7 @@ export default function drawSelection( ctx: CanvasRenderingContext2D, styles: SelectionStyles, grid: RenderedGrid, - selection: Selection | undefined, + selection: GridSelection | undefined, { pending }: { pending: boolean }, ) { ctx.clearRect(0, 0, grid.rect.width, grid.rect.height); @@ -87,7 +87,7 @@ export default function drawSelection( if (!activeCell) return; - if (selection instanceof RangeSelection) { + if (selection.range) { drawCellRange(ctx, styles, grid, selection.range[0], selection.range[1], { stroke: !pending, }); diff --git a/web/src/components/grid/index.ts b/web/src/components/grid/index.ts index 6c7f735..78bb14e 100644 --- a/web/src/components/grid/index.ts +++ b/web/src/components/grid/index.ts @@ -1,12 +1,12 @@ import NotiveElement, { customElement, eventHandler } from "../../element"; import h 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"; +import { extendSelection, GridSelection } from "./selection"; @customElement("ntv-grid") export class NotiveGridElement extends NotiveElement { @@ -14,25 +14,26 @@ export class NotiveGridElement extends NotiveElement { grid?: RenderedGrid; - #selection?: Selection; + #selection?: GridSelection; get selection() { return this.#selection; } - set selection(selection: Selection | undefined) { + set selection(selection: GridSelection | undefined) { this.#selection = selection; this.drawSelection(); } @eventHandler("ntv:grid:selectionchange") - ongridselectionchange?: (event: GridSelectionEvent) => any; + 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; @@ -114,7 +115,7 @@ export class NotiveGridElement extends NotiveElement { }; } - #pendingSelection?: Selection; + #pendingSelection?: GridSelection; #selectionAbortController?: AbortController; startSelecting(cellRef: CellRef) { @@ -123,12 +124,6 @@ export class NotiveGridElement extends NotiveElement { 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( @@ -136,33 +131,43 @@ export class NotiveGridElement extends NotiveElement { (event) => { const cellRef = this.#mouseEventCellRef(event); if (!cellRef) return; - this.#pendingSelection = this.#pendingSelection?.extend(cellRef); + this.#pendingSelection = extendSelection( + this.#pendingSelection, + cellRef, + ); this.drawSelection(); }, { signal }, ); - window.addEventListener( - "mouseup", - () => { - this.#selectionAbortController?.abort(); - - if (!this.#pendingSelection) return; - - this.dispatchEvent( - new GridSelectionEvent( - "ntv:grid:selectionchange", - this.#pendingSelection, - ), - ); + window.addEventListener("mouseup", () => this.#finishSelecting(), { + signal, + }); - this.#pendingSelection = undefined; - this.drawSelection(); + window.addEventListener( + "keydown", + (event) => { + event.preventDefault(); + if (event.key === "Escape") { + this.#pendingSelection = undefined; + this.#finishSelecting(); + } }, { signal }, ); - this.#pendingSelection = new ActiveCellSelection(this.grid.id, cellRef); + 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(); } @@ -235,30 +240,28 @@ export class NotiveGridElement extends NotiveElement { export default NotiveGridElement.makeFactory(); -export class GridSelectionEvent extends Event { - selection: Selection; +export class GridSelectionChangeEvent extends Event { + static readonly TYPE = "ntv:grid:selectionchange"; - constructor(type: string, selection: Selection) { - super(type); - this.selection = selection; + constructor(public selection: GridSelection) { + super(GridSelectionChangeEvent.TYPE); } } export class GridCellChangeEvent extends Event { - cellRef: CellRef; - value?: string; - - constructor(cellRef: CellRef, value: string | undefined) { - super("ntv:grid:cellchange"); + static readonly TYPE = "ntv:grid:cellchange"; - this.cellRef = cellRef; - this.value = value; + constructor( + public cellRef: CellRef, + public value: string | undefined, + ) { + super(GridCellChangeEvent.TYPE); } } declare global { interface HTMLElementEventMap { - "ntv:grid:selectionchange": GridSelectionEvent; - "ntv:grid:cellchange": GridCellChangeEvent; + [GridSelectionChangeEvent.TYPE]: GridSelectionChangeEvent; + [GridCellChangeEvent.TYPE]: GridCellChangeEvent; } } diff --git a/web/src/components/grid/selection.ts b/web/src/components/grid/selection.ts new file mode 100644 index 0000000..a24bbf5 --- /dev/null +++ b/web/src/components/grid/selection.ts @@ -0,0 +1,23 @@ +import { CellRef, cellRefEquals } from "../../types"; + +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] }; +} diff --git a/web/src/components/toolbar/index.css b/web/src/components/toolbar/index.css index b580fbf..e082f7d 100644 --- a/web/src/components/toolbar/index.css +++ b/web/src/components/toolbar/index.css @@ -1,44 +1,46 @@ -ntv-toolbar { - display: flex; - border-radius: 99999px; - background: var(--color-neutral-900); - width: min-content; -} +@layer components { + ntv-toolbar { + display: flex; + border-radius: 99999px; + background: var(--color-neutral-900); + width: min-content; + } -ntv-toolbar > section { - display: flex; - gap: 0.25rem; - padding: 0.325rem; -} + ntv-toolbar > section { + display: flex; + gap: 0.25rem; + padding: 0.325rem; + } -ntv-toolbar button { - border-radius: 99999px; - background: var(--color-neutral-800); - padding: 0 0.5rem; - height: 1.25rem; - color: white; - font-size: 0.75rem; -} + ntv-toolbar button { + border-radius: 99999px; + background: var(--color-neutral-800); + padding: 0 0.5rem; + height: 1.25rem; + color: white; + font-size: 0.75rem; + } -ntv-toolbar button:hover { - background: var(--color-neutral-700); -} + ntv-toolbar button:hover { + background: var(--color-green-400); + } -ntv-toolbar button[data-variant="icon"] { - display: flex; - justify-content: center; - align-items: center; - aspect-ratio: 1; - height: 1.25rem; -} + ntv-toolbar button[data-icon] { + display: flex; + justify-content: center; + align-items: center; + aspect-ratio: 1; + height: 1.25rem; + } -ntv-toolbar input { - border: 1px solid var(--color-neutral-700); - border-radius: 4px; - background: var(--color-neutral-900); - width: 2.5rem; - height: 1.25rem; - color: white; - font-size: 0.75rem; - text-align: center; + ntv-toolbar input { + border: 1px solid var(--color-neutral-700); + border-radius: 4px; + background: var(--color-neutral-900); + width: 2.5rem; + height: 1.25rem; + color: white; + font-size: 0.75rem; + text-align: center; + } } diff --git a/web/src/components/toolbar/index.ts b/web/src/components/toolbar/index.ts index 84f6a65..da4b69d 100644 --- a/web/src/components/toolbar/index.ts +++ b/web/src/components/toolbar/index.ts @@ -1,131 +1,28 @@ import NotiveElement, { customElement } from "../../element"; -import h, { CreateElement } from "../../html"; -import { ActiveCellSelection, RangeSelection } from "../../selection"; +import h, { fragment } from "../../html"; import "./index.css"; -function getSelectedSubdivisionsCount(): number | undefined { - const selection = window.notive.selection; - - if (!selection) return; - - if (selection instanceof ActiveCellSelection) { - return 1; - } - - if (!(selection instanceof RangeSelection)) return; - - const grid = window.notive.getGrid(selection.gridId); - - if (!grid) return; - - const selectedCells = selection.getSelectedCells(grid); - - return Math.min(...selectedCells.map((cells) => cells.length)); -} - @customElement("ntv-toolbar") class NotiveToolbarElement extends NotiveElement { + connectedCallback() { + this.append(this.#view()); + } + #subdivisionsInputEl: HTMLInputElement = h.input({ title: "Subdivisions", - placeholder: "-", disabled: true, }); - connectedCallback() { - this.#render(); - - window.addEventListener("ntv:selectionchange", () => { - if (window.notive.pendingSelection) { - this.#subdivisionsInputEl.disabled = true; - this.#subdivisionsInputEl.value = ""; - return; - } - - const subdivisionsCount = getSelectedSubdivisionsCount(); - - if (!subdivisionsCount) { - this.#subdivisionsInputEl.disabled = true; - this.#subdivisionsInputEl.value = ""; - return; - } - - this.#subdivisionsInputEl.disabled = false; - this.#subdivisionsInputEl.value = subdivisionsCount.toString(); - }); - - this.#subdivisionsInputEl.addEventListener("change", () => { - window.notive.subdivideSelection( - parseInt(this.#subdivisionsInputEl.value), - ); - }); - } - - #render() { - this.append( + #view() { + return fragment( h.section( - h.button( - { - dataset: { variant: "icon" }, - onclick: () => { - const subdivisions = Math.max( - 1, - parseInt(this.#subdivisionsInputEl.value) - 1, - ); - - this.#subdivisionsInputEl.value = subdivisions.toString(); - window.notive.subdivideSelection(subdivisions); - }, - }, - "-", - ), + h.button({ dataset: { icon: "" } }, "-"), this.#subdivisionsInputEl, - h.button( - { - dataset: { variant: "icon" }, - - onclick: () => { - const subdivisions = - parseInt(this.#subdivisionsInputEl.value) + 1; - - this.#subdivisionsInputEl.value = subdivisions.toString(); - window.notive.subdivideSelection(subdivisions); - }, - }, - "+", - ), - ), - h.section( - h.button( - { - dataset: { variant: "menu" }, - onclick: () => { - this.#play(); - }, - }, - "Play", - ), + h.button({ dataset: { icon: "" } }, "+"), ), + h.section(h.button("Play")), ); } - - async #play() { - const Tone = await import("tone"); - await Tone.start(); - const transport = Tone.getTransport(); - transport.stop(); - transport.position = 0; - transport.cancel(); - const synth = new Tone.Synth().toDestination(); - const seq = new Tone.Sequence( - (time, note) => { - synth.triggerAttackRelease(note, 0.1, time); - }, - ["C4", "D4", "E4"], - "1m", - ).start(0); - seq.loop = false; - transport.start(); - } } export default NotiveToolbarElement.makeFactory(); diff --git a/web/src/global.d.ts b/web/src/global.d.ts deleted file mode 100644 index 3b6d980..0000000 --- a/web/src/global.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type Notive from "./index"; - -declare global { - interface Window { - notive: Notive; - } -} - -export {}; diff --git a/web/src/html.ts b/web/src/html.ts index a118849..3fccda3 100644 --- a/web/src/html.ts +++ b/web/src/html.ts @@ -1,16 +1,16 @@ export function createElement( - tag: string, + tagName: string, ...children: (Node | string)[] ): T; export function createElement( - tag: string, + tagName: string, attrs: Partial, ...children: (Node | string)[] ): T; -export function createElement(tag: string, ...args: any[]) { - const el = document.createElement(tag); +export function createElement(tagName: string, ...args: any[]) { + const el = document.createElement(tagName); if (args[0]?.constructor === Object) { const { dataset, style, ...attrs } = args.shift(); @@ -42,3 +42,9 @@ const h = new Proxy({} as ElementCreator, { }); export default h; + +export function fragment(...children: (Node | string)[]): DocumentFragment { + const fragment = document.createDocumentFragment(); + fragment.append(...children); + return fragment; +} diff --git a/web/src/index.html b/web/src/index.html index a9cd398..9f8bcbf 100644 --- a/web/src/index.html +++ b/web/src/index.html @@ -7,7 +7,5 @@ - - - + diff --git a/web/src/index.ts b/web/src/index.ts index 4c30907..7842326 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -1 +1,3 @@ -import "./components"; +import ntvApp from "./components/app"; + +document.body.append(ntvApp()); diff --git a/web/src/selection.ts b/web/src/selection.ts deleted file mode 100644 index abaf8b5..0000000 --- a/web/src/selection.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - getRenderedCell, - RenderedCell, - RenderedGrid, -} from "./components/grid/renderGrid"; -import { CellRef, cellRefEquals } from "./types"; - -export abstract class Selection { - readonly gridId: string; - readonly activeCellRef: CellRef; - - constructor(gridId: string, activeCellRef: CellRef) { - this.gridId = gridId; - this.activeCellRef = activeCellRef; - } - - abstract extend(cellRef: CellRef): Selection; - - abstract getSelectedCells(grid: RenderedGrid): RenderedCell[][]; - - abstract startCellRef(): CellRef; - abstract endCellRef(): CellRef; -} - -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, - ]); - } - - getSelectedCells(grid: RenderedGrid): RenderedCell[][] { - return [[getRenderedCell(grid, this.activeCellRef)!]]; - } - - startCellRef(): CellRef { - return this.activeCellRef; - } - - endCellRef(): CellRef { - return this.activeCellRef; - } -} - -export type CellRange = [CellRef, CellRef]; - -export class RangeSelection extends Selection { - #range: CellRange; - - get range() { - return this.#range; - } - - 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, - ]); - } - - getSelectedCells(grid: RenderedGrid): RenderedCell[][] { - const startCell = getRenderedCell(grid, this.range[0]); - const endCell = getRenderedCell(grid, this.range[1]); - - if (!startCell || !endCell) return []; - - const firstRowIndex = Math.min( - startCell.renderedRowIndex, - endCell.renderedRowIndex, - ); - - const lastRowIndex = Math.max( - startCell.renderedRowIndex, - endCell.renderedRowIndex, - ); - - const startRatio = - startCell.startRatio.compare(endCell.startRatio) <= 0 - ? startCell.startRatio - : endCell.startRatio; - - const endRatio = - startCell.endRatio.compare(endCell.endRatio) >= 0 - ? startCell.endRatio - : endCell.endRatio; - - return grid.renderedRows - .slice(firstRowIndex, lastRowIndex + 1) - .map((row) => { - const firstCellIndex = row.renderedCells.findIndex( - (cell) => cell.startRatio.compare(startRatio) >= 0, - ); - - const lastCellIndex = row.renderedCells.findLastIndex( - (cell) => cell.endRatio.compare(endRatio) <= 0, - ); - - return row.renderedCells.slice(firstCellIndex, lastCellIndex + 1); - }); - } - - startCellRef(): CellRef { - return this.range[0]; - } - - endCellRef(): CellRef { - return this.range[1]; - } -} diff --git a/web/src/types.ts b/web/src/types.ts index 8b1b650..b41bb9a 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -30,7 +30,6 @@ export interface RowRef { rowIndex: number; } -// TODO Should probably have a gridId export interface CellRef { partIndex: number; rowIndex: number; -- cgit v1.2.3