summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJosh Kingsley <josh@joshkingsley.me>2025-10-29 01:54:37 +0200
committerJosh Kingsley <josh@joshkingsley.me>2025-10-29 01:54:37 +0200
commit986e65f9ab7122995ae1d647df23d817cecf6816 (patch)
tree39d4f77c6a6565b59522a5a77e2f550334472713
parent95069f13d908bfd3c0f3b33f8fad7d8464fd192e (diff)
refactor(web): improve state management
-rw-r--r--web/src/components/app/index.ts40
-rw-r--r--web/src/components/grid/drawGrid.ts136
-rw-r--r--web/src/components/grid/drawSelection.ts97
-rw-r--r--web/src/components/grid/excursion.ts8
-rw-r--r--web/src/components/grid/index.css73
-rw-r--r--web/src/components/grid/index.ts223
-rw-r--r--web/src/defaultDoc.ts42
7 files changed, 381 insertions, 238 deletions
diff --git a/web/src/components/app/index.ts b/web/src/components/app/index.ts
index ec40754..195011f 100644
--- a/web/src/components/app/index.ts
+++ b/web/src/components/app/index.ts
@@ -1,14 +1,44 @@
-import ntvGrid from "../grid";
+import defaultDoc from "../../defaultDoc";
+import { Selection } from "../../selection";
+import { Doc } from "../../types";
+import ntvGrid, { NotiveGridElement } from "../grid";
+import renderGrid from "../grid/renderGrid";
import ntvToolbar from "../toolbar";
import "./index.css";
-class NotiveAppElement extends HTMLElement {
+export class NotiveAppElement extends HTMLElement {
+ doc: Doc = defaultDoc();
+ #selection?: Selection;
+
+ get selection() {
+ return this.#selection;
+ }
+
+ set selection(selection: Selection | undefined) {
+ this.#selection = selection;
+ this.#updateGridSelections();
+ }
+
+ #updateGridSelections() {
+ this.querySelectorAll<NotiveGridElement>("ntv-grid").forEach((grid) => {
+ grid.selection =
+ this.#selection?.gridId === grid.grid?.id ? this.#selection : undefined;
+ });
+ }
+
connectedCallback() {
this.append(
ntvToolbar(),
- ...window.notive.doc.grids.map((grid) => {
- return ntvGrid({ gridId: grid.id });
- }),
+
+ ...this.doc.grids.map((grid) =>
+ ntvGrid({
+ grid: renderGrid(grid),
+ dataset: { gridId: grid.id },
+ ongridselectionchange: (event) => {
+ this.selection = event.selection;
+ },
+ }),
+ ),
);
}
}
diff --git a/web/src/components/grid/drawGrid.ts b/web/src/components/grid/drawGrid.ts
index 498abd5..da83c8e 100644
--- a/web/src/components/grid/drawGrid.ts
+++ b/web/src/components/grid/drawGrid.ts
@@ -2,33 +2,14 @@ import { RangeSelection, Selection } from "../../selection";
import { CellRef } from "../../types";
import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid";
-interface GridStyles {
+export interface GridStyles {
bgFill: string;
borderStroke: string;
cellStroke: string;
- selelectionRangeFill: string;
- selectionRangeStroke: string;
- activeCellStroke: string;
cellValueFont: string;
cellValueLineHeight: string;
}
-export function getGridStyles(el: HTMLElement): GridStyles {
- const style = window.getComputedStyle(el);
- const prop = (k: string) => style.getPropertyValue(k);
-
- return {
- bgFill: prop("--grid-bg-fill"),
- borderStroke: prop("--grid-border-stroke"),
- cellStroke: prop("--grid-cell-stroke"),
- selelectionRangeFill: prop("--grid-selection-range-fill"),
- selectionRangeStroke: prop("--grid-selection-range-stroke"),
- activeCellStroke: prop("--grid-active-cell-stroke"),
- cellValueFont: prop("font"),
- cellValueLineHeight: prop("line-height"),
- };
-}
-
function excursion(ctx: CanvasRenderingContext2D, f: () => void) {
ctx.save();
f();
@@ -78,111 +59,6 @@ function strokeGridLines(
});
}
-function strokeActiveCell(
- ctx: CanvasRenderingContext2D,
- styles: GridStyles,
- grid: RenderedGrid,
- cell: RenderedCell,
-) {
- const isLastCell = cell.rect.bottomRight.x === grid.rect.bottomRight.x;
- const isLastRow = cell.rect.bottomRight.y === grid.rect.bottomRight.y;
-
- ctx.strokeStyle = styles.activeCellStroke;
- ctx.lineWidth = 2;
-
- ctx.strokeRect(
- cell.rect.topLeft.x + 1,
- cell.rect.topLeft.y + 1,
- isLastCell ? cell.rect.width - 2 : cell.rect.width - 1,
- isLastRow ? cell.rect.height - 2 : cell.rect.height - 1,
- );
-}
-
-function drawCellRange(
- ctx: CanvasRenderingContext2D,
- styles: GridStyles,
- grid: RenderedGrid,
- start: CellRef,
- end: CellRef,
- { stroke }: { stroke: boolean },
-) {
- const startCell = getRenderedCell(grid, start);
- const endCell = getRenderedCell(grid, end);
-
- if (!startCell || !endCell) return;
-
- const rect = startCell.rect.extend(endCell.rect);
-
- const isRightEdge = rect.bottomRight.x === grid.rect.bottomRight.x;
- const isBottomEdge = rect.bottomRight.y === grid.rect.bottomRight.y;
-
- ctx.fillStyle = styles.selelectionRangeFill;
-
- ctx.fillRect(
- rect.topLeft.x + 1,
- rect.topLeft.y + 1,
- isRightEdge ? rect.width - 2 : rect.width - 1,
- isBottomEdge ? rect.height - 2 : rect.height - 1,
- );
-
- if (!stroke) return;
-
- ctx.strokeStyle = styles.selectionRangeStroke;
-
- ctx.strokeRect(
- rect.topLeft.x + 0.5,
- rect.topLeft.y + 0.5,
- isRightEdge ? rect.width - 1 : rect.width,
- isBottomEdge ? rect.height - 1 : rect.height,
- );
-}
-
-function drawPendingSelection(
- ctx: CanvasRenderingContext2D,
- styles: GridStyles,
- grid: RenderedGrid,
- selection: Selection,
-) {
- if (selection.gridId !== grid.id) return;
-
- const activeCell = getRenderedCell(grid, selection.activeCellRef);
-
- if (!activeCell) return;
-
- if (selection instanceof RangeSelection) {
- excursion(ctx, () => {
- drawCellRange(ctx, styles, grid, selection.range[0], selection.range[1], {
- stroke: false,
- });
- });
- }
-
- excursion(ctx, () => strokeActiveCell(ctx, styles, grid, activeCell));
-}
-
-function drawSelection(
- ctx: CanvasRenderingContext2D,
- styles: GridStyles,
- grid: RenderedGrid,
- selection: Selection,
-) {
- if (selection.gridId !== grid.id) return;
-
- const activeCell = getRenderedCell(grid, selection.activeCellRef);
-
- if (!activeCell) return;
-
- if (selection instanceof RangeSelection) {
- excursion(ctx, () => {
- drawCellRange(ctx, styles, grid, selection.range[0], selection.range[1], {
- stroke: true,
- });
- });
- }
-
- excursion(ctx, () => strokeActiveCell(ctx, styles, grid, activeCell));
-}
-
function drawCellValues(
ctx: CanvasRenderingContext2D,
styles: GridStyles,
@@ -207,19 +83,9 @@ export default function drawGrid(
ctx: CanvasRenderingContext2D,
styles: GridStyles,
grid: RenderedGrid,
- selection?: Selection,
- pendingSelection?: Selection,
) {
excursion(ctx, () => fillBackground(ctx, styles, grid));
excursion(ctx, () => strokeGridLines(ctx, styles, grid));
excursion(ctx, () => strokeGrid(ctx, styles, grid));
excursion(ctx, () => drawCellValues(ctx, styles, grid));
-
- if (pendingSelection) {
- excursion(ctx, () =>
- drawPendingSelection(ctx, styles, grid, pendingSelection),
- );
- } else if (selection) {
- excursion(ctx, () => drawSelection(ctx, styles, grid, selection));
- }
}
diff --git a/web/src/components/grid/drawSelection.ts b/web/src/components/grid/drawSelection.ts
new file mode 100644
index 0000000..e1024a8
--- /dev/null
+++ b/web/src/components/grid/drawSelection.ts
@@ -0,0 +1,97 @@
+import { RangeSelection, Selection } from "../../selection";
+import { CellRef } from "../../types";
+import excursion from "./excursion";
+import { getRenderedCell, RenderedCell, RenderedGrid } from "./renderGrid";
+
+export interface SelectionStyles {
+ activeCellStroke: string;
+ selectionRangeFill: string;
+ selectionRangeStroke: string;
+}
+
+function strokeActiveCell(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ cell: RenderedCell,
+) {
+ excursion(ctx, () => {
+ const isLastCell = cell.rect.bottomRight.x === grid.rect.bottomRight.x;
+ const isLastRow = cell.rect.bottomRight.y === grid.rect.bottomRight.y;
+
+ ctx.strokeStyle = styles.activeCellStroke;
+ ctx.lineWidth = 2;
+
+ ctx.strokeRect(
+ cell.rect.topLeft.x + 1,
+ cell.rect.topLeft.y + 1,
+ isLastCell ? cell.rect.width - 2 : cell.rect.width - 1,
+ isLastRow ? cell.rect.height - 2 : cell.rect.height - 1,
+ );
+ });
+}
+
+function drawCellRange(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ start: CellRef,
+ end: CellRef,
+ { stroke }: { stroke: boolean },
+) {
+ excursion(ctx, () => {
+ const startCell = getRenderedCell(grid, start);
+ const endCell = getRenderedCell(grid, end);
+
+ if (!startCell || !endCell) return;
+
+ const rect = startCell.rect.extend(endCell.rect);
+
+ const isRightEdge = rect.bottomRight.x === grid.rect.bottomRight.x;
+ const isBottomEdge = rect.bottomRight.y === grid.rect.bottomRight.y;
+
+ ctx.fillStyle = styles.selectionRangeFill;
+
+ ctx.fillRect(
+ rect.topLeft.x + 1,
+ rect.topLeft.y + 1,
+ isRightEdge ? rect.width - 2 : rect.width - 1,
+ isBottomEdge ? rect.height - 2 : rect.height - 1,
+ );
+
+ if (!stroke) return;
+
+ ctx.strokeStyle = styles.selectionRangeStroke;
+
+ ctx.strokeRect(
+ rect.topLeft.x + 0.5,
+ rect.topLeft.y + 0.5,
+ isRightEdge ? rect.width - 1 : rect.width,
+ isBottomEdge ? rect.height - 1 : rect.height,
+ );
+ });
+}
+
+export default function drawSelection(
+ ctx: CanvasRenderingContext2D,
+ styles: SelectionStyles,
+ grid: RenderedGrid,
+ selection: Selection | undefined,
+ { pending }: { pending: boolean },
+) {
+ ctx.clearRect(0, 0, grid.rect.width, grid.rect.height);
+
+ if (!selection) return;
+
+ const activeCell = getRenderedCell(grid, selection.activeCellRef);
+
+ if (!activeCell) return;
+
+ if (selection instanceof RangeSelection) {
+ drawCellRange(ctx, styles, grid, selection.range[0], selection.range[1], {
+ stroke: !pending,
+ });
+ }
+
+ strokeActiveCell(ctx, styles, grid, activeCell);
+}
diff --git a/web/src/components/grid/excursion.ts b/web/src/components/grid/excursion.ts
new file mode 100644
index 0000000..7752df1
--- /dev/null
+++ b/web/src/components/grid/excursion.ts
@@ -0,0 +1,8 @@
+export default function excursion(
+ ctx: CanvasRenderingContext2D,
+ f: () => void,
+) {
+ ctx.save();
+ f();
+ ctx.restore();
+}
diff --git a/web/src/components/grid/index.css b/web/src/components/grid/index.css
index 93d6f6f..64153ed 100644
--- a/web/src/components/grid/index.css
+++ b/web/src/components/grid/index.css
@@ -1,34 +1,49 @@
-ntv-grid {
- display: block;
- position: relative;
+@layer components {
+ ntv-grid {
+ display: block;
+ position: relative;
- --grid-bg-fill: var(--color-neutral-900);
- --grid-border-stroke: var(--color-neutral-700);
- --grid-cell-stroke: var(--color-neutral-800);
- --grid-active-cell-stroke: var(--color-green-400);
- --grid-selection-range-fill: color-mix(
- in oklab,
- var(--color-green-400) 10%,
- transparent
- );
- --grid-selection-range-stroke: var(--color-green-400);
- font-size: 14px;
-}
+ --grid-bg-fill: var(--color-neutral-900);
+ --grid-border-stroke: var(--color-neutral-700);
+ --grid-cell-stroke: var(--color-neutral-800);
+ --grid-active-cell-stroke: var(--color-green-400);
+ --grid-selection-range-fill: color-mix(
+ in oklab,
+ var(--color-green-400) 10%,
+ transparent
+ );
+ --grid-selection-range-stroke: var(--color-green-400);
+ font-size: 14px;
+ }
-ntv-grid > canvas {
- display: block;
-}
+ ntv-grid > canvas {
+ display: block;
+ }
-ntv-grid input[data-edit-cell] {
- position: absolute;
- vertical-align: baseline;
- background: var(--color-neutral-800);
- padding-right: 1px;
- padding-bottom: 1px;
- color: white;
- text-align: center;
-}
+ ntv-grid > canvas[data-selection] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ }
+
+ :has(ntv-grid:state(selecting))
+ > ntv-grid:not(:state(selecting))
+ > canvas[data-selection] {
+ display: none;
+ }
+
+ ntv-grid input[data-edit-cell] {
+ position: absolute;
+ vertical-align: baseline;
+ background: var(--color-neutral-800);
+ padding-right: 1px;
+ padding-bottom: 1px;
+ color: white;
+ text-align: center;
+ }
-ntv-grid input[data-edit-cell]:focus-visible {
- outline: none;
+ ntv-grid input[data-edit-cell]:focus-visible {
+ outline: none;
+ }
}
diff --git a/web/src/components/grid/index.ts b/web/src/components/grid/index.ts
index 204ebea..2c00eb8 100644
--- a/web/src/components/grid/index.ts
+++ b/web/src/components/grid/index.ts
@@ -1,119 +1,189 @@
import h, { type CreateElement } from "../../html";
+import { ActiveCellSelection, Selection } from "../../selection";
import { CellRef } from "../../types";
import cellAtCoord from "./cellAtCoord";
-import drawGrid, { getGridStyles } from "./drawGrid";
+import drawGrid, { GridStyles } from "./drawGrid";
+import drawSelection, { SelectionStyles } from "./drawSelection";
import "./index.css";
-import { getRenderedCell } from "./renderGrid";
+import { getRenderedCell, RenderedGrid } from "./renderGrid";
-class NotiveGridElement extends HTMLElement {
- #gridId!: string;
+export class NotiveGridElement extends HTMLElement {
+ #internals: ElementInternals = this.attachInternals();
- get gridId() {
- return this.#gridId;
- }
+ grid?: RenderedGrid;
+
+ #selection?: Selection;
- set gridId(val: string) {
- this.#gridId = val;
- this.setAttribute("grid-id", val);
+ get selection() {
+ return this.#selection;
}
- get renderedGrid() {
- return window.notive.getGrid(this.#gridId)!;
+ set selection(selection: Selection | undefined) {
+ this.#selection = selection;
+ this.drawSelection();
}
- #canvasEl: HTMLCanvasElement = h.canvas();
+ #ongridselectionchange?: ((event: GridSelectionEvent) => any) | undefined;
- connectedCallback() {
- if (!this.gridId) {
- throw new Error("ntv-grid requries gridId attribute");
+ get ongridselectionchange() {
+ return this.#ongridselectionchange;
+ }
+
+ set ongridselectionchange(
+ handler: ((event: GridSelectionEvent) => any) | undefined,
+ ) {
+ if (this.#ongridselectionchange) {
+ this.removeEventListener(
+ "ntv:grid:selectionchange",
+ this.#ongridselectionchange,
+ );
}
- window.addEventListener("ntv:selectionchange", () => this.draw());
- window.addEventListener("ntv:grid:change", () => this.draw());
+ this.#ongridselectionchange = handler;
- this.#canvasEl.addEventListener(
- "mousedown",
- this.#canvasMouseDownCallback.bind(this),
- );
+ if (handler) {
+ this.addEventListener("ntv:grid:selectionchange", handler);
+ }
+ }
- this.#canvasEl.addEventListener(
- "dblclick",
- this.#canvasDoubleClickCallback.bind(this),
- );
+ canvas: HTMLCanvasElement = h.canvas({
+ onmousedown: (event) => {
+ if (!this.grid) return;
+ const cellRef = this.#mouseEventCellRef(event);
+ if (!cellRef) return;
+ this.startSelecting(cellRef);
+ },
+ });
- this.append(this.#canvasEl);
+ selectionCanvas: HTMLCanvasElement = h.canvas({
+ dataset: { selection: "true" },
+ });
+ connectedCallback() {
+ this.append(this.canvas, this.selectionCanvas);
this.draw();
+ this.drawSelection();
}
draw() {
- const ctx = this.#canvasEl.getContext("2d");
+ if (!this.grid) return;
+
+ const ctx = this.canvas.getContext("2d");
if (!ctx) throw new Error("Unable to get canvas context");
- const grid = window.notive.getGrid(this.gridId);
+ this.canvas.setAttribute("width", this.grid.rect.width + "px");
+ this.canvas.setAttribute("height", this.grid.rect.height + "px");
- if (!grid) return;
+ drawGrid(ctx, this.getGridStyles(), this.grid);
+ }
- this.#canvasEl.setAttribute("width", grid.rect.width + "px");
- this.#canvasEl.setAttribute("height", grid.rect.height + "px");
+ drawSelection() {
+ if (!this.grid) return;
- const styles = getGridStyles(this);
+ const ctx = this.selectionCanvas.getContext("2d");
- drawGrid(
+ if (!ctx) throw new Error("Unable to get canvas context");
+
+ this.selectionCanvas.setAttribute("width", this.grid.rect.width + "px");
+ this.selectionCanvas.setAttribute("height", this.grid.rect.height + "px");
+
+ drawSelection(
ctx,
- styles,
- grid,
- window.notive.selection,
- window.notive.pendingSelection,
+ this.getSelectionStyles(),
+ this.grid,
+ this.#pendingSelection ?? this.selection,
+ {
+ pending: !!this.#pendingSelection,
+ },
);
}
- #mouseEventCellRef(
- this: NotiveGridElement,
- event: MouseEvent,
- ): CellRef | undefined {
- const clientRect = this.#canvasEl.getBoundingClientRect();
- const x = event.x - clientRect.x;
- const y = event.y - clientRect.y;
- return cellAtCoord(this.renderedGrid, x, y);
+ getGridStyles(): GridStyles {
+ const style = window.getComputedStyle(this);
+ const val = (k: string) => style.getPropertyValue(k);
+
+ return {
+ bgFill: val("--grid-bg-fill"),
+ borderStroke: val("--grid-border-stroke"),
+ cellStroke: val("--grid-cell-stroke"),
+ cellValueFont: val("font"),
+ cellValueLineHeight: val("line-height"),
+ };
}
- #canvasMouseDownCallback(this: NotiveGridElement, event: MouseEvent) {
- const cellRef = this.#mouseEventCellRef(event);
+ getSelectionStyles(): SelectionStyles {
+ const style = window.getComputedStyle(this);
+ const val = (k: string) => style.getPropertyValue(k);
- if (!cellRef) return;
+ return {
+ activeCellStroke: val("--grid-active-cell-stroke"),
+ selectionRangeFill: val("--grid-selection-range-fill"),
+ selectionRangeStroke: val("--grid-selection-range-stroke"),
+ };
+ }
+
+ #pendingSelection?: Selection;
+ #selectionAbortController?: AbortController;
- window.notive.startSelecting(this.gridId, cellRef);
+ startSelecting(cellRef: CellRef) {
+ if (!this.grid || this.#pendingSelection) return;
+
+ this.#internals.states.add("selecting");
this.#selectionAbortController = new AbortController();
+
+ this.#selectionAbortController.signal.addEventListener("abort", () => {
+ this.#internals.states.delete("selecting");
+ this.#selectionAbortController = undefined;
+ });
+
const { signal } = this.#selectionAbortController;
window.addEventListener(
"mousemove",
- this.#selectionMouseMoveCallback.bind(this),
+ (event) => {
+ const cellRef = this.#mouseEventCellRef(event);
+ if (!cellRef) return;
+ this.#pendingSelection = this.#pendingSelection?.extend(cellRef);
+ this.drawSelection();
+ },
{ signal },
);
window.addEventListener(
"mouseup",
- this.#selectionMouseUpCallback.bind(this),
+ () => {
+ this.#selectionAbortController?.abort();
+
+ if (!this.#pendingSelection) return;
+
+ this.dispatchEvent(
+ new GridSelectionEvent(
+ "ntv:grid:selectionchange",
+ this.#pendingSelection,
+ ),
+ );
+
+ this.#pendingSelection = undefined;
+ this.drawSelection();
+ },
{ signal },
);
- }
- #selectionAbortController?: AbortController;
-
- #selectionMouseMoveCallback(this: NotiveGridElement, event: MouseEvent) {
- const cellRef = this.#mouseEventCellRef(event);
- if (!cellRef) return;
- window.notive.extendSelection(cellRef);
+ this.#pendingSelection = new ActiveCellSelection(this.grid.id, cellRef);
+ this.drawSelection();
}
- #selectionMouseUpCallback(this: NotiveGridElement, event: MouseEvent) {
- this.#selectionAbortController?.abort();
- this.#selectionAbortController = undefined;
- window.notive.finishSelecting();
+ #mouseEventCellRef(
+ this: NotiveGridElement,
+ event: MouseEvent,
+ ): CellRef | undefined {
+ if (!this.grid) return;
+ const clientRect = this.canvas.getBoundingClientRect();
+ const x = event.x - clientRect.x;
+ const y = event.y - clientRect.y;
+ return cellAtCoord(this.grid, x, y);
}
#editingCellRef?: CellRef;
@@ -137,15 +207,13 @@ class NotiveGridElement extends HTMLElement {
});
#canvasDoubleClickCallback(this: NotiveGridElement, event: MouseEvent) {
+ if (!this.grid) return;
+
const cellRef = this.#mouseEventCellRef(event);
if (!cellRef) return;
- const grid = window.notive.getGrid(this.gridId);
-
- if (!grid) return;
-
- const cell = getRenderedCell(grid, cellRef);
+ const cell = getRenderedCell(this.grid, cellRef);
if (!cell) return;
@@ -168,8 +236,10 @@ class NotiveGridElement extends HTMLElement {
#finishEditing() {
this.#editInputEl.remove();
+ if (!this.grid) return;
+
window.notive.setCellValue(
- this.gridId,
+ this.grid.id,
this.#editingCellRef!,
this.#editInputEl.value,
);
@@ -180,3 +250,18 @@ customElements.define("ntv-grid", NotiveGridElement);
export default ((...args: any[]): NotiveGridElement =>
(h as any)["ntv-grid"](...args)) as CreateElement<NotiveGridElement>;
+
+export class GridSelectionEvent extends Event {
+ selection: Selection;
+
+ constructor(type: string, selection: Selection) {
+ super(type);
+ this.selection = selection;
+ }
+}
+
+declare global {
+ interface HTMLElementEventMap {
+ "ntv:grid:selectionchange": GridSelectionEvent;
+ }
+}
diff --git a/web/src/defaultDoc.ts b/web/src/defaultDoc.ts
new file mode 100644
index 0000000..7409c1a
--- /dev/null
+++ b/web/src/defaultDoc.ts
@@ -0,0 +1,42 @@
+import Ratio from "./math/Ratio";
+import { Cell, Doc } from "./types";
+
+export default function defaultDoc(): Doc {
+ const defaultCells: Cell[] = Array.from({ length: 16 }, () => ({
+ widthRatio: new Ratio(1, 16),
+ }));
+
+ return {
+ grids: [
+ {
+ id: window.crypto.randomUUID(),
+ baseCellSize: 42,
+ baseCellWidthRatio: new Ratio(1, 16),
+ parts: [
+ {
+ rows: Array.from({ length: 4 }, () => ({
+ cells: [...defaultCells],
+ })),
+ },
+ ],
+ },
+ {
+ id: window.crypto.randomUUID(),
+ baseCellSize: 42,
+ baseCellWidthRatio: new Ratio(1, 16),
+ parts: [
+ {
+ rows: Array.from({ length: 2 }, () => ({
+ cells: [...defaultCells],
+ })),
+ },
+ {
+ rows: Array.from({ length: 2 }, () => ({
+ cells: [...defaultCells],
+ })),
+ },
+ ],
+ },
+ ],
+ };
+}