From d724cc0bf6ff6d351319e6fb00f5184a04e16ac0 Mon Sep 17 00:00:00 2001 From: Josh Kingsley Date: Mon, 24 Nov 2025 15:46:22 +0200 Subject: chore: improve dev tasks --- apps/web/package.json | 20 ++ apps/web/src/components/app/index.css | 12 + apps/web/src/components/app/index.ts | 93 +++++++ apps/web/src/components/grid/cellAtCoord.ts | 40 +++ apps/web/src/components/grid/drawGrid.ts | 91 +++++++ apps/web/src/components/grid/drawSelection.ts | 97 ++++++++ apps/web/src/components/grid/excursion.ts | 8 + apps/web/src/components/grid/index.css | 49 ++++ apps/web/src/components/grid/index.ts | 276 +++++++++++++++++++++ apps/web/src/components/grid/renderGrid.ts | 144 +++++++++++ apps/web/src/components/grid/selection.ts | 28 +++ apps/web/src/components/icons/index.ts | 19 ++ apps/web/src/components/icons/svgs/minus16.svg | 3 + apps/web/src/components/icons/svgs/plus16.svg | 3 + apps/web/src/components/index.ts | 3 + apps/web/src/components/toolbar/index.css | 48 ++++ apps/web/src/components/toolbar/index.ts | 70 ++++++ apps/web/src/defaultDoc.ts | 42 ++++ apps/web/src/doc/index.test.ts | 16 ++ apps/web/src/doc/index.ts | 125 ++++++++++ apps/web/src/element.ts | 45 ++++ apps/web/src/favicon.ico | Bin 0 -> 15406 bytes apps/web/src/grid.test.ts | 45 ++++ apps/web/src/grid.ts | 109 ++++++++ apps/web/src/html.ts | 50 ++++ apps/web/src/index.css | 10 + apps/web/src/index.html | 11 + apps/web/src/index.ts | 8 + apps/web/src/math/Coord.ts | 23 ++ apps/web/src/math/Ratio.test.ts | 27 ++ apps/web/src/math/Ratio.ts | 105 ++++++++ apps/web/src/math/Rect.ts | 104 ++++++++ apps/web/src/math/index.ts | 3 + apps/web/src/types.ts | 55 ++++ apps/web/tsconfig.json | 12 + apps/web/vite.config.ts | 8 + package.json | 9 +- packages/doc/package.json | 5 +- packages/web/package.json | 20 -- packages/web/src/components/app/index.css | 12 - packages/web/src/components/app/index.ts | 93 ------- packages/web/src/components/grid/cellAtCoord.ts | 40 --- packages/web/src/components/grid/drawGrid.ts | 91 ------- packages/web/src/components/grid/drawSelection.ts | 97 -------- packages/web/src/components/grid/excursion.ts | 8 - packages/web/src/components/grid/index.css | 49 ---- packages/web/src/components/grid/index.ts | 276 --------------------- packages/web/src/components/grid/renderGrid.ts | 144 ----------- packages/web/src/components/grid/selection.ts | 28 --- packages/web/src/components/icons/index.ts | 19 -- packages/web/src/components/icons/svgs/minus16.svg | 3 - packages/web/src/components/icons/svgs/plus16.svg | 3 - packages/web/src/components/index.ts | 3 - packages/web/src/components/toolbar/index.css | 48 ---- packages/web/src/components/toolbar/index.ts | 70 ------ packages/web/src/defaultDoc.ts | 42 ---- packages/web/src/doc/index.test.ts | 16 -- packages/web/src/doc/index.ts | 125 ---------- packages/web/src/element.ts | 45 ---- packages/web/src/favicon.ico | Bin 15406 -> 0 bytes packages/web/src/grid.test.ts | 45 ---- packages/web/src/grid.ts | 109 -------- packages/web/src/html.ts | 50 ---- packages/web/src/index.css | 10 - packages/web/src/index.html | 11 - packages/web/src/index.ts | 8 - packages/web/src/math/Coord.ts | 23 -- packages/web/src/math/Ratio.test.ts | 27 -- packages/web/src/math/Ratio.ts | 105 -------- packages/web/src/math/Rect.ts | 104 -------- packages/web/src/math/index.ts | 3 - packages/web/src/types.ts | 55 ---- packages/web/tsconfig.json | 12 - packages/web/vite.config.ts | 8 - pnpm-lock.yaml | 74 +++--- pnpm-workspace.yaml | 5 + turbo.json | 13 +- 77 files changed, 1867 insertions(+), 1843 deletions(-) create mode 100644 apps/web/package.json create mode 100644 apps/web/src/components/app/index.css create mode 100644 apps/web/src/components/app/index.ts create mode 100644 apps/web/src/components/grid/cellAtCoord.ts create mode 100644 apps/web/src/components/grid/drawGrid.ts create mode 100644 apps/web/src/components/grid/drawSelection.ts create mode 100644 apps/web/src/components/grid/excursion.ts create mode 100644 apps/web/src/components/grid/index.css create mode 100644 apps/web/src/components/grid/index.ts create mode 100644 apps/web/src/components/grid/renderGrid.ts create mode 100644 apps/web/src/components/grid/selection.ts create mode 100644 apps/web/src/components/icons/index.ts create mode 100644 apps/web/src/components/icons/svgs/minus16.svg create mode 100644 apps/web/src/components/icons/svgs/plus16.svg create mode 100644 apps/web/src/components/index.ts create mode 100644 apps/web/src/components/toolbar/index.css create mode 100644 apps/web/src/components/toolbar/index.ts create mode 100644 apps/web/src/defaultDoc.ts create mode 100644 apps/web/src/doc/index.test.ts create mode 100644 apps/web/src/doc/index.ts create mode 100644 apps/web/src/element.ts create mode 100644 apps/web/src/favicon.ico create mode 100644 apps/web/src/grid.test.ts create mode 100644 apps/web/src/grid.ts create mode 100644 apps/web/src/html.ts create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/index.html create mode 100644 apps/web/src/index.ts create mode 100644 apps/web/src/math/Coord.ts create mode 100644 apps/web/src/math/Ratio.test.ts create mode 100644 apps/web/src/math/Ratio.ts create mode 100644 apps/web/src/math/Rect.ts create mode 100644 apps/web/src/math/index.ts create mode 100644 apps/web/src/types.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts delete mode 100644 packages/web/package.json delete mode 100644 packages/web/src/components/app/index.css delete mode 100644 packages/web/src/components/app/index.ts delete mode 100644 packages/web/src/components/grid/cellAtCoord.ts delete mode 100644 packages/web/src/components/grid/drawGrid.ts delete mode 100644 packages/web/src/components/grid/drawSelection.ts delete mode 100644 packages/web/src/components/grid/excursion.ts delete mode 100644 packages/web/src/components/grid/index.css delete mode 100644 packages/web/src/components/grid/index.ts delete mode 100644 packages/web/src/components/grid/renderGrid.ts delete mode 100644 packages/web/src/components/grid/selection.ts delete mode 100644 packages/web/src/components/icons/index.ts delete mode 100644 packages/web/src/components/icons/svgs/minus16.svg delete mode 100644 packages/web/src/components/icons/svgs/plus16.svg delete mode 100644 packages/web/src/components/index.ts delete mode 100644 packages/web/src/components/toolbar/index.css delete mode 100644 packages/web/src/components/toolbar/index.ts delete mode 100644 packages/web/src/defaultDoc.ts delete mode 100644 packages/web/src/doc/index.test.ts delete mode 100644 packages/web/src/doc/index.ts delete mode 100644 packages/web/src/element.ts delete mode 100644 packages/web/src/favicon.ico delete mode 100644 packages/web/src/grid.test.ts delete mode 100644 packages/web/src/grid.ts delete mode 100644 packages/web/src/html.ts delete mode 100644 packages/web/src/index.css delete mode 100644 packages/web/src/index.html delete mode 100644 packages/web/src/index.ts delete mode 100644 packages/web/src/math/Coord.ts delete mode 100644 packages/web/src/math/Ratio.test.ts delete mode 100644 packages/web/src/math/Ratio.ts delete mode 100644 packages/web/src/math/Rect.ts delete mode 100644 packages/web/src/math/index.ts delete mode 100644 packages/web/src/types.ts delete mode 100644 packages/web/tsconfig.json delete mode 100644 packages/web/vite.config.ts diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..81bfe17 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "@notive/web", + "private": true, + "scripts": { + "dev": "vite --clearScreen false", + "test": "vitest" + }, + "dependencies": { + "@notive/doc": "workspace:*", + "immer": "^10.2.0", + "tailwindcss": "^4.1.16", + "tone": "^15.1.22" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.16", + "vite": "^7.1.12", + "vite-plugin-wasm": "^3.5.0", + "vitest": "^4.0.6" + } +} diff --git a/apps/web/src/components/app/index.css b/apps/web/src/components/app/index.css new file mode 100644 index 0000000..aaf2ced --- /dev/null +++ b/apps/web/src/components/app/index.css @@ -0,0 +1,12 @@ +ntv-app { + display: block; + padding: 1.5rem; +} + +ntv-app > ntv-toolbar { + margin-bottom: 1.5rem; +} + +ntv-app > ntv-grid + ntv-grid { + margin-top: 1.5rem; +} diff --git a/apps/web/src/components/app/index.ts b/apps/web/src/components/app/index.ts new file mode 100644 index 0000000..a2c0c9d --- /dev/null +++ b/apps/web/src/components/app/index.ts @@ -0,0 +1,93 @@ +import { produce } from "immer"; +import defaultDoc from "../../defaultDoc"; +import NotiveElement, { customElement } from "../../element"; +import { + changeSelectedSubdivisions, + getSelectedSubdivisionsCount, +} from "../../grid"; +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(); + + #selectedGridId?: string; + #selection?: GridSelection; + + setSelection(gridId: string, selection: GridSelection) { + const grid = this.doc.grids.find((grid) => grid.id === gridId); + if (!grid) throw new Error("Invalid grid ID"); + + this.#selectedGridId = gridId; + this.#selection = selection; + this.#updateGridSelections(); + + this.#toolbar.subdivisions = getSelectedSubdivisionsCount(grid, selection); + } + + clearSelection() { + this.#selectedGridId = undefined; + this.#selection = undefined; + this.#updateGridSelections(); + this.#toolbar.subdivisions = undefined; + } + + #updateGridSelections() { + this.querySelectorAll("ntv-grid").forEach((grid) => { + grid.selection = + this.#selectedGridId === grid.grid?.id ? this.#selection : undefined; + }); + } + + #toolbar = ntvToolbar({ + onsubdivisionschange: ({ subdivisions }) => { + if (!subdivisions) return; + + const gridId = this.#selectedGridId; + const selection = this.#selection; + + if (!gridId || !selection) return; + + const gridIndex = this.doc.grids.findIndex((grid) => grid.id === gridId); + + this.doc = produce(this.doc, (doc) => { + doc.grids[gridIndex] = changeSelectedSubdivisions( + this.doc.grids[gridIndex], + selection, + subdivisions, + ); + }); + + this.querySelector( + `ntv-grid[data-grid-id="${gridId}"]`, + )!.grid = renderGrid(this.doc.grids[gridIndex]); + + this.clearSelection(); + }, + }); + + connectedCallback() { + this.append( + this.#toolbar, + ...this.doc.grids.map((grid) => + ntvGrid({ + grid: renderGrid(grid), + dataset: { gridId: grid.id }, + ongridselectionchange: (event) => { + this.setSelection(grid.id, event.selection); + }, + oncellchange: (event) => { + console.log(event); + }, + }), + ), + ); + } +} + +export default NotiveAppElement.makeFactory(); diff --git a/apps/web/src/components/grid/cellAtCoord.ts b/apps/web/src/components/grid/cellAtCoord.ts new file mode 100644 index 0000000..dd594a4 --- /dev/null +++ b/apps/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/apps/web/src/components/grid/drawGrid.ts b/apps/web/src/components/grid/drawGrid.ts new file mode 100644 index 0000000..da83c8e --- /dev/null +++ b/apps/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/apps/web/src/components/grid/drawSelection.ts b/apps/web/src/components/grid/drawSelection.ts new file mode 100644 index 0000000..1b8c2ed --- /dev/null +++ b/apps/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/apps/web/src/components/grid/excursion.ts b/apps/web/src/components/grid/excursion.ts new file mode 100644 index 0000000..7752df1 --- /dev/null +++ b/apps/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/apps/web/src/components/grid/index.css b/apps/web/src/components/grid/index.css new file mode 100644 index 0000000..c29f55d --- /dev/null +++ b/apps/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/apps/web/src/components/grid/index.ts b/apps/web/src/components/grid/index.ts new file mode 100644 index 0000000..3189409 --- /dev/null +++ b/apps/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/apps/web/src/components/grid/renderGrid.ts b/apps/web/src/components/grid/renderGrid.ts new file mode 100644 index 0000000..89938ec --- /dev/null +++ b/apps/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/apps/web/src/components/grid/selection.ts b/apps/web/src/components/grid/selection.ts new file mode 100644 index 0000000..517f8ae --- /dev/null +++ b/apps/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]; +} diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts new file mode 100644 index 0000000..5731026 --- /dev/null +++ b/apps/web/src/components/icons/index.ts @@ -0,0 +1,19 @@ +import plus16 from "./svgs/plus16.svg?raw"; +import minus16 from "./svgs/minus16.svg?raw"; + +function makeIconFactory(source: string) { + return (attrs?: object): SVGElement => { + const parser = new DOMParser(); + const doc = parser.parseFromString(source, "image/svg+xml"); + const svg = doc.documentElement as unknown as SVGElement; + + if (attrs) { + Object.entries(attrs).forEach(([k, v]) => svg.setAttribute(k, v)); + } + + return svg; + }; +} + +export const plus16Icon = makeIconFactory(plus16); +export const minus16Icon = makeIconFactory(minus16); diff --git a/apps/web/src/components/icons/svgs/minus16.svg b/apps/web/src/components/icons/svgs/minus16.svg new file mode 100644 index 0000000..d77dcfc --- /dev/null +++ b/apps/web/src/components/icons/svgs/minus16.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/components/icons/svgs/plus16.svg b/apps/web/src/components/icons/svgs/plus16.svg new file mode 100644 index 0000000..1d7b023 --- /dev/null +++ b/apps/web/src/components/icons/svgs/plus16.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/components/index.ts b/apps/web/src/components/index.ts new file mode 100644 index 0000000..b7f6f55 --- /dev/null +++ b/apps/web/src/components/index.ts @@ -0,0 +1,3 @@ +import "./app"; +import "./grid"; +import "./toolbar"; diff --git a/apps/web/src/components/toolbar/index.css b/apps/web/src/components/toolbar/index.css new file mode 100644 index 0000000..653c326 --- /dev/null +++ b/apps/web/src/components/toolbar/index.css @@ -0,0 +1,48 @@ +@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 button { + border-radius: 99999px; + background: var(--color-neutral-800); + padding: 0 0.5rem; + height: 1.25rem; + color: white; + font-weight: 600; + font-size: 0.75rem; + } + + ntv-toolbar button:hover { + background: var(--color-green-400); + color: var(--color-neutral-900); + } + + 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; + } +} diff --git a/apps/web/src/components/toolbar/index.ts b/apps/web/src/components/toolbar/index.ts new file mode 100644 index 0000000..b8a383d --- /dev/null +++ b/apps/web/src/components/toolbar/index.ts @@ -0,0 +1,70 @@ +import NotiveElement, { customElement, eventHandler } from "../../element"; +import h from "../../html"; +import { minus16Icon, plus16Icon } from "../icons"; +import "./index.css"; + +export class SubdivisionsChangeEvent extends Event { + static readonly TYPE = "ntv:toolbar:subdivisionschange"; + + constructor(public subdivisions: number | undefined) { + super(SubdivisionsChangeEvent.TYPE); + } +} + +@customElement("ntv-toolbar") +class NotiveToolbarElement extends NotiveElement { + #subdivisionsInputEl: HTMLInputElement = h.input({ + title: "Subdivisions", + disabled: true, + }); + + get subdivisions(): number | undefined { + if (this.#subdivisionsInputEl.value === "") return; + return parseInt(this.#subdivisionsInputEl.value); + } + + set subdivisions(n: number | undefined) { + const m = n && Math.max(n, 1); + this.#subdivisionsInputEl.value = m === undefined ? "" : m.toString(); + } + + @eventHandler(SubdivisionsChangeEvent.TYPE) + onsubdivisionschange?: (event: SubdivisionsChangeEvent) => any; + + connectedCallback() { + this.append( + h.section( + h.button( + { + dataset: { icon: "" }, + onclick: () => { + if (!this.subdivisions) return; + this.subdivisions = this.subdivisions - 1; + this.dispatchEvent( + new SubdivisionsChangeEvent(this.subdivisions), + ); + }, + }, + h.span(minus16Icon()), + ), + this.#subdivisionsInputEl, + h.button( + { + dataset: { icon: "" }, + onclick: () => { + if (!this.subdivisions) return; + this.subdivisions = this.subdivisions + 1; + this.dispatchEvent( + new SubdivisionsChangeEvent(this.subdivisions), + ); + }, + }, + h.span(plus16Icon()), + ), + ), + h.section(h.button("Play")), + ); + } +} + +export default NotiveToolbarElement.makeFactory(); diff --git a/apps/web/src/defaultDoc.ts b/apps/web/src/defaultDoc.ts new file mode 100644 index 0000000..0a3fbfb --- /dev/null +++ b/apps/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: globalThis.crypto.randomUUID(), + baseCellSize: 42, + baseCellWidthRatio: new Ratio(1, 16), + parts: [ + { + rows: Array.from({ length: 4 }, () => ({ + cells: [...defaultCells], + })), + }, + ], + }, + { + id: globalThis.crypto.randomUUID(), + baseCellSize: 42, + baseCellWidthRatio: new Ratio(1, 16), + parts: [ + { + rows: Array.from({ length: 2 }, () => ({ + cells: [...defaultCells], + })), + }, + { + rows: Array.from({ length: 2 }, () => ({ + cells: [...defaultCells], + })), + }, + ], + }, + ], + }; +} diff --git a/apps/web/src/doc/index.test.ts b/apps/web/src/doc/index.test.ts new file mode 100644 index 0000000..5f61398 --- /dev/null +++ b/apps/web/src/doc/index.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { apply, defaultDoc, realizeGrids, subdivide } from "."; + +test(realizeGrids, () => { + const doc = defaultDoc(); + const grids = realizeGrids(doc); + + expect(grids.length).toBe(1); + expect(grids[0].rows.length).toBe(4); + expect(grids[0].rows[0].cells.length).toBe(16); + + const doc2 = apply(doc, subdivide(grids[0].id, 0, 0, 3, 3)); + const grids2 = realizeGrids(doc2); + + expect(grids2[0].rows[0].cells.length).toBe(15); +}); diff --git a/apps/web/src/doc/index.ts b/apps/web/src/doc/index.ts new file mode 100644 index 0000000..ae221f0 --- /dev/null +++ b/apps/web/src/doc/index.ts @@ -0,0 +1,125 @@ +import { Immutable, produce } from "immer"; + +export type Doc = Immutable<{ ops: Op[] }>; + +export type Op = CreateGrid | Subdivide; + +export type CreateGrid = Immutable<{ + type: "createGrid"; + gridId: string; + rows: number; + baseCellsPerRow: number; +}>; + +export function createGrid(): CreateGrid { + return { + type: "createGrid", + gridId: crypto.randomUUID(), + rows: 4, + baseCellsPerRow: 16, + }; +} + +export type Subdivide = Immutable<{ + type: "subdivide"; + gridId: string; + rowIndex: number; + startCellIndex: number; + endCellIndex: number; + subdivisions: number; +}>; + +export function subdivide( + gridId: string, + rowIndex: number, + startCellIndex: number, + endCellIndex: number, + subdivisions: number, +): Subdivide { + return { + type: "subdivide", + gridId, + rowIndex, + startCellIndex, + endCellIndex, + subdivisions, + }; +} + +export function defaultDoc(): Doc { + const ops = [createGrid()]; + return { ops }; +} + +export function apply(doc: Doc, ...ops: Op[]): Doc { + return produce(doc, (doc) => { + doc.ops.push(...ops); + }); +} + +export type DocIndex = Immutable<{ + opsByType: Map; +}>; + +export function indexDoc(doc: Doc): DocIndex { + const opsByType = new Map(); + + for (const op of doc.ops) { + opsByType.set(op.type, [...(opsByType.get(op.type) ?? []), op]); + } + + return { opsByType }; +} + +export function getOpsByType( + index: DocIndex, + type: T, +): Extract[] { + return (index.opsByType.get(type) ?? []) as Extract[]; +} + +export type Grid = Immutable<{ id: string; rows: Row[] }>; + +export type Row = Immutable<{ index: number; cells: Cell[] }>; + +export type Cell = Immutable<{}>; + +export function realizeGrids(doc: Doc): Grid[] { + const index = indexDoc(doc); + const createGridOps = getOpsByType(index, "createGrid"); + return createGridOps.map((op) => realizeGrid(doc, index, op)); +} + +function realizeGrid(doc: Doc, index: DocIndex, createOp: CreateGrid): Grid { + const rows = []; + + for (let rowIndex = 0; rowIndex < createOp.rows; rowIndex++) { + let cells: Cell[] = []; + + for (let cellIndex = 0; cellIndex < createOp.baseCellsPerRow; cellIndex++) { + cells.push({ index: cellIndex }); + } + + const subdivideOps = doc.ops.filter( + (op) => + op.type === "subdivide" && + op.gridId === createOp.gridId && + op.rowIndex === rowIndex, + ) as Subdivide[]; + + subdivideOps.forEach((op) => { + cells = [ + ...cells.slice(0, op.startCellIndex), + ...Array.from({ length: op.subdivisions }, () => ({})), + ...cells.slice(op.endCellIndex + 1), + ]; + }); + + rows.push({ index: rowIndex, cells }); + } + + return { + id: createOp.gridId, + rows, + }; +} diff --git a/apps/web/src/element.ts b/apps/web/src/element.ts new file mode 100644 index 0000000..6299d2f --- /dev/null +++ b/apps/web/src/element.ts @@ -0,0 +1,45 @@ +import { createElement, type CreateElement } from "./html"; + +export default class NotiveElement extends HTMLElement { + static makeFactory(this: { + new (): T; + }): CreateElement; + + 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; + }); + }; +} + +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/apps/web/src/favicon.ico b/apps/web/src/favicon.ico new file mode 100644 index 0000000..c10cfe9 Binary files /dev/null and b/apps/web/src/favicon.ico differ diff --git a/apps/web/src/grid.test.ts b/apps/web/src/grid.test.ts new file mode 100644 index 0000000..50c0626 --- /dev/null +++ b/apps/web/src/grid.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "vitest"; +import defaultDoc from "./defaultDoc"; +import renderGrid from "./components/grid/renderGrid"; +import { changeSelectedSubdivisions } from "./grid"; +import { GridSelection } from "./components/grid/selection"; + +test("foo", () => { + const doc = defaultDoc(); + const grid = doc.grids[1]; + + const selection: GridSelection = { + activeCellRef: { partIndex: 0, rowIndex: 0, cellIndex: 0 }, + range: [ + { partIndex: 0, rowIndex: 0, cellIndex: 0 }, + { partIndex: 0, rowIndex: 0, cellIndex: 3 }, + ], + }; + + const newGrid = changeSelectedSubdivisions(grid, selection, 3); + const renderedGrid = renderGrid(newGrid); + + expect( + renderedGrid.renderedRows.map((row) => row.renderedCells.length), + ).toStrictEqual([15, 16, 16, 16]); + + expect( + newGrid.parts[0].rows[0].cells.map((cell) => cell.widthRatio.toData()), + ).toStrictEqual([ + [1, 12], + [1, 12], + [1, 12], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + ]); +}); diff --git a/apps/web/src/grid.ts b/apps/web/src/grid.ts new file mode 100644 index 0000000..e849803 --- /dev/null +++ b/apps/web/src/grid.ts @@ -0,0 +1,109 @@ +import { produce } from "immer"; +import renderGrid, { getRenderedCell } from "./components/grid/renderGrid"; +import { getSelectionRange, GridSelection } from "./components/grid/selection"; +import Ratio from "./math/Ratio"; +import { Cell, Grid, renderedRowIndexToRef } from "./types"; + +export function getSelectedSubdivisionsCount( + grid: Grid, + selection: GridSelection, +): number | undefined { + const renderedGrid = renderGrid(grid); + + const [startCellRef, endCellRef] = getSelectionRange(selection); + const startCell = getRenderedCell(renderedGrid, startCellRef); + const endCell = getRenderedCell(renderedGrid, endCellRef); + + if (!startCell || !endCell) throw new Error("Invalid cell refs"); + + const startRenderedRowIndex = Math.min( + startCell.renderedRowIndex, + endCell.renderedRowIndex, + ); + + const endRenderedRowIndex = Math.max( + startCell.renderedRowIndex, + endCell.renderedRowIndex, + ); + + const startRatio = Ratio.min(startCell.startRatio, endCell.startRatio); + const endRatio = Ratio.max(startCell.endRatio, endCell.endRatio); + + return Math.min( + ...renderedGrid.renderedRows + .slice(startRenderedRowIndex, endRenderedRowIndex + 1) + .map((row) => { + const startCellIndex = row.renderedCells.findIndex((cell) => + cell.startRatio.equals(startRatio), + ); + + const endCellIndex = row.renderedCells.findLastIndex((cell) => + cell.endRatio.equals(endRatio), + ); + + return endCellIndex - startCellIndex + 1; + }), + ); +} + +export function changeSelectedSubdivisions( + grid: Grid, + selection: GridSelection, + subdivisions: number, +): Grid { + const renderedGrid = renderGrid(grid); + const [startCellRef, endCellRef] = getSelectionRange(selection); + const startCell = getRenderedCell(renderedGrid, startCellRef); + const endCell = getRenderedCell(renderedGrid, endCellRef); + if (!startCell || !endCell) throw new Error("Invalid cell refs"); + + const startRenderedRowIndex = Math.min( + startCell.renderedRowIndex, + endCell.renderedRowIndex, + ); + + const endRenderedRowIndex = Math.max( + startCell.renderedRowIndex, + endCell.renderedRowIndex, + ); + + const startRatio = Ratio.min(startCell.startRatio, endCell.startRatio); + const endRatio = Ratio.max(startCell.endRatio, endCell.endRatio); + const selectedWidthRatio = endRatio.subtract(startRatio); + const widthRatio = selectedWidthRatio.divideRatio( + Ratio.fromInteger(subdivisions), + ); + + return produce(grid, (draft) => { + for ( + let renderedRowIndex = startRenderedRowIndex; + renderedRowIndex <= endRenderedRowIndex; + renderedRowIndex++ + ) { + const renderedRow = renderedGrid.renderedRows[renderedRowIndex]; + + const startCellIndex = renderedRow.renderedCells.findIndex((cell) => + cell.startRatio.equals(startRatio), + ); + + const endCellIndex = renderedRow.renderedCells.findLastIndex((cell) => + cell.endRatio.equals(endRatio), + ); + + const { partIndex, rowIndex } = renderedRowIndexToRef( + grid, + renderedRowIndex, + ); + + const row = draft.parts[partIndex].rows[rowIndex]; + const previousCells = row.cells.slice(0, startCellIndex); + const nextCells = row.cells.slice(endCellIndex + 1); + + const newCells: Cell[] = Array.from({ length: subdivisions }, () => ({ + widthRatio, + })); + + row.cells = [...previousCells, ...newCells, ...nextCells]; + } + }); +} diff --git a/apps/web/src/html.ts b/apps/web/src/html.ts new file mode 100644 index 0000000..3fccda3 --- /dev/null +++ b/apps/web/src/html.ts @@ -0,0 +1,50 @@ +export function createElement( + tagName: string, + ...children: (Node | string)[] +): T; + +export function createElement( + tagName: string, + attrs: Partial, + ...children: (Node | string)[] +): T; + +export function createElement(tagName: string, ...args: any[]) { + const el = document.createElement(tagName); + + 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 = { + (...children: (Node | string)[]): T; + (attrs: Partial, ...children: (Node | string)[]): T; +}; + +type ElementCreator = { + [K in keyof HTMLElementTagNameMap]: CreateElement; +}; + +const h = new Proxy({} as ElementCreator, { + get: + (_, tagName: string) => + (...args: any[]) => { + return createElement(tagName, ...args); + }, +}); + +export default h; + +export function fragment(...children: (Node | string)[]): DocumentFragment { + const fragment = document.createDocumentFragment(); + fragment.append(...children); + return fragment; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..f100378 --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,10 @@ +@import "tailwindcss"; + +body { + background: var(--color-neutral-800); + user-select: none; +} + +*:focus-visible { + outline: 2px solid var(--color-green-400); +} diff --git a/apps/web/src/index.html b/apps/web/src/index.html new file mode 100644 index 0000000..9f8bcbf --- /dev/null +++ b/apps/web/src/index.html @@ -0,0 +1,11 @@ + + + + + Notive + + + + + + diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts new file mode 100644 index 0000000..857e76a --- /dev/null +++ b/apps/web/src/index.ts @@ -0,0 +1,8 @@ +import ntvApp from "./components/app"; +import { State } from "@notive/doc"; + +const state = new State(); +state.create_grid(); +console.log(state.to_json()); + +document.body.append(ntvApp()); diff --git a/apps/web/src/math/Coord.ts b/apps/web/src/math/Coord.ts new file mode 100644 index 0000000..db7ee6d --- /dev/null +++ b/apps/web/src/math/Coord.ts @@ -0,0 +1,23 @@ +/** A coord on a grid whose origin is in the top left. */ +export default class Coord { + private readonly _x: number; + private readonly _y: number; + + constructor(x: number, y: number) { + this._x = x; + this._y = y; + } + + get x(): number { + return this._x; + } + + get y(): number { + return this._y; + } + + /** Get the squared distance of this point from the origin. */ + squaredDistanceFromOrigin(): number { + return this._x * this._x + this._y * this._y; + } +} diff --git a/apps/web/src/math/Ratio.test.ts b/apps/web/src/math/Ratio.test.ts new file mode 100644 index 0000000..da6fef2 --- /dev/null +++ b/apps/web/src/math/Ratio.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import Ratio from "./Ratio"; + +describe(Ratio, () => { + describe(Ratio.prototype.add, () => { + test("returns fractions in simplest form", () => { + const a = Ratio.fromInteger(0); + const b = new Ratio(1, 4); + + const c = a.add(b); + expect(c.numerator).toBe(1); + expect(c.denominator).toBe(4); + + const d = c.add(b); + expect(d.numerator).toBe(1); + expect(d.denominator).toBe(2); + + const e = d.add(b); + expect(e.numerator).toBe(3); + expect(e.denominator).toBe(4); + + const f = e.add(b); + expect(f.numerator).toBe(1); + expect(f.denominator).toBe(1); + }); + }); +}); diff --git a/apps/web/src/math/Ratio.ts b/apps/web/src/math/Ratio.ts new file mode 100644 index 0000000..e2a1fbf --- /dev/null +++ b/apps/web/src/math/Ratio.ts @@ -0,0 +1,105 @@ +import { gcd } from "."; + +/** Serializable representation of a ratio. */ +export type RatioData = [numerator: number, denominator: number]; + +/** Representation of a ratio for performing fractional artithmetic. */ +export default class Ratio { + readonly #numerator: number; + readonly #denominator: number; + + get numerator(): number { + return this.#numerator; + } + + get denominator(): number { + return this.#denominator; + } + + constructor(numerator: number, denominator: number) { + if (!Number.isInteger(numerator) || !Number.isInteger(denominator)) { + throw new TypeError( + `Ratio must have integer parts: ${numerator} / ${denominator}`, + ); + } + + if (denominator === 0) { + throw new RangeError("Ratio demnominator cannot be zero"); + } + + const divisor = gcd(numerator, denominator); + + this.#numerator = numerator / divisor; + this.#denominator = denominator / divisor; + } + + multiplyRatio(other: Ratio): Ratio { + return new Ratio( + this.numerator * other.numerator, + this.denominator * other.denominator, + ); + } + + divideRatio(other: Ratio): Ratio { + return new Ratio( + this.numerator * other.denominator, + this.denominator * other.numerator, + ); + } + + add(other: Ratio): Ratio { + return new Ratio( + this.numerator * other.denominator + other.numerator * this.denominator, + this.denominator * other.denominator, + ); + } + + subtract(other: Ratio): Ratio { + return new Ratio( + this.numerator * other.denominator - other.numerator * this.denominator, + this.denominator * other.denominator, + ); + } + + compare(other: Ratio): number { + const left = this.numerator * other.denominator; + const right = other.numerator * this.denominator; + return left < right ? -1 : left > right ? 1 : 0; + } + + equals(other: Ratio): boolean { + return this.compare(other) === 0; + } + + toNumber(): number { + return this.numerator / this.denominator; + } + + toString(): string { + return `${this.numerator}/${this.denominator}`; + } + + [Symbol.for("nodejs.util.inspect.custom")](): string { + return `Ratio { ${this.numerator}/${this.denominator} }`; + } + + static fromInteger(n: number): Ratio { + return new Ratio(n, 1); + } + + toData(): RatioData { + return [this.numerator, this.denominator]; + } + + static fromData(ratio: RatioData): Ratio { + return new Ratio(ratio[0], ratio[1]); + } + + static min(...ratios: Ratio[]): Ratio { + return ratios.reduce((a, b) => (a.compare(b) <= 0 ? a : b)); + } + + static max(...ratios: Ratio[]): Ratio { + return ratios.reduce((a, b) => (a.compare(b) >= 0 ? a : b)); + } +} diff --git a/apps/web/src/math/Rect.ts b/apps/web/src/math/Rect.ts new file mode 100644 index 0000000..f52a2f7 --- /dev/null +++ b/apps/web/src/math/Rect.ts @@ -0,0 +1,104 @@ +import Coord from "./Coord"; + +/** A rectangle on a grid whose origin is in the top left. */ +export default class Rect { + private readonly _topLeft: Coord; + private readonly _width: number; + private readonly _height: number; + + constructor( + topLeftX: number, + topLeftY: number, + width: number, + height: number, + ) { + this._topLeft = new Coord(topLeftX, topLeftY); + this._width = width; + this._height = height; + } + + /** Width of this rectangle. */ + get width(): number { + return this._width; + } + + /** Height of this rectangle. */ + get height(): number { + return this._height; + } + + /** Coord of the top-left point of this rectangle. */ + get topLeft(): Coord { + return this._topLeft; + } + + /** Coord of the bottom-right point of this rectangle. */ + get bottomRight(): Coord { + return new Coord( + this._topLeft.x + this._width, + this._topLeft.y + this._height, + ); + } + + get center(): Coord { + return new Coord( + this.topLeft.x + (this.bottomRight.x - this.topLeft.x) / 2, + this.topLeft.y + (this.bottomRight.y - this.topLeft.y) / 2, + ); + } + + /** Determine if this rectangle contains the point at `coord`. */ + containsCoord(coord: Coord): boolean { + return ( + this.topLeft.x <= coord.x && + coord.x <= this.bottomRight.x && + this.topLeft.y <= coord.y && + coord.y <= this.bottomRight.y + ); + } + + verticallyContainsCoord(coord: Coord): boolean { + return this.topLeft.y <= coord.y && coord.y <= this.bottomRight.y; + } + + horizontallyContainsCoord(coord: Coord): boolean { + return this.topLeft.x <= coord.x && coord.x <= this.bottomRight.x; + } + + extend(other: Rect): Rect { + const topLeftX = Math.min( + this.topLeft.x, + this.bottomRight.x, + other.topLeft.x, + other.bottomRight.x, + ); + + const topLeftY = Math.min( + this.topLeft.y, + this.bottomRight.y, + other.topLeft.y, + other.bottomRight.y, + ); + + const bottomRightX = Math.max( + this.topLeft.x, + this.bottomRight.x, + other.topLeft.x, + other.bottomRight.x, + ); + + const bottomRightY = Math.max( + this.topLeft.y, + this.bottomRight.y, + other.topLeft.y, + other.bottomRight.y, + ); + + return new Rect( + topLeftX, + topLeftY, + bottomRightX - topLeftX, + bottomRightY - topLeftY, + ); + } +} diff --git a/apps/web/src/math/index.ts b/apps/web/src/math/index.ts new file mode 100644 index 0000000..70dbb67 --- /dev/null +++ b/apps/web/src/math/index.ts @@ -0,0 +1,3 @@ +export function gcd(a: number, b: number): number { + return b === 0 ? Math.abs(a) : gcd(b, a % b); +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts new file mode 100644 index 0000000..dc26c89 --- /dev/null +++ b/apps/web/src/types.ts @@ -0,0 +1,55 @@ +import { Immutable } from "immer"; +import Ratio from "./math/Ratio"; + +export type Cell = Immutable<{ + value?: string; + widthRatio: Ratio; +}>; + +export interface Row { + cells: Cell[]; +} + +export interface Part { + title?: string; + rows: Row[]; +} + +export interface Grid { + id: string; + baseCellSize: number; + baseCellWidthRatio: Ratio; + parts: Part[]; +} + +export interface Doc { + grids: Grid[]; +} + +export interface RowRef { + partIndex: number; + rowIndex: number; +} + +export interface CellRef { + partIndex: number; + 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 + ); +} + +export function renderedRowIndexToRef( + grid: Grid, + renderedRowIndex: number, +): RowRef { + const partIndex = renderedRowIndex % grid.parts.length; + const rowIndex = Math.floor(renderedRowIndex / grid.parts.length); + return { partIndex, rowIndex }; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..b650d24 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/*.ts", "src/**/*.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..7f8e924 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,8 @@ +import tailwindcss from "@tailwindcss/vite"; +import wasm from "vite-plugin-wasm"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: "src", + plugins: [tailwindcss(), wasm()], +}); diff --git a/package.json b/package.json index f51a394..74c034a 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,18 @@ "name": "notive", "private": true, "type": "module", + "scripts": { + "build": "turbo run build", + "build:dev": "turbo run build:dev", + "clean": "turbo run clean && rm -rf target .turbo", + "dev": "turbo run dev", + "test": "turbo run test" + }, "devDependencies": { "prettier": "^3.6.2", "prettier-plugin-css-order": "^2.1.2", "prettier-plugin-packagejson": "^2.5.19", - "turbo": "^2.6.0", + "turbo": "^2.6.1", "typescript": "^5.9.3" }, "packageManager": "pnpm@10.22.0" diff --git a/packages/doc/package.json b/packages/doc/package.json index d7b9a93..e251e0d 100644 --- a/packages/doc/package.json +++ b/packages/doc/package.json @@ -5,7 +5,10 @@ ".": "dist/notive_doc.js" }, "scripts": { - "build": "wasm-pack build --target bundler --release --out-dir dist" + "build": "wasm-pack build --target bundler --release --out-dir dist", + "build:dev": "wasm-pack build --target bundler --dev --out-dir dist", + "clean": "rm -rf dist", + "dev": "cargo watch -i dist -s 'pnpm build:dev'" }, "devDependencies": { "wasm-pack": "^0.13.1" diff --git a/packages/web/package.json b/packages/web/package.json deleted file mode 100644 index 81bfe17..0000000 --- a/packages/web/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@notive/web", - "private": true, - "scripts": { - "dev": "vite --clearScreen false", - "test": "vitest" - }, - "dependencies": { - "@notive/doc": "workspace:*", - "immer": "^10.2.0", - "tailwindcss": "^4.1.16", - "tone": "^15.1.22" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.1.16", - "vite": "^7.1.12", - "vite-plugin-wasm": "^3.5.0", - "vitest": "^4.0.6" - } -} diff --git a/packages/web/src/components/app/index.css b/packages/web/src/components/app/index.css deleted file mode 100644 index aaf2ced..0000000 --- a/packages/web/src/components/app/index.css +++ /dev/null @@ -1,12 +0,0 @@ -ntv-app { - display: block; - padding: 1.5rem; -} - -ntv-app > ntv-toolbar { - margin-bottom: 1.5rem; -} - -ntv-app > ntv-grid + ntv-grid { - margin-top: 1.5rem; -} diff --git a/packages/web/src/components/app/index.ts b/packages/web/src/components/app/index.ts deleted file mode 100644 index a2c0c9d..0000000 --- a/packages/web/src/components/app/index.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { produce } from "immer"; -import defaultDoc from "../../defaultDoc"; -import NotiveElement, { customElement } from "../../element"; -import { - changeSelectedSubdivisions, - getSelectedSubdivisionsCount, -} from "../../grid"; -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(); - - #selectedGridId?: string; - #selection?: GridSelection; - - setSelection(gridId: string, selection: GridSelection) { - const grid = this.doc.grids.find((grid) => grid.id === gridId); - if (!grid) throw new Error("Invalid grid ID"); - - this.#selectedGridId = gridId; - this.#selection = selection; - this.#updateGridSelections(); - - this.#toolbar.subdivisions = getSelectedSubdivisionsCount(grid, selection); - } - - clearSelection() { - this.#selectedGridId = undefined; - this.#selection = undefined; - this.#updateGridSelections(); - this.#toolbar.subdivisions = undefined; - } - - #updateGridSelections() { - this.querySelectorAll("ntv-grid").forEach((grid) => { - grid.selection = - this.#selectedGridId === grid.grid?.id ? this.#selection : undefined; - }); - } - - #toolbar = ntvToolbar({ - onsubdivisionschange: ({ subdivisions }) => { - if (!subdivisions) return; - - const gridId = this.#selectedGridId; - const selection = this.#selection; - - if (!gridId || !selection) return; - - const gridIndex = this.doc.grids.findIndex((grid) => grid.id === gridId); - - this.doc = produce(this.doc, (doc) => { - doc.grids[gridIndex] = changeSelectedSubdivisions( - this.doc.grids[gridIndex], - selection, - subdivisions, - ); - }); - - this.querySelector( - `ntv-grid[data-grid-id="${gridId}"]`, - )!.grid = renderGrid(this.doc.grids[gridIndex]); - - this.clearSelection(); - }, - }); - - connectedCallback() { - this.append( - this.#toolbar, - ...this.doc.grids.map((grid) => - ntvGrid({ - grid: renderGrid(grid), - dataset: { gridId: grid.id }, - ongridselectionchange: (event) => { - this.setSelection(grid.id, event.selection); - }, - oncellchange: (event) => { - console.log(event); - }, - }), - ), - ); - } -} - -export default NotiveAppElement.makeFactory(); diff --git a/packages/web/src/components/grid/cellAtCoord.ts b/packages/web/src/components/grid/cellAtCoord.ts deleted file mode 100644 index dd594a4..0000000 --- a/packages/web/src/components/grid/cellAtCoord.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index da83c8e..0000000 --- a/packages/web/src/components/grid/drawGrid.ts +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 1b8c2ed..0000000 --- a/packages/web/src/components/grid/drawSelection.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 7752df1..0000000 --- a/packages/web/src/components/grid/excursion.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index c29f55d..0000000 --- a/packages/web/src/components/grid/index.css +++ /dev/null @@ -1,49 +0,0 @@ -@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 deleted file mode 100644 index 3189409..0000000 --- a/packages/web/src/components/grid/index.ts +++ /dev/null @@ -1,276 +0,0 @@ -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 deleted file mode 100644 index 89938ec..0000000 --- a/packages/web/src/components/grid/renderGrid.ts +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 517f8ae..0000000 --- a/packages/web/src/components/grid/selection.ts +++ /dev/null @@ -1,28 +0,0 @@ -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]; -} diff --git a/packages/web/src/components/icons/index.ts b/packages/web/src/components/icons/index.ts deleted file mode 100644 index 5731026..0000000 --- a/packages/web/src/components/icons/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import plus16 from "./svgs/plus16.svg?raw"; -import minus16 from "./svgs/minus16.svg?raw"; - -function makeIconFactory(source: string) { - return (attrs?: object): SVGElement => { - const parser = new DOMParser(); - const doc = parser.parseFromString(source, "image/svg+xml"); - const svg = doc.documentElement as unknown as SVGElement; - - if (attrs) { - Object.entries(attrs).forEach(([k, v]) => svg.setAttribute(k, v)); - } - - return svg; - }; -} - -export const plus16Icon = makeIconFactory(plus16); -export const minus16Icon = makeIconFactory(minus16); diff --git a/packages/web/src/components/icons/svgs/minus16.svg b/packages/web/src/components/icons/svgs/minus16.svg deleted file mode 100644 index d77dcfc..0000000 --- a/packages/web/src/components/icons/svgs/minus16.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/web/src/components/icons/svgs/plus16.svg b/packages/web/src/components/icons/svgs/plus16.svg deleted file mode 100644 index 1d7b023..0000000 --- a/packages/web/src/components/icons/svgs/plus16.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/web/src/components/index.ts b/packages/web/src/components/index.ts deleted file mode 100644 index b7f6f55..0000000 --- a/packages/web/src/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "./app"; -import "./grid"; -import "./toolbar"; diff --git a/packages/web/src/components/toolbar/index.css b/packages/web/src/components/toolbar/index.css deleted file mode 100644 index 653c326..0000000 --- a/packages/web/src/components/toolbar/index.css +++ /dev/null @@ -1,48 +0,0 @@ -@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 button { - border-radius: 99999px; - background: var(--color-neutral-800); - padding: 0 0.5rem; - height: 1.25rem; - color: white; - font-weight: 600; - font-size: 0.75rem; - } - - ntv-toolbar button:hover { - background: var(--color-green-400); - color: var(--color-neutral-900); - } - - 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; - } -} diff --git a/packages/web/src/components/toolbar/index.ts b/packages/web/src/components/toolbar/index.ts deleted file mode 100644 index b8a383d..0000000 --- a/packages/web/src/components/toolbar/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import NotiveElement, { customElement, eventHandler } from "../../element"; -import h from "../../html"; -import { minus16Icon, plus16Icon } from "../icons"; -import "./index.css"; - -export class SubdivisionsChangeEvent extends Event { - static readonly TYPE = "ntv:toolbar:subdivisionschange"; - - constructor(public subdivisions: number | undefined) { - super(SubdivisionsChangeEvent.TYPE); - } -} - -@customElement("ntv-toolbar") -class NotiveToolbarElement extends NotiveElement { - #subdivisionsInputEl: HTMLInputElement = h.input({ - title: "Subdivisions", - disabled: true, - }); - - get subdivisions(): number | undefined { - if (this.#subdivisionsInputEl.value === "") return; - return parseInt(this.#subdivisionsInputEl.value); - } - - set subdivisions(n: number | undefined) { - const m = n && Math.max(n, 1); - this.#subdivisionsInputEl.value = m === undefined ? "" : m.toString(); - } - - @eventHandler(SubdivisionsChangeEvent.TYPE) - onsubdivisionschange?: (event: SubdivisionsChangeEvent) => any; - - connectedCallback() { - this.append( - h.section( - h.button( - { - dataset: { icon: "" }, - onclick: () => { - if (!this.subdivisions) return; - this.subdivisions = this.subdivisions - 1; - this.dispatchEvent( - new SubdivisionsChangeEvent(this.subdivisions), - ); - }, - }, - h.span(minus16Icon()), - ), - this.#subdivisionsInputEl, - h.button( - { - dataset: { icon: "" }, - onclick: () => { - if (!this.subdivisions) return; - this.subdivisions = this.subdivisions + 1; - this.dispatchEvent( - new SubdivisionsChangeEvent(this.subdivisions), - ); - }, - }, - h.span(plus16Icon()), - ), - ), - h.section(h.button("Play")), - ); - } -} - -export default NotiveToolbarElement.makeFactory(); diff --git a/packages/web/src/defaultDoc.ts b/packages/web/src/defaultDoc.ts deleted file mode 100644 index 0a3fbfb..0000000 --- a/packages/web/src/defaultDoc.ts +++ /dev/null @@ -1,42 +0,0 @@ -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: globalThis.crypto.randomUUID(), - baseCellSize: 42, - baseCellWidthRatio: new Ratio(1, 16), - parts: [ - { - rows: Array.from({ length: 4 }, () => ({ - cells: [...defaultCells], - })), - }, - ], - }, - { - id: globalThis.crypto.randomUUID(), - baseCellSize: 42, - baseCellWidthRatio: new Ratio(1, 16), - parts: [ - { - rows: Array.from({ length: 2 }, () => ({ - cells: [...defaultCells], - })), - }, - { - rows: Array.from({ length: 2 }, () => ({ - cells: [...defaultCells], - })), - }, - ], - }, - ], - }; -} diff --git a/packages/web/src/doc/index.test.ts b/packages/web/src/doc/index.test.ts deleted file mode 100644 index 5f61398..0000000 --- a/packages/web/src/doc/index.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from "vitest"; -import { apply, defaultDoc, realizeGrids, subdivide } from "."; - -test(realizeGrids, () => { - const doc = defaultDoc(); - const grids = realizeGrids(doc); - - expect(grids.length).toBe(1); - expect(grids[0].rows.length).toBe(4); - expect(grids[0].rows[0].cells.length).toBe(16); - - const doc2 = apply(doc, subdivide(grids[0].id, 0, 0, 3, 3)); - const grids2 = realizeGrids(doc2); - - expect(grids2[0].rows[0].cells.length).toBe(15); -}); diff --git a/packages/web/src/doc/index.ts b/packages/web/src/doc/index.ts deleted file mode 100644 index ae221f0..0000000 --- a/packages/web/src/doc/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Immutable, produce } from "immer"; - -export type Doc = Immutable<{ ops: Op[] }>; - -export type Op = CreateGrid | Subdivide; - -export type CreateGrid = Immutable<{ - type: "createGrid"; - gridId: string; - rows: number; - baseCellsPerRow: number; -}>; - -export function createGrid(): CreateGrid { - return { - type: "createGrid", - gridId: crypto.randomUUID(), - rows: 4, - baseCellsPerRow: 16, - }; -} - -export type Subdivide = Immutable<{ - type: "subdivide"; - gridId: string; - rowIndex: number; - startCellIndex: number; - endCellIndex: number; - subdivisions: number; -}>; - -export function subdivide( - gridId: string, - rowIndex: number, - startCellIndex: number, - endCellIndex: number, - subdivisions: number, -): Subdivide { - return { - type: "subdivide", - gridId, - rowIndex, - startCellIndex, - endCellIndex, - subdivisions, - }; -} - -export function defaultDoc(): Doc { - const ops = [createGrid()]; - return { ops }; -} - -export function apply(doc: Doc, ...ops: Op[]): Doc { - return produce(doc, (doc) => { - doc.ops.push(...ops); - }); -} - -export type DocIndex = Immutable<{ - opsByType: Map; -}>; - -export function indexDoc(doc: Doc): DocIndex { - const opsByType = new Map(); - - for (const op of doc.ops) { - opsByType.set(op.type, [...(opsByType.get(op.type) ?? []), op]); - } - - return { opsByType }; -} - -export function getOpsByType( - index: DocIndex, - type: T, -): Extract[] { - return (index.opsByType.get(type) ?? []) as Extract[]; -} - -export type Grid = Immutable<{ id: string; rows: Row[] }>; - -export type Row = Immutable<{ index: number; cells: Cell[] }>; - -export type Cell = Immutable<{}>; - -export function realizeGrids(doc: Doc): Grid[] { - const index = indexDoc(doc); - const createGridOps = getOpsByType(index, "createGrid"); - return createGridOps.map((op) => realizeGrid(doc, index, op)); -} - -function realizeGrid(doc: Doc, index: DocIndex, createOp: CreateGrid): Grid { - const rows = []; - - for (let rowIndex = 0; rowIndex < createOp.rows; rowIndex++) { - let cells: Cell[] = []; - - for (let cellIndex = 0; cellIndex < createOp.baseCellsPerRow; cellIndex++) { - cells.push({ index: cellIndex }); - } - - const subdivideOps = doc.ops.filter( - (op) => - op.type === "subdivide" && - op.gridId === createOp.gridId && - op.rowIndex === rowIndex, - ) as Subdivide[]; - - subdivideOps.forEach((op) => { - cells = [ - ...cells.slice(0, op.startCellIndex), - ...Array.from({ length: op.subdivisions }, () => ({})), - ...cells.slice(op.endCellIndex + 1), - ]; - }); - - rows.push({ index: rowIndex, cells }); - } - - return { - id: createOp.gridId, - rows, - }; -} diff --git a/packages/web/src/element.ts b/packages/web/src/element.ts deleted file mode 100644 index 6299d2f..0000000 --- a/packages/web/src/element.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { createElement, type CreateElement } from "./html"; - -export default class NotiveElement extends HTMLElement { - static makeFactory(this: { - new (): T; - }): CreateElement; - - 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; - }); - }; -} - -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/packages/web/src/favicon.ico b/packages/web/src/favicon.ico deleted file mode 100644 index c10cfe9..0000000 Binary files a/packages/web/src/favicon.ico and /dev/null differ diff --git a/packages/web/src/grid.test.ts b/packages/web/src/grid.test.ts deleted file mode 100644 index 50c0626..0000000 --- a/packages/web/src/grid.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { expect, test } from "vitest"; -import defaultDoc from "./defaultDoc"; -import renderGrid from "./components/grid/renderGrid"; -import { changeSelectedSubdivisions } from "./grid"; -import { GridSelection } from "./components/grid/selection"; - -test("foo", () => { - const doc = defaultDoc(); - const grid = doc.grids[1]; - - const selection: GridSelection = { - activeCellRef: { partIndex: 0, rowIndex: 0, cellIndex: 0 }, - range: [ - { partIndex: 0, rowIndex: 0, cellIndex: 0 }, - { partIndex: 0, rowIndex: 0, cellIndex: 3 }, - ], - }; - - const newGrid = changeSelectedSubdivisions(grid, selection, 3); - const renderedGrid = renderGrid(newGrid); - - expect( - renderedGrid.renderedRows.map((row) => row.renderedCells.length), - ).toStrictEqual([15, 16, 16, 16]); - - expect( - newGrid.parts[0].rows[0].cells.map((cell) => cell.widthRatio.toData()), - ).toStrictEqual([ - [1, 12], - [1, 12], - [1, 12], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - [1, 16], - ]); -}); diff --git a/packages/web/src/grid.ts b/packages/web/src/grid.ts deleted file mode 100644 index e849803..0000000 --- a/packages/web/src/grid.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { produce } from "immer"; -import renderGrid, { getRenderedCell } from "./components/grid/renderGrid"; -import { getSelectionRange, GridSelection } from "./components/grid/selection"; -import Ratio from "./math/Ratio"; -import { Cell, Grid, renderedRowIndexToRef } from "./types"; - -export function getSelectedSubdivisionsCount( - grid: Grid, - selection: GridSelection, -): number | undefined { - const renderedGrid = renderGrid(grid); - - const [startCellRef, endCellRef] = getSelectionRange(selection); - const startCell = getRenderedCell(renderedGrid, startCellRef); - const endCell = getRenderedCell(renderedGrid, endCellRef); - - if (!startCell || !endCell) throw new Error("Invalid cell refs"); - - const startRenderedRowIndex = Math.min( - startCell.renderedRowIndex, - endCell.renderedRowIndex, - ); - - const endRenderedRowIndex = Math.max( - startCell.renderedRowIndex, - endCell.renderedRowIndex, - ); - - const startRatio = Ratio.min(startCell.startRatio, endCell.startRatio); - const endRatio = Ratio.max(startCell.endRatio, endCell.endRatio); - - return Math.min( - ...renderedGrid.renderedRows - .slice(startRenderedRowIndex, endRenderedRowIndex + 1) - .map((row) => { - const startCellIndex = row.renderedCells.findIndex((cell) => - cell.startRatio.equals(startRatio), - ); - - const endCellIndex = row.renderedCells.findLastIndex((cell) => - cell.endRatio.equals(endRatio), - ); - - return endCellIndex - startCellIndex + 1; - }), - ); -} - -export function changeSelectedSubdivisions( - grid: Grid, - selection: GridSelection, - subdivisions: number, -): Grid { - const renderedGrid = renderGrid(grid); - const [startCellRef, endCellRef] = getSelectionRange(selection); - const startCell = getRenderedCell(renderedGrid, startCellRef); - const endCell = getRenderedCell(renderedGrid, endCellRef); - if (!startCell || !endCell) throw new Error("Invalid cell refs"); - - const startRenderedRowIndex = Math.min( - startCell.renderedRowIndex, - endCell.renderedRowIndex, - ); - - const endRenderedRowIndex = Math.max( - startCell.renderedRowIndex, - endCell.renderedRowIndex, - ); - - const startRatio = Ratio.min(startCell.startRatio, endCell.startRatio); - const endRatio = Ratio.max(startCell.endRatio, endCell.endRatio); - const selectedWidthRatio = endRatio.subtract(startRatio); - const widthRatio = selectedWidthRatio.divideRatio( - Ratio.fromInteger(subdivisions), - ); - - return produce(grid, (draft) => { - for ( - let renderedRowIndex = startRenderedRowIndex; - renderedRowIndex <= endRenderedRowIndex; - renderedRowIndex++ - ) { - const renderedRow = renderedGrid.renderedRows[renderedRowIndex]; - - const startCellIndex = renderedRow.renderedCells.findIndex((cell) => - cell.startRatio.equals(startRatio), - ); - - const endCellIndex = renderedRow.renderedCells.findLastIndex((cell) => - cell.endRatio.equals(endRatio), - ); - - const { partIndex, rowIndex } = renderedRowIndexToRef( - grid, - renderedRowIndex, - ); - - const row = draft.parts[partIndex].rows[rowIndex]; - const previousCells = row.cells.slice(0, startCellIndex); - const nextCells = row.cells.slice(endCellIndex + 1); - - const newCells: Cell[] = Array.from({ length: subdivisions }, () => ({ - widthRatio, - })); - - row.cells = [...previousCells, ...newCells, ...nextCells]; - } - }); -} diff --git a/packages/web/src/html.ts b/packages/web/src/html.ts deleted file mode 100644 index 3fccda3..0000000 --- a/packages/web/src/html.ts +++ /dev/null @@ -1,50 +0,0 @@ -export function createElement( - tagName: string, - ...children: (Node | string)[] -): T; - -export function createElement( - tagName: string, - attrs: Partial, - ...children: (Node | string)[] -): T; - -export function createElement(tagName: string, ...args: any[]) { - const el = document.createElement(tagName); - - 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 = { - (...children: (Node | string)[]): T; - (attrs: Partial, ...children: (Node | string)[]): T; -}; - -type ElementCreator = { - [K in keyof HTMLElementTagNameMap]: CreateElement; -}; - -const h = new Proxy({} as ElementCreator, { - get: - (_, tagName: string) => - (...args: any[]) => { - return createElement(tagName, ...args); - }, -}); - -export default h; - -export function fragment(...children: (Node | string)[]): DocumentFragment { - const fragment = document.createDocumentFragment(); - fragment.append(...children); - return fragment; -} diff --git a/packages/web/src/index.css b/packages/web/src/index.css deleted file mode 100644 index f100378..0000000 --- a/packages/web/src/index.css +++ /dev/null @@ -1,10 +0,0 @@ -@import "tailwindcss"; - -body { - background: var(--color-neutral-800); - user-select: none; -} - -*:focus-visible { - outline: 2px solid var(--color-green-400); -} diff --git a/packages/web/src/index.html b/packages/web/src/index.html deleted file mode 100644 index 9f8bcbf..0000000 --- a/packages/web/src/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - Notive - - - - - - diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts deleted file mode 100644 index 857e76a..0000000 --- a/packages/web/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import ntvApp from "./components/app"; -import { State } from "@notive/doc"; - -const state = new State(); -state.create_grid(); -console.log(state.to_json()); - -document.body.append(ntvApp()); diff --git a/packages/web/src/math/Coord.ts b/packages/web/src/math/Coord.ts deleted file mode 100644 index db7ee6d..0000000 --- a/packages/web/src/math/Coord.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** A coord on a grid whose origin is in the top left. */ -export default class Coord { - private readonly _x: number; - private readonly _y: number; - - constructor(x: number, y: number) { - this._x = x; - this._y = y; - } - - get x(): number { - return this._x; - } - - get y(): number { - return this._y; - } - - /** Get the squared distance of this point from the origin. */ - squaredDistanceFromOrigin(): number { - return this._x * this._x + this._y * this._y; - } -} diff --git a/packages/web/src/math/Ratio.test.ts b/packages/web/src/math/Ratio.test.ts deleted file mode 100644 index da6fef2..0000000 --- a/packages/web/src/math/Ratio.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "vitest"; -import Ratio from "./Ratio"; - -describe(Ratio, () => { - describe(Ratio.prototype.add, () => { - test("returns fractions in simplest form", () => { - const a = Ratio.fromInteger(0); - const b = new Ratio(1, 4); - - const c = a.add(b); - expect(c.numerator).toBe(1); - expect(c.denominator).toBe(4); - - const d = c.add(b); - expect(d.numerator).toBe(1); - expect(d.denominator).toBe(2); - - const e = d.add(b); - expect(e.numerator).toBe(3); - expect(e.denominator).toBe(4); - - const f = e.add(b); - expect(f.numerator).toBe(1); - expect(f.denominator).toBe(1); - }); - }); -}); diff --git a/packages/web/src/math/Ratio.ts b/packages/web/src/math/Ratio.ts deleted file mode 100644 index e2a1fbf..0000000 --- a/packages/web/src/math/Ratio.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { gcd } from "."; - -/** Serializable representation of a ratio. */ -export type RatioData = [numerator: number, denominator: number]; - -/** Representation of a ratio for performing fractional artithmetic. */ -export default class Ratio { - readonly #numerator: number; - readonly #denominator: number; - - get numerator(): number { - return this.#numerator; - } - - get denominator(): number { - return this.#denominator; - } - - constructor(numerator: number, denominator: number) { - if (!Number.isInteger(numerator) || !Number.isInteger(denominator)) { - throw new TypeError( - `Ratio must have integer parts: ${numerator} / ${denominator}`, - ); - } - - if (denominator === 0) { - throw new RangeError("Ratio demnominator cannot be zero"); - } - - const divisor = gcd(numerator, denominator); - - this.#numerator = numerator / divisor; - this.#denominator = denominator / divisor; - } - - multiplyRatio(other: Ratio): Ratio { - return new Ratio( - this.numerator * other.numerator, - this.denominator * other.denominator, - ); - } - - divideRatio(other: Ratio): Ratio { - return new Ratio( - this.numerator * other.denominator, - this.denominator * other.numerator, - ); - } - - add(other: Ratio): Ratio { - return new Ratio( - this.numerator * other.denominator + other.numerator * this.denominator, - this.denominator * other.denominator, - ); - } - - subtract(other: Ratio): Ratio { - return new Ratio( - this.numerator * other.denominator - other.numerator * this.denominator, - this.denominator * other.denominator, - ); - } - - compare(other: Ratio): number { - const left = this.numerator * other.denominator; - const right = other.numerator * this.denominator; - return left < right ? -1 : left > right ? 1 : 0; - } - - equals(other: Ratio): boolean { - return this.compare(other) === 0; - } - - toNumber(): number { - return this.numerator / this.denominator; - } - - toString(): string { - return `${this.numerator}/${this.denominator}`; - } - - [Symbol.for("nodejs.util.inspect.custom")](): string { - return `Ratio { ${this.numerator}/${this.denominator} }`; - } - - static fromInteger(n: number): Ratio { - return new Ratio(n, 1); - } - - toData(): RatioData { - return [this.numerator, this.denominator]; - } - - static fromData(ratio: RatioData): Ratio { - return new Ratio(ratio[0], ratio[1]); - } - - static min(...ratios: Ratio[]): Ratio { - return ratios.reduce((a, b) => (a.compare(b) <= 0 ? a : b)); - } - - static max(...ratios: Ratio[]): Ratio { - return ratios.reduce((a, b) => (a.compare(b) >= 0 ? a : b)); - } -} diff --git a/packages/web/src/math/Rect.ts b/packages/web/src/math/Rect.ts deleted file mode 100644 index f52a2f7..0000000 --- a/packages/web/src/math/Rect.ts +++ /dev/null @@ -1,104 +0,0 @@ -import Coord from "./Coord"; - -/** A rectangle on a grid whose origin is in the top left. */ -export default class Rect { - private readonly _topLeft: Coord; - private readonly _width: number; - private readonly _height: number; - - constructor( - topLeftX: number, - topLeftY: number, - width: number, - height: number, - ) { - this._topLeft = new Coord(topLeftX, topLeftY); - this._width = width; - this._height = height; - } - - /** Width of this rectangle. */ - get width(): number { - return this._width; - } - - /** Height of this rectangle. */ - get height(): number { - return this._height; - } - - /** Coord of the top-left point of this rectangle. */ - get topLeft(): Coord { - return this._topLeft; - } - - /** Coord of the bottom-right point of this rectangle. */ - get bottomRight(): Coord { - return new Coord( - this._topLeft.x + this._width, - this._topLeft.y + this._height, - ); - } - - get center(): Coord { - return new Coord( - this.topLeft.x + (this.bottomRight.x - this.topLeft.x) / 2, - this.topLeft.y + (this.bottomRight.y - this.topLeft.y) / 2, - ); - } - - /** Determine if this rectangle contains the point at `coord`. */ - containsCoord(coord: Coord): boolean { - return ( - this.topLeft.x <= coord.x && - coord.x <= this.bottomRight.x && - this.topLeft.y <= coord.y && - coord.y <= this.bottomRight.y - ); - } - - verticallyContainsCoord(coord: Coord): boolean { - return this.topLeft.y <= coord.y && coord.y <= this.bottomRight.y; - } - - horizontallyContainsCoord(coord: Coord): boolean { - return this.topLeft.x <= coord.x && coord.x <= this.bottomRight.x; - } - - extend(other: Rect): Rect { - const topLeftX = Math.min( - this.topLeft.x, - this.bottomRight.x, - other.topLeft.x, - other.bottomRight.x, - ); - - const topLeftY = Math.min( - this.topLeft.y, - this.bottomRight.y, - other.topLeft.y, - other.bottomRight.y, - ); - - const bottomRightX = Math.max( - this.topLeft.x, - this.bottomRight.x, - other.topLeft.x, - other.bottomRight.x, - ); - - const bottomRightY = Math.max( - this.topLeft.y, - this.bottomRight.y, - other.topLeft.y, - other.bottomRight.y, - ); - - return new Rect( - topLeftX, - topLeftY, - bottomRightX - topLeftX, - bottomRightY - topLeftY, - ); - } -} diff --git a/packages/web/src/math/index.ts b/packages/web/src/math/index.ts deleted file mode 100644 index 70dbb67..0000000 --- a/packages/web/src/math/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function gcd(a: number, b: number): number { - return b === 0 ? Math.abs(a) : gcd(b, a % b); -} diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts deleted file mode 100644 index dc26c89..0000000 --- a/packages/web/src/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Immutable } from "immer"; -import Ratio from "./math/Ratio"; - -export type Cell = Immutable<{ - value?: string; - widthRatio: Ratio; -}>; - -export interface Row { - cells: Cell[]; -} - -export interface Part { - title?: string; - rows: Row[]; -} - -export interface Grid { - id: string; - baseCellSize: number; - baseCellWidthRatio: Ratio; - parts: Part[]; -} - -export interface Doc { - grids: Grid[]; -} - -export interface RowRef { - partIndex: number; - rowIndex: number; -} - -export interface CellRef { - partIndex: number; - 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 - ); -} - -export function renderedRowIndexToRef( - grid: Grid, - renderedRowIndex: number, -): RowRef { - const partIndex = renderedRowIndex % grid.parts.length; - const rowIndex = Math.floor(renderedRowIndex / grid.parts.length); - return { partIndex, rowIndex }; -} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json deleted file mode 100644 index b650d24..0000000 --- a/packages/web/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "moduleResolution": "bundler", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true - }, - "include": ["src/*.ts", "src/**/*.ts"] -} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts deleted file mode 100644 index 7f8e924..0000000 --- a/packages/web/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import tailwindcss from "@tailwindcss/vite"; -import wasm from "vite-plugin-wasm"; -import { defineConfig } from "vite"; - -export default defineConfig({ - root: "src", - plugins: [tailwindcss(), wasm()], -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21d4127..4ace30d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,23 +18,17 @@ importers: specifier: ^2.5.19 version: 2.5.19(prettier@3.6.2) turbo: - specifier: ^2.6.0 - version: 2.6.0 + specifier: ^2.6.1 + version: 2.6.1 typescript: specifier: ^5.9.3 version: 5.9.3 - packages/doc: - devDependencies: - wasm-pack: - specifier: ^0.13.1 - version: 0.13.1 - - packages/web: + apps/web: dependencies: '@notive/doc': specifier: workspace:* - version: link:../doc + version: link:../../packages/doc immer: specifier: ^10.2.0 version: 10.2.0 @@ -58,6 +52,12 @@ importers: specifier: ^4.0.6 version: 4.0.6(jiti@2.6.1)(lightningcss@1.30.2) + packages/doc: + devDependencies: + wasm-pack: + specifier: ^0.13.1 + version: 0.13.1 + packages: '@babel/runtime@7.28.4': @@ -845,38 +845,38 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - turbo-darwin-64@2.6.0: - resolution: {integrity: sha512-6vHnLAubHj8Ib45Knu+oY0ZVCLO7WcibzAvt5b1E72YHqAs4y8meMAGMZM0jLqWPh/9maHDc16/qBCMxtW4pXg==} + turbo-darwin-64@2.6.1: + resolution: {integrity: sha512-Dm0HwhyZF4J0uLqkhUyCVJvKM9Rw7M03v3J9A7drHDQW0qAbIGBrUijQ8g4Q9Cciw/BXRRd8Uzkc3oue+qn+ZQ==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.6.0: - resolution: {integrity: sha512-IU+gWMEXNBw8H0pxvE7nPEa5p6yahxbN8g/Q4Bf0AHymsAFqsScgV0peeNbWybdmY9jk1LPbALOsF2kY1I7ZiQ==} + turbo-darwin-arm64@2.6.1: + resolution: {integrity: sha512-U0PIPTPyxdLsrC3jN7jaJUwgzX5sVUBsKLO7+6AL+OASaa1NbT1pPdiZoTkblBAALLP76FM0LlnsVQOnmjYhyw==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.6.0: - resolution: {integrity: sha512-CKoiJ2ZFJLCDsWdRlZg+ew1BkGn8iCEGdePhISVpjsGwkJwSVhVu49z2zKdBeL1IhcSKS2YALwp9ellNZANJxw==} + turbo-linux-64@2.6.1: + resolution: {integrity: sha512-eM1uLWgzv89bxlK29qwQEr9xYWBhmO/EGiH22UGfq+uXr+QW1OvNKKMogSN65Ry8lElMH4LZh0aX2DEc7eC0Mw==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.6.0: - resolution: {integrity: sha512-WroVCdCvJbrhNxNdw7XB7wHAfPPJPV+IXY+ZKNed+9VdfBu/2mQNfKnvqTuFTH7n+Pdpv8to9qwhXRTJe26upg==} + turbo-linux-arm64@2.6.1: + resolution: {integrity: sha512-MFFh7AxAQAycXKuZDrbeutfWM5Ep0CEZ9u7zs4Hn2FvOViTCzIfEhmuJou3/a5+q5VX1zTxQrKGy+4Lf5cdpsA==} cpu: [arm64] os: [linux] - turbo-windows-64@2.6.0: - resolution: {integrity: sha512-7pZo5aGQPR+A7RMtWCZHusarJ6y15LQ+o3jOmpMxTic/W6Bad+jSeqo07TWNIseIWjCVzrSv27+0odiYRYtQdA==} + turbo-windows-64@2.6.1: + resolution: {integrity: sha512-buq7/VAN7KOjMYi4tSZT5m+jpqyhbRU2EUTTvp6V0Ii8dAkY2tAAjQN1q5q2ByflYWKecbQNTqxmVploE0LVwQ==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.6.0: - resolution: {integrity: sha512-1Ty+NwIksQY7AtFUCPrTpcKQE7zmd/f7aRjdT+qkqGFQjIjFYctEtN7qo4vpQPBgCfS1U3ka83A2u/9CfJQ3wQ==} + turbo-windows-arm64@2.6.1: + resolution: {integrity: sha512-7w+AD5vJp3R+FB0YOj1YJcNcOOvBior7bcHTodqp90S3x3bLgpr7tE6xOea1e8JkP7GK6ciKVUpQvV7psiwU5Q==} cpu: [arm64] os: [win32] - turbo@2.6.0: - resolution: {integrity: sha512-kC5VJqOXo50k0/0jnJDDjibLAXalqT9j7PQ56so0pN+81VR4Fwb2QgIE9dTzT3phqOTQuEXkPh3sCpnv5Isz2g==} + turbo@2.6.1: + resolution: {integrity: sha512-qBwXXuDT3rA53kbNafGbT5r++BrhRgx3sAo0cHoDAeG9g1ItTmUMgltz3Hy7Hazy1ODqNpR+C7QwqL6DYB52yA==} hasBin: true typescript@5.9.3: @@ -1609,32 +1609,32 @@ snapshots: tslib@2.8.1: {} - turbo-darwin-64@2.6.0: + turbo-darwin-64@2.6.1: optional: true - turbo-darwin-arm64@2.6.0: + turbo-darwin-arm64@2.6.1: optional: true - turbo-linux-64@2.6.0: + turbo-linux-64@2.6.1: optional: true - turbo-linux-arm64@2.6.0: + turbo-linux-arm64@2.6.1: optional: true - turbo-windows-64@2.6.0: + turbo-windows-64@2.6.1: optional: true - turbo-windows-arm64@2.6.0: + turbo-windows-arm64@2.6.1: optional: true - turbo@2.6.0: + turbo@2.6.1: optionalDependencies: - turbo-darwin-64: 2.6.0 - turbo-darwin-arm64: 2.6.0 - turbo-linux-64: 2.6.0 - turbo-linux-arm64: 2.6.0 - turbo-windows-64: 2.6.0 - turbo-windows-arm64: 2.6.0 + turbo-darwin-64: 2.6.1 + turbo-darwin-arm64: 2.6.1 + turbo-linux-64: 2.6.1 + turbo-linux-arm64: 2.6.1 + turbo-windows-64: 2.6.1 + turbo-windows-arm64: 2.6.1 typescript@5.9.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 924b55f..c34b5d5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,7 @@ packages: + - apps/* - packages/* + +onlyBuiltDependencies: + - esbuild + - wasm-pack diff --git a/turbo.json b/turbo.json index d62812e..85a9fd8 100644 --- a/turbo.json +++ b/turbo.json @@ -1,8 +1,12 @@ { "$schema": "https://turborepo.com/schema.json", "tasks": { + "build:dev": { + "dependsOn": ["^build:dev"], + "outputs": ["dist/**"] + }, "dev": { - "dependsOn": ["^build"], + "dependsOn": ["build:dev", "^build:dev"], "cache": false, "persistent": true }, @@ -11,7 +15,12 @@ "persistent": true }, "build": { - "outputs": ["dist/**"] + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "inputs": ["src/**", "Cargo.toml", "Cargo.lock", "package.json"] + }, + "clean": { + "cache": false } } } -- cgit v1.2.3