diff options
| author | Josh Kingsley <josh@joshkingsley.me> | 2025-10-26 14:28:55 +0200 |
|---|---|---|
| committer | Josh Kingsley <josh@joshkingsley.me> | 2025-10-26 14:28:55 +0200 |
| commit | 0324660a26684a5382b2c6c18cd0a4e9f0169631 (patch) | |
| tree | 64c16e8a4a4815f050f7e06a3b9486a668f2b4d4 /web | |
| parent | 1b8d05bf83d7bd9ab425852f519ea81bdc379444 (diff) | |
feat(web): add dummy toolbar + tailwindcss colors
Diffstat (limited to 'web')
| -rw-r--r-- | web/package.json | 2 | ||||
| -rw-r--r-- | web/src/components/app/index.css | 9 | ||||
| -rw-r--r-- | web/src/components/app/index.ts | 3 | ||||
| -rw-r--r-- | web/src/components/grid/cellAtCoord.ts | 40 | ||||
| -rw-r--r-- | web/src/components/grid/drawGrid.ts | 95 | ||||
| -rw-r--r-- | web/src/components/grid/index.css | 1 | ||||
| -rw-r--r-- | web/src/components/grid/index.ts | 36 | ||||
| -rw-r--r-- | web/src/components/grid/renderGrid.ts | 2 | ||||
| -rw-r--r-- | web/src/components/index.ts | 1 | ||||
| -rw-r--r-- | web/src/components/toolbar/index.css | 50 | ||||
| -rw-r--r-- | web/src/components/toolbar/index.ts | 24 | ||||
| -rw-r--r-- | web/src/html.ts | 10 | ||||
| -rw-r--r-- | web/src/index.css | 10 | ||||
| -rw-r--r-- | web/src/index.ts | 38 | ||||
| -rw-r--r-- | web/src/selection.ts | 19 | ||||
| -rw-r--r-- | web/vite.config.ts | 8 |
16 files changed, 317 insertions, 31 deletions
diff --git a/web/package.json b/web/package.json index e15cd1e..a565e36 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,8 @@ "open-color": "^1.9.1" }, "devDependencies": { + "@tailwindcss/vite": "^4.1.16", + "tailwindcss": "^4.1.16", "vite": "^7.1.12" } } diff --git a/web/src/components/app/index.css b/web/src/components/app/index.css index 3eeaee9..aaf2ced 100644 --- a/web/src/components/app/index.css +++ b/web/src/components/app/index.css @@ -1,3 +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/web/src/components/app/index.ts b/web/src/components/app/index.ts index 2782e22..910aa52 100644 --- a/web/src/components/app/index.ts +++ b/web/src/components/app/index.ts @@ -1,9 +1,12 @@ +import h from "../../html"; import ntvGrid from "../grid"; +import ntvToolbar from "../toolbar"; import "./index.css"; class NotiveAppElement extends HTMLElement { connectedCallback() { this.append( + ntvToolbar(), ...window.notive.doc.grids.map((grid) => { return ntvGrid({ gridId: grid.id }); }), diff --git a/web/src/components/grid/cellAtCoord.ts b/web/src/components/grid/cellAtCoord.ts new file mode 100644 index 0000000..dd594a4 --- /dev/null +++ b/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/web/src/components/grid/drawGrid.ts b/web/src/components/grid/drawGrid.ts index 6284693..01240b5 100644 --- a/web/src/components/grid/drawGrid.ts +++ b/web/src/components/grid/drawGrid.ts @@ -1,16 +1,21 @@ -import { RenderedGrid } from "./renderGrid"; -import colors from "open-color"; +import colors from "tailwindcss/colors"; +import { PendingSelection, Selection } from "../../selection"; +import { CellRef } from "../../types"; +import { RenderedCell, RenderedGrid } from "./renderGrid"; -export default function drawGrid( - ctx: CanvasRenderingContext2D, - grid: RenderedGrid, -) { +function fillBackground(ctx: CanvasRenderingContext2D, grid: RenderedGrid) { ctx.clearRect(0, 0, grid.rect.width, grid.rect.height); - - ctx.fillStyle = colors.gray[8]; + ctx.fillStyle = colors.neutral[800]; ctx.fillRect(0, 0, grid.rect.width, grid.rect.height); +} + +function strokeGrid(ctx: CanvasRenderingContext2D, grid: RenderedGrid) { + ctx.strokeStyle = colors.neutral[700]; + ctx.strokeRect(0.5, 0.5, grid.rect.width - 1, grid.rect.height - 1); +} - ctx.strokeStyle = colors.gray[7]; +function strokeGridLines(ctx: CanvasRenderingContext2D, grid: RenderedGrid) { + ctx.strokeStyle = colors.neutral[700]; grid.renderedRows.forEach((row, renderedRowIndex) => { const isLastRow = renderedRowIndex === grid.renderedRows.length - 1; @@ -28,3 +33,75 @@ export default function drawGrid( }); }); } + +function getRenderedCell( + grid: RenderedGrid, + cellRef: CellRef, +): RenderedCell | undefined { + const rowsPerPart = grid.renderedRows.length / grid.parts.length; + const renderedRowIndex = cellRef.partIndex * rowsPerPart + cellRef.rowIndex; + return grid.renderedRows[renderedRowIndex]?.renderedCells[cellRef.cellIndex]; +} + +function drawPendingSelection( + ctx: CanvasRenderingContext2D, + grid: RenderedGrid, + selection: PendingSelection, +) {} + +function drawSelection( + ctx: CanvasRenderingContext2D, + grid: RenderedGrid, + selection: Selection, +) { + if (selection.gridId !== grid.id) return; + + const cell = getRenderedCell(grid, selection.activeCellRef); + + if (!cell) return; + + const isLastCell = cell.rect.bottomRight.x === grid.rect.bottomRight.x; + const isLastRow = cell.rect.bottomRight.y === grid.rect.bottomRight.y; + + // ctx.fillStyle = colors.green[4] + "30"; + + // ctx.fillRect( + // cell.rect.topLeft.x + 1, + // cell.rect.topLeft.y + 1, + // cell.rect.width - 1, + // cell.rect.height - 1, + // ); + + ctx.strokeStyle = colors.green[600]; + 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, + ); +} + +export default function drawGrid( + ctx: CanvasRenderingContext2D, + grid: RenderedGrid, + selection?: Selection, + pendingSelection?: PendingSelection, +) { + const excursion = (f: () => void) => { + ctx.save(); + f(); + ctx.restore(); + }; + + excursion(() => fillBackground(ctx, grid)); + excursion(() => strokeGridLines(ctx, grid)); + excursion(() => strokeGrid(ctx, grid)); + + if (pendingSelection) { + excursion(() => drawPendingSelection(ctx, grid, pendingSelection)); + } else if (selection) { + excursion(() => drawSelection(ctx, grid, selection)); + } +} diff --git a/web/src/components/grid/index.css b/web/src/components/grid/index.css index 0fad720..a733015 100644 --- a/web/src/components/grid/index.css +++ b/web/src/components/grid/index.css @@ -1,6 +1,5 @@ ntv-grid { display: block; - padding: 1.5rem; } ntv-grid > canvas { diff --git a/web/src/components/grid/index.ts b/web/src/components/grid/index.ts index 829a511..0acace4 100644 --- a/web/src/components/grid/index.ts +++ b/web/src/components/grid/index.ts @@ -1,5 +1,5 @@ import h, { type CreateElement } from "../../html"; -import renderGrid from "./renderGrid"; +import cellAtCoord from "./cellAtCoord"; import drawGrid from "./drawGrid"; import "./index.css"; @@ -15,6 +15,10 @@ class NotiveGridElement extends HTMLElement { this.setAttribute("grid-id", val); } + get renderedGrid() { + return window.notive.getGrid(this.#gridId)!; + } + canvasEl: HTMLCanvasElement = h.canvas(); connectedCallback() { @@ -22,19 +26,41 @@ class NotiveGridElement extends HTMLElement { throw new Error("ntv-grid requries gridId attribute"); } + this.canvasEl.addEventListener("mousedown", (event) => { + const clientRect = this.canvasEl.getBoundingClientRect(); + const x = event.x - clientRect.x; + const y = event.y - clientRect.y; + const cellRef = cellAtCoord(this.renderedGrid, x, y); + if (!cellRef) return; + window.notive.selectCell(this.#gridId, cellRef); + }); + + window.addEventListener("ntv:selection-changed", () => { + this.draw(); + }); + this.append(this.canvasEl); this.draw(); } draw() { const ctx = this.canvasEl.getContext("2d"); + if (!ctx) throw new Error("Unable to get canvas context"); + const grid = window.notive.getGrid(this.gridId); + if (!grid) return; - const renderedGrid = renderGrid(grid); - this.canvasEl.setAttribute("width", renderedGrid.rect.width + "px"); - this.canvasEl.setAttribute("height", renderedGrid.rect.height + "px"); - drawGrid(ctx, renderedGrid); + + this.canvasEl.setAttribute("width", grid.rect.width + "px"); + this.canvasEl.setAttribute("height", grid.rect.height + "px"); + + drawGrid( + ctx, + grid, + window.notive.selection, + window.notive.pendingSelection, + ); } } diff --git a/web/src/components/grid/renderGrid.ts b/web/src/components/grid/renderGrid.ts index 5666f66..7ef8813 100644 --- a/web/src/components/grid/renderGrid.ts +++ b/web/src/components/grid/renderGrid.ts @@ -1,6 +1,6 @@ import Ratio from "../../math/Ratio"; import Rect from "../../math/Rect"; -import { Cell, CellRef, Grid, Row, RowRef } from "./types"; +import { Cell, CellRef, Grid, Row, RowRef } from "../../types"; export interface RenderedCell extends Cell { cellRef: CellRef; diff --git a/web/src/components/index.ts b/web/src/components/index.ts index 8bc14e7..b7f6f55 100644 --- a/web/src/components/index.ts +++ b/web/src/components/index.ts @@ -1,2 +1,3 @@ import "./app"; import "./grid"; +import "./toolbar"; diff --git a/web/src/components/toolbar/index.css b/web/src/components/toolbar/index.css new file mode 100644 index 0000000..3f78671 --- /dev/null +++ b/web/src/components/toolbar/index.css @@ -0,0 +1,50 @@ +ntv-toolbar { + display: flex; + border-radius: 4px; + background: var(--color-neutral-800); + width: min-content; +} + +ntv-toolbar > section { + display: flex; + gap: 0.5rem; + padding: 0.5rem; +} + +ntv-toolbar button[data-variant="menu"] { + border-radius: 4px; + background: var(--color-neutral-700); + padding: 0 0.625rem; + height: 1.5rem; + color: white; + font-size: 0.75rem; +} + +ntv-toolbar button[data-variant="icon"] { + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + background: var(--color-neutral-700); + padding: 0.125rem 0.625rem; + aspect-ratio: 1; + height: 1.5rem; + color: white; + font-weight: 600; + font-size: 0.75rem; +} + +ntv-toolbar button:hover { + background: var(--color-neutral-600); +} + +ntv-toolbar input { + border: 1px solid var(--color-neutral-700); + border-radius: 4px; + background: var(--color-neutral-900); + width: 2.5rem; + height: 1.5rem; + color: white; + font-size: 0.75rem; + text-align: center; +} diff --git a/web/src/components/toolbar/index.ts b/web/src/components/toolbar/index.ts new file mode 100644 index 0000000..d844a69 --- /dev/null +++ b/web/src/components/toolbar/index.ts @@ -0,0 +1,24 @@ +import h, { CreateElement } from "../../html"; +import "./index.css"; + +class NotiveToolbarElement extends HTMLElement { + connectedCallback() { + this.append( + h.section( + h.button({ dataset: { variant: "menu" } }, "File"), + h.button({ dataset: { variant: "menu" } }, "Edit"), + h.button({ dataset: { variant: "menu" } }, "Format"), + ), + h.section( + h.button({ dataset: { variant: "icon" } }, "-"), + h.input({ type: "text", value: "1" }), + h.button({ dataset: { variant: "icon" } }, "+"), + ), + ); + } +} + +customElements.define("ntv-toolbar", NotiveToolbarElement); + +export default ((...args: any[]): NotiveToolbarElement => + (h as any)["ntv-toolbar"](...args)) as CreateElement<NotiveToolbarElement>; diff --git a/web/src/html.ts b/web/src/html.ts index 5bfff21..8802e50 100644 --- a/web/src/html.ts +++ b/web/src/html.ts @@ -13,8 +13,14 @@ const h = new Proxy({} as ElementCreator, { (...args: any[]) => { const el = document.createElement(tag); - if (typeof args[0] === "object") { - Object.assign(el, args.shift()); + if (args[0]?.constructor === Object) { + const { dataset, ...attrs } = args.shift(); + + Object.assign(el, attrs); + + if (dataset) { + Object.assign(el.dataset, dataset); + } } el.append(...args.flat()); diff --git a/web/src/index.css b/web/src/index.css index ba2a6a7..4fe2764 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,11 +1,5 @@ -@import "open-color"; +@import "tailwindcss"; body { - margin: 0; - background: var(--oc-gray-9); - color: var(--oc-white); - font-weight: normal; - font-family: - Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, - sans-serif; + background: var(--color-neutral-900); } diff --git a/web/src/index.ts b/web/src/index.ts index fbbf37c..ac4870c 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -1,5 +1,7 @@ import Ratio from "./math/Ratio"; -import { Cell, Doc, Grid } from "./types"; +import { Cell, CellRef, Doc, Grid } from "./types"; +import { ActiveCellSelection, PendingSelection, Selection } from "./selection"; +import renderGrid, { RenderedGrid } from "./components/grid/renderGrid"; function defaultDoc(): Doc { const defaultCells: Cell[] = Array.from({ length: 16 }, () => ({ @@ -20,17 +22,47 @@ function defaultDoc(): Doc { }, ], }, + { + id: window.crypto.randomUUID(), + baseCellSize: 48, + baseCellWidthRatio: new Ratio(1, 16), + parts: [ + { + rows: Array.from({ length: 4 }, () => ({ + cells: [...defaultCells], + })), + }, + ], + }, ], }; } export default class Notive { doc: Doc = defaultDoc(); - gridsById = Object.fromEntries(this.doc.grids.map((grid) => [grid.id, grid])); - getGrid(id: string): Grid | undefined { + gridsById = Object.fromEntries( + this.doc.grids.map((grid) => [grid.id, renderGrid(grid)]), + ); + + selection?: Selection; + + pendingSelection?: Selection; + + getGrid(id: string): RenderedGrid | undefined { return this.gridsById[id]; } + + selectCell(gridId: string, cellRef: CellRef) { + const previousSelection = this.selection; + this.selection = new ActiveCellSelection(gridId, cellRef); + + window.dispatchEvent( + new CustomEvent("ntv:selection-changed", { + detail: { selection: this.selection, previousSelection }, + }), + ); + } } window.notive = new Notive(); diff --git a/web/src/selection.ts b/web/src/selection.ts new file mode 100644 index 0000000..88d394b --- /dev/null +++ b/web/src/selection.ts @@ -0,0 +1,19 @@ +import { CellRef } from "./types"; + +export abstract class Selection { + readonly gridId: string; + readonly activeCellRef: CellRef; + + constructor(gridId: string, activeCellRef: CellRef) { + this.gridId = gridId; + this.activeCellRef = activeCellRef; + } +} + +export class ActiveCellSelection extends Selection {} + +export class RangeSelection extends Selection {} + +export class AllSelection extends Selection {} + +export class PendingSelection extends Selection {} diff --git a/web/vite.config.ts b/web/vite.config.ts index d59c396..21088ad 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,3 +1,7 @@ -export default { +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ root: "src", -}; + plugins: [tailwindcss()], +}); |
