@dxos/react-ui-canvas 0.8.4-main.b97322e → 0.8.4-main.bc2380dfbc

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +1128 -372
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/node-esm/index.mjs +1128 -372
  6. package/dist/lib/node-esm/index.mjs.map +4 -4
  7. package/dist/lib/node-esm/meta.json +1 -1
  8. package/dist/types/src/components/Canvas/Canvas.d.ts +2 -2
  9. package/dist/types/src/components/Canvas/Canvas.d.ts.map +1 -1
  10. package/dist/types/src/components/Canvas/Canvas.stories.d.ts +12 -4
  11. package/dist/types/src/components/Canvas/Canvas.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/CellGrid/CellGrid.d.ts +21 -0
  13. package/dist/types/src/components/CellGrid/CellGrid.d.ts.map +1 -0
  14. package/dist/types/src/components/CellGrid/CellGrid.stories.d.ts +21 -0
  15. package/dist/types/src/components/CellGrid/CellGrid.stories.d.ts.map +1 -0
  16. package/dist/types/src/components/CellGrid/headers/Ruler.d.ts +15 -0
  17. package/dist/types/src/components/CellGrid/headers/Ruler.d.ts.map +1 -0
  18. package/dist/types/src/components/CellGrid/headers/TrackHeader.d.ts +19 -0
  19. package/dist/types/src/components/CellGrid/headers/TrackHeader.d.ts.map +1 -0
  20. package/dist/types/src/components/CellGrid/headers/index.d.ts +3 -0
  21. package/dist/types/src/components/CellGrid/headers/index.d.ts.map +1 -0
  22. package/dist/types/src/components/CellGrid/index.d.ts +6 -0
  23. package/dist/types/src/components/CellGrid/index.d.ts.map +1 -0
  24. package/dist/types/src/components/CellGrid/input/index.d.ts +3 -0
  25. package/dist/types/src/components/CellGrid/input/index.d.ts.map +1 -0
  26. package/dist/types/src/components/CellGrid/input/pointer.d.ts +29 -0
  27. package/dist/types/src/components/CellGrid/input/pointer.d.ts.map +1 -0
  28. package/dist/types/src/components/CellGrid/input/wheel.d.ts +14 -0
  29. package/dist/types/src/components/CellGrid/input/wheel.d.ts.map +1 -0
  30. package/dist/types/src/components/CellGrid/render/index.d.ts +3 -0
  31. package/dist/types/src/components/CellGrid/render/index.d.ts.map +1 -0
  32. package/dist/types/src/components/CellGrid/render/overlay-layer.d.ts +21 -0
  33. package/dist/types/src/components/CellGrid/render/overlay-layer.d.ts.map +1 -0
  34. package/dist/types/src/components/CellGrid/render/static-layer.d.ts +36 -0
  35. package/dist/types/src/components/CellGrid/render/static-layer.d.ts.map +1 -0
  36. package/dist/types/src/components/CellGrid/state/atoms.d.ts +23 -0
  37. package/dist/types/src/components/CellGrid/state/atoms.d.ts.map +1 -0
  38. package/dist/types/src/components/CellGrid/state/index.d.ts +4 -0
  39. package/dist/types/src/components/CellGrid/state/index.d.ts.map +1 -0
  40. package/dist/types/src/components/CellGrid/state/types.d.ts +39 -0
  41. package/dist/types/src/components/CellGrid/state/types.d.ts.map +1 -0
  42. package/dist/types/src/components/CellGrid/state/viewport.d.ts +52 -0
  43. package/dist/types/src/components/CellGrid/state/viewport.d.ts.map +1 -0
  44. package/dist/types/src/components/CellGrid/state/viewport.test.d.ts +2 -0
  45. package/dist/types/src/components/CellGrid/state/viewport.test.d.ts.map +1 -0
  46. package/dist/types/src/components/FPS.d.ts.map +1 -1
  47. package/dist/types/src/components/Grid/Grid.d.ts +2 -2
  48. package/dist/types/src/components/Grid/Grid.d.ts.map +1 -1
  49. package/dist/types/src/components/Grid/Grid.stories.d.ts +19 -4
  50. package/dist/types/src/components/Grid/Grid.stories.d.ts.map +1 -1
  51. package/dist/types/src/components/index.d.ts +1 -0
  52. package/dist/types/src/components/index.d.ts.map +1 -1
  53. package/dist/types/src/hooks/index.d.ts +1 -0
  54. package/dist/types/src/hooks/index.d.ts.map +1 -1
  55. package/dist/types/src/hooks/projection.d.ts +1 -1
  56. package/dist/types/src/hooks/projection.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useDrag.d.ts +6 -0
  58. package/dist/types/src/hooks/useDrag.d.ts.map +1 -0
  59. package/dist/types/src/hooks/useWheel.d.ts.map +1 -1
  60. package/dist/types/src/types.d.ts +1 -1
  61. package/dist/types/src/types.d.ts.map +1 -1
  62. package/dist/types/src/util/svg.d.ts +1 -1
  63. package/dist/types/src/util/svg.d.ts.map +1 -1
  64. package/dist/types/src/util/svg.stories.d.ts +12 -4
  65. package/dist/types/src/util/svg.stories.d.ts.map +1 -1
  66. package/dist/types/src/util/util.d.ts.map +1 -1
  67. package/dist/types/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +27 -26
  69. package/src/components/Canvas/Canvas.stories.tsx +13 -11
  70. package/src/components/Canvas/Canvas.tsx +5 -5
  71. package/src/components/CellGrid/CellGrid.stories.tsx +238 -0
  72. package/src/components/CellGrid/CellGrid.tsx +266 -0
  73. package/src/components/CellGrid/headers/Ruler.tsx +71 -0
  74. package/src/components/CellGrid/headers/TrackHeader.tsx +58 -0
  75. package/src/components/CellGrid/headers/index.ts +6 -0
  76. package/src/components/CellGrid/index.ts +9 -0
  77. package/src/components/CellGrid/input/index.ts +6 -0
  78. package/src/components/CellGrid/input/pointer.ts +208 -0
  79. package/src/components/CellGrid/input/wheel.ts +68 -0
  80. package/src/components/CellGrid/render/index.ts +6 -0
  81. package/src/components/CellGrid/render/overlay-layer.ts +66 -0
  82. package/src/components/CellGrid/render/static-layer.ts +112 -0
  83. package/src/components/CellGrid/state/atoms.ts +43 -0
  84. package/src/components/CellGrid/state/index.ts +7 -0
  85. package/src/components/CellGrid/state/types.ts +40 -0
  86. package/src/components/CellGrid/state/viewport.test.ts +50 -0
  87. package/src/components/CellGrid/state/viewport.ts +94 -0
  88. package/src/components/FPS.tsx +3 -3
  89. package/src/components/Grid/Grid.stories.tsx +10 -9
  90. package/src/components/Grid/Grid.tsx +15 -17
  91. package/src/components/index.ts +1 -0
  92. package/src/hooks/index.ts +1 -0
  93. package/src/hooks/projection.tsx +2 -2
  94. package/src/hooks/useDrag.tsx +96 -0
  95. package/src/hooks/useWheel.tsx +1 -28
  96. package/src/types.ts +1 -1
  97. package/src/util/svg.stories.tsx +9 -9
  98. package/src/util/svg.tsx +1 -1
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './overlay-layer';
6
+ export * from './static-layer';
@@ -0,0 +1,66 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type { Headers, Selection, Viewport } from '../state/types';
6
+ import { cellWidth, worldToScreen } from '../state/viewport';
7
+
8
+ export type OverlayStyle = {
9
+ playhead: string;
10
+ selectionFill: string;
11
+ selectionStroke: string;
12
+ };
13
+
14
+ export type DrawOverlayOptions = {
15
+ ctx: CanvasRenderingContext2D;
16
+ size: { width: number; height: number };
17
+ viewport: Viewport;
18
+ headers: Headers;
19
+ selection: Selection;
20
+ /** Playhead position in world units (col + fraction), or null when not playing. */
21
+ playhead: number | null;
22
+ style: OverlayStyle;
23
+ };
24
+
25
+ export const drawOverlay = ({ ctx, size, viewport, headers, selection, playhead, style }: DrawOverlayOptions): void => {
26
+ ctx.clearRect(0, 0, size.width, size.height);
27
+
28
+ ctx.save();
29
+ ctx.beginPath();
30
+ ctx.rect(headers.left, headers.top, size.width - headers.left, size.height - headers.top);
31
+ ctx.clip();
32
+
33
+ // Selection rectangle.
34
+ if (selection.range) {
35
+ const { col0, row0, col1, row1 } = selection.range;
36
+ const minCol = Math.min(col0, col1);
37
+ const maxCol = Math.max(col0, col1);
38
+ const minRow = Math.min(row0, row1);
39
+ const maxRow = Math.max(row0, row1);
40
+ const tl = worldToScreen(viewport, headers, { col: minCol, row: minRow });
41
+ const br = worldToScreen(viewport, headers, { col: maxCol + 1, row: maxRow + 1 });
42
+ ctx.fillStyle = style.selectionFill;
43
+ ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y);
44
+ ctx.strokeStyle = style.selectionStroke;
45
+ ctx.setLineDash([4, 3]);
46
+ ctx.lineWidth = 1;
47
+ ctx.strokeRect(tl.x + 0.5, tl.y + 0.5, br.x - tl.x - 1, br.y - tl.y - 1);
48
+ ctx.setLineDash([]);
49
+ }
50
+
51
+ // Playhead.
52
+ if (playhead !== null) {
53
+ const w = cellWidth(viewport);
54
+ const x = headers.left + playhead * w - viewport.scrollX;
55
+ if (x >= headers.left && x <= size.width) {
56
+ ctx.strokeStyle = style.playhead;
57
+ ctx.lineWidth = 2;
58
+ ctx.beginPath();
59
+ ctx.moveTo(x, headers.top);
60
+ ctx.lineTo(x, size.height);
61
+ ctx.stroke();
62
+ }
63
+ }
64
+
65
+ ctx.restore();
66
+ };
@@ -0,0 +1,112 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type { Cell, Headers, Row, Viewport } from '../state/types';
6
+ import { cellWidth, visibleCellRange, visibleCells, worldToScreen } from '../state/viewport';
7
+
8
+ export type RenderCellArgs<T> = {
9
+ ctx: CanvasRenderingContext2D;
10
+ x: number;
11
+ y: number;
12
+ w: number;
13
+ h: number;
14
+ cell: Cell<T>;
15
+ };
16
+
17
+ export type RenderCell<T = unknown> = (args: RenderCellArgs<T>) => void;
18
+
19
+ export type StaticLayerStyle = {
20
+ /** Grid line color, e.g. 'rgba(0,0,0,0.08)'. */
21
+ gridLine: string;
22
+ /** Row band fill (alternating), e.g. 'rgba(0,0,0,0.02)'. */
23
+ rowBand?: string;
24
+ background?: string;
25
+ };
26
+
27
+ export type DrawCellsOptions<T> = {
28
+ ctx: CanvasRenderingContext2D;
29
+ size: { width: number; height: number };
30
+ viewport: Viewport;
31
+ headers: Headers;
32
+ rows: ReadonlyArray<Row>;
33
+ cells: ReadonlyMap<string, Cell<T>>;
34
+ renderCell: RenderCell<T>;
35
+ style: StaticLayerStyle;
36
+ };
37
+
38
+ /**
39
+ * Paint the static layer: background, gridlines, alternating row bands, and cells.
40
+ * Pure with respect to its inputs (writes only to the supplied ctx).
41
+ */
42
+ export const drawCells = <T>({
43
+ ctx,
44
+ size,
45
+ viewport,
46
+ headers,
47
+ rows,
48
+ cells,
49
+ renderCell,
50
+ style,
51
+ }: DrawCellsOptions<T>): void => {
52
+ ctx.clearRect(0, 0, size.width, size.height);
53
+
54
+ if (style.background) {
55
+ ctx.fillStyle = style.background;
56
+ ctx.fillRect(0, 0, size.width, size.height);
57
+ }
58
+
59
+ const range = visibleCellRange(viewport, headers, size);
60
+ const w = cellWidth(viewport);
61
+ const h = viewport.cellHeight;
62
+
63
+ // Row bands. Paint odd rows so the stripe pattern matches the TrackHeader's
64
+ // label backgrounds (which darken odd row indices) — keeps the frozen left
65
+ // column and the cell area visually in lockstep.
66
+ if (style.rowBand) {
67
+ ctx.fillStyle = style.rowBand;
68
+ for (let row = range.minRow; row <= Math.min(range.maxRow, rows.length - 1); row++) {
69
+ if (row % 2 === 0) {
70
+ continue;
71
+ }
72
+ const y = headers.top + row * h - viewport.scrollY;
73
+ ctx.fillRect(headers.left, y, size.width - headers.left, h);
74
+ }
75
+ }
76
+
77
+ // Gridlines.
78
+ ctx.strokeStyle = style.gridLine;
79
+ ctx.lineWidth = 1;
80
+ ctx.beginPath();
81
+ for (let col = range.minCol; col <= range.maxCol + 1; col++) {
82
+ const x = Math.floor(headers.left + col * w - viewport.scrollX) + 0.5;
83
+ if (x < headers.left) {
84
+ continue;
85
+ }
86
+ ctx.moveTo(x, headers.top);
87
+ ctx.lineTo(x, size.height);
88
+ }
89
+ for (let row = range.minRow; row <= Math.min(range.maxRow + 1, rows.length); row++) {
90
+ const y = Math.floor(headers.top + row * h - viewport.scrollY) + 0.5;
91
+ if (y < headers.top) {
92
+ continue;
93
+ }
94
+ ctx.moveTo(headers.left, y);
95
+ ctx.lineTo(size.width, y);
96
+ }
97
+ ctx.stroke();
98
+
99
+ // Cells.
100
+ ctx.save();
101
+ ctx.beginPath();
102
+ ctx.rect(headers.left, headers.top, size.width - headers.left, size.height - headers.top);
103
+ ctx.clip();
104
+ for (const cell of visibleCells(cells, range)) {
105
+ if (cell.row >= rows.length) {
106
+ continue;
107
+ }
108
+ const rect = worldToScreen(viewport, headers, cell);
109
+ renderCell({ ctx, ...rect, cell });
110
+ }
111
+ ctx.restore();
112
+ };
@@ -0,0 +1,43 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Atom } from '@effect-atom/atom-react';
6
+
7
+ import type { Cell, Selection, Tool, Viewport } from './types';
8
+
9
+ export type CellGridAtoms<T = unknown> = {
10
+ cells: Atom.Writable<ReadonlyMap<string, Cell<T>>>;
11
+ viewport: Atom.Writable<Viewport>;
12
+ selection: Atom.Writable<Selection>;
13
+ playhead: Atom.Writable<number | null>;
14
+ tool: Atom.Writable<Tool>;
15
+ };
16
+
17
+ export type CellGridAtomsOptions = {
18
+ cellWidth?: number;
19
+ cellHeight?: number;
20
+ };
21
+
22
+ export const defaultViewport = (options: CellGridAtomsOptions = {}): Viewport => ({
23
+ scrollX: 0,
24
+ scrollY: 0,
25
+ baseCellWidth: options.cellWidth ?? 24,
26
+ cellHeight: options.cellHeight ?? 24,
27
+ zoomX: 1,
28
+ });
29
+
30
+ /**
31
+ * Create a fresh set of atoms backing a CellGrid instance. Consumers may pass any of these as props
32
+ * or substitute their own (e.g., a cells atom backed by ECHO).
33
+ *
34
+ * Atoms are marked keepAlive so they preserve state across transient unsubscribe windows (e.g. React
35
+ * Strict Mode mount-unmount-remount). Consumers are responsible for the atoms' lifetime.
36
+ */
37
+ export const createCellGridAtoms = <T = unknown>(options: CellGridAtomsOptions = {}): CellGridAtoms<T> => ({
38
+ cells: Atom.keepAlive(Atom.make<ReadonlyMap<string, Cell<T>>>(new Map())),
39
+ viewport: Atom.keepAlive(Atom.make<Viewport>(defaultViewport(options))),
40
+ selection: Atom.keepAlive(Atom.make<Selection>({ range: null })),
41
+ playhead: Atom.keepAlive(Atom.make<number | null>(null)),
42
+ tool: Atom.keepAlive(Atom.make<Tool>('toggle')),
43
+ });
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './atoms';
6
+ export * from './types';
7
+ export * from './viewport';
@@ -0,0 +1,40 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export type CellCoord = { col: number; row: number };
6
+
7
+ export type Cell<T = unknown> = {
8
+ col: number;
9
+ row: number;
10
+ length: number;
11
+ data?: T;
12
+ };
13
+
14
+ export type Viewport = {
15
+ /** World-space scroll offset (pixels). */
16
+ scrollX: number;
17
+ scrollY: number;
18
+ /** Cell dimensions in pixels at zoomX = 1. */
19
+ baseCellWidth: number;
20
+ cellHeight: number;
21
+ /** Horizontal zoom factor. */
22
+ zoomX: number;
23
+ };
24
+
25
+ export type SelectionRange = {
26
+ col0: number;
27
+ row0: number;
28
+ col1: number;
29
+ row1: number;
30
+ };
31
+
32
+ export type Selection = {
33
+ range: SelectionRange | null;
34
+ };
35
+
36
+ export type Tool = 'toggle' | 'select' | 'resize';
37
+
38
+ export type Row = { id: string; label?: string };
39
+
40
+ export type Headers = { left: number; top: number };
@@ -0,0 +1,50 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { defaultViewport } from './atoms';
8
+ import { hitTestCell, screenToWorld, visibleCellRange, visibleCells, worldToScreen } from './viewport';
9
+
10
+ const headers = { left: 80, top: 24 };
11
+
12
+ describe('viewport', () => {
13
+ test('worldToScreen / screenToWorld round-trip', ({ expect }) => {
14
+ const viewport = { ...defaultViewport(), scrollX: 100, scrollY: 50 };
15
+ const { x, y } = worldToScreen(viewport, headers, { col: 5, row: 3 });
16
+ const back = screenToWorld(viewport, headers, { x, y });
17
+ expect(back.col).toBeCloseTo(5);
18
+ expect(back.row).toBeCloseTo(3);
19
+ });
20
+
21
+ test('hitTestCell returns null in header region', ({ expect }) => {
22
+ const viewport = defaultViewport();
23
+ expect(hitTestCell(viewport, headers, { x: 10, y: 10 })).toBeNull();
24
+ expect(hitTestCell(viewport, headers, { x: 200, y: 10 })).toBeNull();
25
+ expect(hitTestCell(viewport, headers, { x: 10, y: 200 })).toBeNull();
26
+ });
27
+
28
+ test('hitTestCell floors fractional coords', ({ expect }) => {
29
+ const viewport = { ...defaultViewport(), baseCellWidth: 20, cellHeight: 20 };
30
+ const coord = hitTestCell(viewport, headers, { x: 80 + 25, y: 24 + 45 });
31
+ expect(coord).toEqual({ col: 1, row: 2 });
32
+ });
33
+
34
+ test('visibleCellRange respects scroll', ({ expect }) => {
35
+ const viewport = { ...defaultViewport(), scrollX: 240, baseCellWidth: 24, cellHeight: 24 };
36
+ const range = visibleCellRange(viewport, headers, { width: 400, height: 200 });
37
+ expect(range.minCol).toBe(10);
38
+ });
39
+
40
+ test('visibleCells filters by row and column extent', ({ expect }) => {
41
+ const cells = new Map([
42
+ ['0,0', { col: 0, row: 0, length: 1 }],
43
+ ['100,0', { col: 100, row: 0, length: 1 }],
44
+ ['5,5', { col: 5, row: 5, length: 1 }],
45
+ ['8,1', { col: 8, row: 1, length: 10 }],
46
+ ]);
47
+ const result = Array.from(visibleCells(cells, { minCol: 0, maxCol: 12, minRow: 0, maxRow: 2 }));
48
+ expect(result.map((cell) => `${cell.col},${cell.row}`).sort()).toEqual(['0,0', '8,1']);
49
+ });
50
+ });
@@ -0,0 +1,94 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type { Cell, CellCoord, Headers, Viewport } from './types';
6
+
7
+ export const cellKey = (col: number, row: number): string => `${col},${row}`;
8
+
9
+ export const cellWidth = (viewport: Viewport): number => viewport.baseCellWidth * viewport.zoomX;
10
+
11
+ /**
12
+ * Convert a cell's world coordinates to screen-space pixel rectangle (relative to canvas origin).
13
+ */
14
+ export const worldToScreen = (
15
+ viewport: Viewport,
16
+ headers: Headers,
17
+ coord: { col: number; row: number; length?: number },
18
+ ): { x: number; y: number; w: number; h: number } => {
19
+ const w = cellWidth(viewport);
20
+ return {
21
+ x: headers.left + coord.col * w - viewport.scrollX,
22
+ y: headers.top + coord.row * viewport.cellHeight - viewport.scrollY,
23
+ w: (coord.length ?? 1) * w,
24
+ h: viewport.cellHeight,
25
+ };
26
+ };
27
+
28
+ /**
29
+ * Convert screen-space pixels (relative to canvas origin) to fractional cell coordinates.
30
+ */
31
+ export const screenToWorld = (
32
+ viewport: Viewport,
33
+ headers: Headers,
34
+ point: { x: number; y: number },
35
+ ): { col: number; row: number } => {
36
+ const w = cellWidth(viewport);
37
+ return {
38
+ col: (point.x - headers.left + viewport.scrollX) / w,
39
+ row: (point.y - headers.top + viewport.scrollY) / viewport.cellHeight,
40
+ };
41
+ };
42
+
43
+ export const hitTestCell = (
44
+ viewport: Viewport,
45
+ headers: Headers,
46
+ point: { x: number; y: number },
47
+ ): CellCoord | null => {
48
+ if (point.x < headers.left || point.y < headers.top) {
49
+ return null;
50
+ }
51
+ const { col, row } = screenToWorld(viewport, headers, point);
52
+ if (col < 0 || row < 0) {
53
+ return null;
54
+ }
55
+ return { col: Math.floor(col), row: Math.floor(row) };
56
+ };
57
+
58
+ /**
59
+ * Compute the inclusive range of cell coordinates intersecting the visible content rect.
60
+ */
61
+ export const visibleCellRange = (
62
+ viewport: Viewport,
63
+ headers: Headers,
64
+ size: { width: number; height: number },
65
+ ): { minCol: number; maxCol: number; minRow: number; maxRow: number } => {
66
+ const w = cellWidth(viewport);
67
+ const innerW = Math.max(0, size.width - headers.left);
68
+ const innerH = Math.max(0, size.height - headers.top);
69
+ const minCol = Math.max(0, Math.floor(viewport.scrollX / w));
70
+ const maxCol = Math.floor((viewport.scrollX + innerW) / w);
71
+ const minRow = Math.max(0, Math.floor(viewport.scrollY / viewport.cellHeight));
72
+ const maxRow = Math.floor((viewport.scrollY + innerH) / viewport.cellHeight);
73
+ return { minCol, maxCol, minRow, maxRow };
74
+ };
75
+
76
+ /**
77
+ * Iterate sparse cell map, yielding only cells whose horizontal extent intersects visible cols and whose row is visible.
78
+ */
79
+ export const visibleCells = function* <T>(
80
+ cells: ReadonlyMap<string, Cell<T>>,
81
+ range: { minCol: number; maxCol: number; minRow: number; maxRow: number },
82
+ ): Generator<Cell<T>> {
83
+ for (const cell of cells.values()) {
84
+ if (cell.row < range.minRow || cell.row > range.maxRow) {
85
+ continue;
86
+ }
87
+ const start = cell.col;
88
+ const end = cell.col + cell.length - 1;
89
+ if (end < range.minCol || start > range.maxCol) {
90
+ continue;
91
+ }
92
+ yield cell;
93
+ }
94
+ };
@@ -6,7 +6,7 @@
6
6
  import React, { useEffect, useReducer, useRef } from 'react';
7
7
 
8
8
  import { type ThemedClassName } from '@dxos/react-ui';
9
- import { mx } from '@dxos/react-ui-theme';
9
+ import { mx } from '@dxos/ui-theme';
10
10
 
11
11
  export type FPSProps = ThemedClassName<{
12
12
  width?: number;
@@ -53,7 +53,7 @@ export const FPS = ({ classNames, width = 60, height = 30, bar = 'bg-cyan-500' }
53
53
  },
54
54
  );
55
55
 
56
- const requestRef = useRef<number>();
56
+ const requestRef = useRef<number | null>(null);
57
57
  const tick = () => {
58
58
  dispatch();
59
59
  requestRef.current = requestAnimationFrame(tick);
@@ -73,7 +73,7 @@ export const FPS = ({ classNames, width = 60, height = 30, bar = 'bg-cyan-500' }
73
73
  style={{ width: width + 6 }}
74
74
  className={mx(
75
75
  'relative flex flex-col p-0.5',
76
- 'bg-baseSurface text-xs text-subdued font-thin pointer-events-none border border-separator',
76
+ 'bg-base-surface text-xs text-subdued font-thin pointer-events-none border border-separator',
77
77
  classNames,
78
78
  )}
79
79
  >
@@ -2,15 +2,13 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
- import type { Meta, StoryObj } from '@storybook/react-vite';
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
8
6
  import React, { useRef, useState } from 'react';
9
7
 
10
- import { withLayout, withTheme } from '@dxos/storybook-utils';
8
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
9
 
12
- import { GridComponent, type GridProps } from './Grid';
13
10
  import { type ProjectionState } from '../../hooks';
11
+ import { GridComponent, type GridProps } from './Grid';
14
12
 
15
13
  const DefaultStory = (props: GridProps) => {
16
14
  const ref = useRef<HTMLDivElement>(null);
@@ -23,16 +21,19 @@ const DefaultStory = (props: GridProps) => {
23
21
  );
24
22
  };
25
23
 
26
- const meta: Meta<GridProps> = {
24
+ const meta = {
27
25
  title: 'ui/react-ui-canvas/Grid',
28
26
  component: GridComponent,
29
27
  render: DefaultStory,
30
- decorators: [withTheme, withLayout({ fullscreen: true })],
31
- };
28
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
29
+ parameters: {
30
+ layout: 'fullscreen',
31
+ },
32
+ } satisfies Meta<typeof GridComponent>;
32
33
 
33
34
  export default meta;
34
35
 
35
- type Story = StoryObj<GridProps>;
36
+ type Story = StoryObj<typeof meta>;
36
37
 
37
38
  export const Default: Story = {
38
39
  args: {
@@ -2,10 +2,10 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import React, { forwardRef, useMemo, useId } from 'react';
5
+ import React, { forwardRef, useId, useMemo } from 'react';
6
6
 
7
- import { useForwardedRef, type ThemedClassName } from '@dxos/react-ui';
8
- import { mx } from '@dxos/react-ui-theme';
7
+ import { type ThemedClassName, useForwardedRef } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/ui-theme';
9
9
 
10
10
  import { useCanvasContext } from '../../hooks';
11
11
  import { type Point } from '../../types';
@@ -18,6 +18,8 @@ const defaultOffset: Point = { x: 0, y: 0 };
18
18
 
19
19
  const createId = (parent: string, grid: number) => `dx-canvas-grid-${parent}-${grid}`;
20
20
 
21
+ // TODO(burdon): Click to drag.
22
+
21
23
  export type GridProps = ThemedClassName<{
22
24
  size?: number;
23
25
  scale?: number;
@@ -25,32 +27,34 @@ export type GridProps = ThemedClassName<{
25
27
  showAxes?: boolean;
26
28
  }>;
27
29
 
30
+ // TODO(burdon): Use id of parent canvas.
31
+ export const Grid = (props: GridProps) => {
32
+ const { scale, offset } = useCanvasContext();
33
+ return <GridComponent {...props} scale={scale} offset={offset} />;
34
+ };
35
+
28
36
  export const GridComponent = forwardRef<SVGSVGElement, GridProps>(
29
37
  (
30
38
  { size: gridSize = defaultGridSize, scale = 1, offset = defaultOffset, showAxes = true, classNames },
31
39
  forwardedRef,
32
40
  ) => {
33
41
  const svgRef = useForwardedRef(forwardedRef);
42
+ const { width = 0, height = 0 } = svgRef.current?.getBoundingClientRect() ?? {};
43
+
34
44
  const instanceId = useId();
35
45
  const grids = useMemo(
36
46
  () =>
37
47
  gridRatios
38
48
  .map((ratio) => ({ id: ratio, size: ratio * gridSize * scale }))
39
- .filter(({ size }) => size >= gridSize && size <= 256),
49
+ .filter(({ size }) => size >= gridSize && size <= 128),
40
50
  [gridSize, scale],
41
51
  );
42
52
 
43
- const { width = 0, height = 0 } = svgRef.current?.getBoundingClientRect() ?? {};
44
-
45
53
  return (
46
54
  <svg
47
55
  {...testId('dx-canvas-grid')}
48
56
  ref={svgRef}
49
- className={mx(
50
- 'absolute inset-0 w-full h-full pointer-events-none touch-none select-none',
51
- 'stroke-neutral-500',
52
- classNames,
53
- )}
57
+ className={mx('dx-fullscreen pointer-events-none touch-none select-none', 'stroke-neutral-500', classNames)}
54
58
  >
55
59
  {/* NOTE: The pattern is offset so that the middle of the pattern aligns with the grid. */}
56
60
  <defs>
@@ -79,9 +83,3 @@ export const GridComponent = forwardRef<SVGSVGElement, GridProps>(
79
83
  );
80
84
  },
81
85
  );
82
-
83
- // TODO(burdon): Use id of parent canvas.
84
- export const Grid = (props: GridProps) => {
85
- const { scale, offset } = useCanvasContext();
86
- return <GridComponent {...props} scale={scale} offset={offset} />;
87
- };
@@ -3,5 +3,6 @@
3
3
  //
4
4
 
5
5
  export * from './Canvas';
6
+ export * from './CellGrid';
6
7
  export * from './FPS';
7
8
  export * from './Grid';
@@ -4,4 +4,5 @@
4
4
 
5
5
  export * from './projection';
6
6
  export * from './useCanvasContext';
7
+ export * from './useDrag';
7
8
  export * from './useWheel';
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { interpolate, interpolateObject, transition, easeSinOut } from 'd3';
5
+ import { easeSinOut, interpolate, interpolateObject, transition } from 'd3';
6
6
  import {
7
7
  type Matrix,
8
8
  applyToPoints,
@@ -13,7 +13,7 @@ import {
13
13
  translate as translateMatrix,
14
14
  } from 'transformation-matrix';
15
15
 
16
- import { type Point, type Dimension } from '../types';
16
+ import { type Dimension, type Point } from '../types';
17
17
 
18
18
  export const defaultOrigin: Point = { x: 0, y: 0 };
19
19