summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJosh Kingsley <josh@joshkingsley.me>2025-10-29 18:26:41 +0200
committerJosh Kingsley <josh@joshkingsley.me>2025-10-29 18:26:41 +0200
commit7ef8366bfc43775bf26e71e77bddf31af829dfde (patch)
tree38f2551d3676838df5e35c97e5678f89fd75a56f
parent986e65f9ab7122995ae1d647df23d817cecf6816 (diff)
refactor(web): add decorators
-rw-r--r--web/src/components/app/index.ts9
-rw-r--r--web/src/components/grid/index.css4
-rw-r--r--web/src/components/grid/index.ts99
-rw-r--r--web/src/components/toolbar/index.ts10
-rw-r--r--web/src/element.ts45
-rw-r--r--web/src/html.ts44
-rw-r--r--web/src/index.html1
-rw-r--r--web/src/index.ts215
-rw-r--r--web/tsconfig.json2
9 files changed, 135 insertions, 294 deletions
diff --git a/web/src/components/app/index.ts b/web/src/components/app/index.ts
index 195011f..c30249f 100644
--- a/web/src/components/app/index.ts
+++ b/web/src/components/app/index.ts
@@ -1,4 +1,5 @@
import defaultDoc from "../../defaultDoc";
+import NotiveElement, { customElement } from "../../element";
import { Selection } from "../../selection";
import { Doc } from "../../types";
import ntvGrid, { NotiveGridElement } from "../grid";
@@ -6,7 +7,8 @@ import renderGrid from "../grid/renderGrid";
import ntvToolbar from "../toolbar";
import "./index.css";
-export class NotiveAppElement extends HTMLElement {
+@customElement("ntv-app")
+export class NotiveAppElement extends NotiveElement {
doc: Doc = defaultDoc();
#selection?: Selection;
@@ -37,10 +39,11 @@ export class NotiveAppElement extends HTMLElement {
ongridselectionchange: (event) => {
this.selection = event.selection;
},
+ oncellchange: (event) => {
+ console.log(event);
+ },
}),
),
);
}
}
-
-customElements.define("ntv-app", NotiveAppElement);
diff --git a/web/src/components/grid/index.css b/web/src/components/grid/index.css
index 64153ed..c29f55d 100644
--- a/web/src/components/grid/index.css
+++ b/web/src/components/grid/index.css
@@ -33,7 +33,7 @@
display: none;
}
- ntv-grid input[data-edit-cell] {
+ ntv-grid input[data-edit] {
position: absolute;
vertical-align: baseline;
background: var(--color-neutral-800);
@@ -43,7 +43,7 @@
text-align: center;
}
- ntv-grid input[data-edit-cell]:focus-visible {
+ ntv-grid input[data-edit]:focus-visible {
outline: none;
}
}
diff --git a/web/src/components/grid/index.ts b/web/src/components/grid/index.ts
index 2c00eb8..6c7f735 100644
--- a/web/src/components/grid/index.ts
+++ b/web/src/components/grid/index.ts
@@ -1,4 +1,5 @@
-import h, { type CreateElement } from "../../html";
+import NotiveElement, { customElement, eventHandler } from "../../element";
+import h from "../../html";
import { ActiveCellSelection, Selection } from "../../selection";
import { CellRef } from "../../types";
import cellAtCoord from "./cellAtCoord";
@@ -7,7 +8,8 @@ import drawSelection, { SelectionStyles } from "./drawSelection";
import "./index.css";
import { getRenderedCell, RenderedGrid } from "./renderGrid";
-export class NotiveGridElement extends HTMLElement {
+@customElement("ntv-grid")
+export class NotiveGridElement extends NotiveElement {
#internals: ElementInternals = this.attachInternals();
grid?: RenderedGrid;
@@ -23,28 +25,11 @@ export class NotiveGridElement extends HTMLElement {
this.drawSelection();
}
- #ongridselectionchange?: ((event: GridSelectionEvent) => any) | undefined;
+ @eventHandler("ntv:grid:selectionchange")
+ ongridselectionchange?: (event: GridSelectionEvent) => any;
- 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);
- }
- }
+ @eventHandler("ntv:grid:cellchange")
+ oncellchange?: (event: GridCellChangeEvent) => any;
canvas: HTMLCanvasElement = h.canvas({
onmousedown: (event) => {
@@ -53,6 +38,12 @@ export class NotiveGridElement extends HTMLElement {
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({
@@ -188,11 +179,9 @@ export class NotiveGridElement extends HTMLElement {
#editingCellRef?: CellRef;
- #editInputEl: HTMLInputElement = h.input({
- dataset: { editCell: "true" },
- onblur: () => {
- this.#finishEditing();
- },
+ #editInput: HTMLInputElement = h.input({
+ dataset: { edit: "true" },
+ onblur: () => this.#finishEditing(),
onkeydown: (event) => {
switch (event.key) {
case "Enter":
@@ -206,50 +195,45 @@ export class NotiveGridElement extends HTMLElement {
},
});
- #canvasDoubleClickCallback(this: NotiveGridElement, event: MouseEvent) {
+ startEditing(cellRef: CellRef) {
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.append(this.#editInput);
- 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();
+ 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.#editInputEl.remove();
+ this.#editInput.remove();
}
#finishEditing() {
- this.#editInputEl.remove();
+ this.#editInput.remove();
- if (!this.grid) return;
+ if (!this.grid || !this.#editingCellRef) return;
- window.notive.setCellValue(
- this.grid.id,
- this.#editingCellRef!,
- this.#editInputEl.value,
+ this.dispatchEvent(
+ new GridCellChangeEvent(this.#editingCellRef, this.#editInput.value),
);
}
}
-customElements.define("ntv-grid", NotiveGridElement);
-
-export default ((...args: any[]): NotiveGridElement =>
- (h as any)["ntv-grid"](...args)) as CreateElement<NotiveGridElement>;
+export default NotiveGridElement.makeFactory();
export class GridSelectionEvent extends Event {
selection: Selection;
@@ -260,8 +244,21 @@ export class GridSelectionEvent extends Event {
}
}
+export class GridCellChangeEvent extends Event {
+ cellRef: CellRef;
+ value?: string;
+
+ constructor(cellRef: CellRef, value: string | undefined) {
+ super("ntv:grid:cellchange");
+
+ this.cellRef = cellRef;
+ this.value = value;
+ }
+}
+
declare global {
interface HTMLElementEventMap {
"ntv:grid:selectionchange": GridSelectionEvent;
+ "ntv:grid:cellchange": GridCellChangeEvent;
}
}
diff --git a/web/src/components/toolbar/index.ts b/web/src/components/toolbar/index.ts
index 1400174..84f6a65 100644
--- a/web/src/components/toolbar/index.ts
+++ b/web/src/components/toolbar/index.ts
@@ -1,6 +1,6 @@
+import NotiveElement, { customElement } from "../../element";
import h, { CreateElement } from "../../html";
import { ActiveCellSelection, RangeSelection } from "../../selection";
-import { RenderedGrid } from "../grid/renderGrid";
import "./index.css";
function getSelectedSubdivisionsCount(): number | undefined {
@@ -23,7 +23,8 @@ function getSelectedSubdivisionsCount(): number | undefined {
return Math.min(...selectedCells.map((cells) => cells.length));
}
-class NotiveToolbarElement extends HTMLElement {
+@customElement("ntv-toolbar")
+class NotiveToolbarElement extends NotiveElement {
#subdivisionsInputEl: HTMLInputElement = h.input({
title: "Subdivisions",
placeholder: "-",
@@ -127,7 +128,4 @@ class NotiveToolbarElement extends HTMLElement {
}
}
-customElements.define("ntv-toolbar", NotiveToolbarElement);
-
-export default ((...args: any[]): NotiveToolbarElement =>
- (h as any)["ntv-toolbar"](...args)) as CreateElement<NotiveToolbarElement>;
+export default NotiveToolbarElement.makeFactory();
diff --git a/web/src/element.ts b/web/src/element.ts
new file mode 100644
index 0000000..6299d2f
--- /dev/null
+++ b/web/src/element.ts
@@ -0,0 +1,45 @@
+import { createElement, type CreateElement } from "./html";
+
+export default class NotiveElement extends HTMLElement {
+ static makeFactory<T extends NotiveElement>(this: {
+ new (): T;
+ }): CreateElement<T>;
+
+ static makeFactory(): any {
+ throw new Error(
+ "Missing makeFactory implementation. Did you forget to use @customElement?",
+ );
+ }
+}
+
+export function customElement(tagName: string) {
+ return function (_value: unknown, context: ClassDecoratorContext) {
+ context.addInitializer(function () {
+ window.customElements.define(tagName, this as typeof NotiveElement);
+ (this as typeof NotiveElement).makeFactory = () =>
+ ((...args: any[]) => createElement(tagName, ...args)) as CreateElement<any>;
+ });
+ };
+}
+
+export function eventHandler(eventName: string) {
+ return function (_value: unknown, context: ClassFieldDecoratorContext) {
+ const privateKey = Symbol(context.name.toString());
+
+ context.addInitializer(function () {
+ Object.defineProperty(this, context.name, {
+ get() {
+ return this[privateKey];
+ },
+ set(handler) {
+ const oldHandler = this[privateKey];
+ if (oldHandler) this.removeEventListener(eventName, oldHandler);
+ this[privateKey] = handler;
+ if (handler) this.addEventListener(eventName, handler);
+ },
+ enumerable: true,
+ configurable: true,
+ });
+ });
+ };
+}
diff --git a/web/src/html.ts b/web/src/html.ts
index 8802e50..a118849 100644
--- a/web/src/html.ts
+++ b/web/src/html.ts
@@ -1,3 +1,29 @@
+export function createElement<T extends HTMLElement>(
+ tag: string,
+ ...children: (Node | string)[]
+): T;
+
+export function createElement<T extends HTMLElement>(
+ tag: string,
+ attrs: Partial<T>,
+ ...children: (Node | string)[]
+): T;
+
+export function createElement(tag: string, ...args: any[]) {
+ const el = document.createElement(tag);
+
+ if (args[0]?.constructor === Object) {
+ const { dataset, style, ...attrs } = args.shift();
+ Object.assign(el, attrs);
+ if (dataset) Object.assign(el.dataset, dataset);
+ if (style) Object.assign(el.style, style);
+ }
+
+ el.append(...args.flat());
+
+ return el;
+}
+
export type CreateElement<T extends HTMLElement> = {
(...children: (Node | string)[]): T;
(attrs: Partial<T>, ...children: (Node | string)[]): T;
@@ -9,23 +35,9 @@ type ElementCreator = {
const h = new Proxy({} as ElementCreator, {
get:
- (_, tag: string) =>
+ (_, tagName: string) =>
(...args: any[]) => {
- const el = document.createElement(tag);
-
- if (args[0]?.constructor === Object) {
- const { dataset, ...attrs } = args.shift();
-
- Object.assign(el, attrs);
-
- if (dataset) {
- Object.assign(el.dataset, dataset);
- }
- }
-
- el.append(...args.flat());
-
- return el;
+ return createElement(tagName, ...args);
},
});
diff --git a/web/src/index.html b/web/src/index.html
index b296f52..a9cd398 100644
--- a/web/src/index.html
+++ b/web/src/index.html
@@ -6,7 +6,6 @@
<link rel="stylesheet" href="index.css" />
<link rel="icon" href="favicon.ico" />
<script type="module" src="index.ts"></script>
- <script type="module" src="components/index.ts"></script>
</head>
<body>
<ntv-app></ntv-app>
diff --git a/web/src/index.ts b/web/src/index.ts
index 97cfdf8..4c30907 100644
--- a/web/src/index.ts
+++ b/web/src/index.ts
@@ -1,214 +1 @@
-import Ratio from "./math/Ratio";
-import { Cell, CellRef, Doc, Grid, mapRowsInRange } from "./types";
-import { ActiveCellSelection, Selection } from "./selection";
-import renderGrid, {
- getRenderedCell,
- RenderedGrid,
-} from "./components/grid/renderGrid";
-
-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],
- })),
- },
- ],
- },
- ],
- };
-}
-
-export default class Notive {
- #doc: Doc = defaultDoc();
-
- get doc() {
- return this.#doc;
- }
-
- #gridsById = Object.fromEntries(
- this.#doc.grids.map((grid) => [grid.id, renderGrid(grid)]),
- );
-
- getGrid(id: string): RenderedGrid | undefined {
- return this.#gridsById[id];
- }
-
- #selection?: Selection;
-
- get selection() {
- return this.#selection;
- }
-
- #pendingSelection?: Selection;
-
- get pendingSelection() {
- return this.#pendingSelection;
- }
-
- selectCell(gridId: string, cellRef: CellRef) {
- 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:selectionchange"));
- }
-
- subdivideSelection(subdivisions: number) {
- const selection = this.selection;
- if (!selection) return;
-
- const grid = this.getGrid(selection.gridId);
- if (!grid) return;
-
- const startCellRef = selection.startCellRef();
- const endCellRef = selection.endCellRef();
-
- const startCell = getRenderedCell(grid, startCellRef);
- const endCell = getRenderedCell(grid, endCellRef);
- if (!startCell || !endCell) return;
-
- const startRatio =
- startCell.startRatio.compare(endCell.startRatio) <= 0
- ? startCell.startRatio
- : endCell.startRatio;
-
- const endRatio =
- startCell.endRatio.compare(endCell.endRatio) >= 0
- ? startCell.endRatio
- : endCell.endRatio;
-
- const totalWidth = endRatio.subtract(startRatio);
- const subdivisionWidth = totalWidth.divideRatio(
- Ratio.fromInteger(subdivisions),
- );
-
- const newDoc = mapRowsInRange(
- this.doc,
- selection.gridId,
- startCellRef,
- endCellRef,
- (row, rowRef) => {
- const newCells: Cell[] = [];
- let currentRatio = Ratio.fromInteger(0);
-
- for (const cell of row.cells) {
- const cellStart = currentRatio;
- const cellEnd = currentRatio.add(cell.widthRatio);
-
- // Cell is entirely before selection
- if (cellEnd.compare(startRatio) <= 0) {
- newCells.push(cell);
- currentRatio = cellEnd;
- continue;
- }
-
- // Cell is entirely after selection
- if (cellStart.compare(endRatio) >= 0) {
- newCells.push(cell);
- currentRatio = cellEnd;
- continue;
- }
-
- // First cell that overlaps - insert subdivisions
- if (newCells.length === 0 || currentRatio.compare(startRatio) < 0) {
- for (let i = 0; i < subdivisions; i++) {
- newCells.push({ widthRatio: subdivisionWidth });
- }
- }
-
- currentRatio = cellEnd;
- }
-
- return { ...row, cells: newCells };
- },
- );
-
- this.#doc = newDoc;
-
- this.#gridsById = Object.fromEntries(
- this.#doc.grids.map((grid) => [grid.id, renderGrid(grid)]),
- );
-
- window.dispatchEvent(
- new CustomEvent("ntv:grid:change", {
- detail: { gridId: selection.gridId },
- }),
- );
- }
-
- setCellValue(gridId: string, cellRef: CellRef, value: string | undefined) {
- const grid = this.doc.grids.find((grid) => grid.id === gridId);
-
- if (!grid) return;
-
- const cell =
- grid.parts[cellRef.partIndex].rows[cellRef.rowIndex].cells[
- cellRef.cellIndex
- ];
-
- grid.parts[cellRef.partIndex].rows[cellRef.rowIndex].cells[
- cellRef.cellIndex
- ] = { ...cell, value };
-
- this.#gridsById = Object.fromEntries(
- this.#doc.grids.map((grid) => [grid.id, renderGrid(grid)]),
- );
-
- window.dispatchEvent(
- new CustomEvent("ntv:grid:change", {
- detail: { gridId },
- }),
- );
- }
-}
-
-window.notive = new Notive();
+import "./components";
diff --git a/web/tsconfig.json b/web/tsconfig.json
index 91be5ef..b650d24 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "target": "ESNext",
+ "target": "ES2020",
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",