@dxos/react-ui-canvas 0.8.4-main.ead640a → 0.8.4-main.f466a3d56e

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 (90) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +1131 -388
  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 +1131 -388
  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.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/CellGrid/CellGrid.d.ts +21 -0
  11. package/dist/types/src/components/CellGrid/CellGrid.d.ts.map +1 -0
  12. package/dist/types/src/components/CellGrid/CellGrid.stories.d.ts +21 -0
  13. package/dist/types/src/components/CellGrid/CellGrid.stories.d.ts.map +1 -0
  14. package/dist/types/src/components/CellGrid/headers/Ruler.d.ts +15 -0
  15. package/dist/types/src/components/CellGrid/headers/Ruler.d.ts.map +1 -0
  16. package/dist/types/src/components/CellGrid/headers/TrackHeader.d.ts +19 -0
  17. package/dist/types/src/components/CellGrid/headers/TrackHeader.d.ts.map +1 -0
  18. package/dist/types/src/components/CellGrid/headers/index.d.ts +3 -0
  19. package/dist/types/src/components/CellGrid/headers/index.d.ts.map +1 -0
  20. package/dist/types/src/components/CellGrid/index.d.ts +6 -0
  21. package/dist/types/src/components/CellGrid/index.d.ts.map +1 -0
  22. package/dist/types/src/components/CellGrid/input/index.d.ts +3 -0
  23. package/dist/types/src/components/CellGrid/input/index.d.ts.map +1 -0
  24. package/dist/types/src/components/CellGrid/input/pointer.d.ts +29 -0
  25. package/dist/types/src/components/CellGrid/input/pointer.d.ts.map +1 -0
  26. package/dist/types/src/components/CellGrid/input/wheel.d.ts +14 -0
  27. package/dist/types/src/components/CellGrid/input/wheel.d.ts.map +1 -0
  28. package/dist/types/src/components/CellGrid/render/index.d.ts +3 -0
  29. package/dist/types/src/components/CellGrid/render/index.d.ts.map +1 -0
  30. package/dist/types/src/components/CellGrid/render/overlay-layer.d.ts +21 -0
  31. package/dist/types/src/components/CellGrid/render/overlay-layer.d.ts.map +1 -0
  32. package/dist/types/src/components/CellGrid/render/static-layer.d.ts +36 -0
  33. package/dist/types/src/components/CellGrid/render/static-layer.d.ts.map +1 -0
  34. package/dist/types/src/components/CellGrid/state/atoms.d.ts +23 -0
  35. package/dist/types/src/components/CellGrid/state/atoms.d.ts.map +1 -0
  36. package/dist/types/src/components/CellGrid/state/index.d.ts +4 -0
  37. package/dist/types/src/components/CellGrid/state/index.d.ts.map +1 -0
  38. package/dist/types/src/components/CellGrid/state/types.d.ts +39 -0
  39. package/dist/types/src/components/CellGrid/state/types.d.ts.map +1 -0
  40. package/dist/types/src/components/CellGrid/state/viewport.d.ts +52 -0
  41. package/dist/types/src/components/CellGrid/state/viewport.d.ts.map +1 -0
  42. package/dist/types/src/components/CellGrid/state/viewport.test.d.ts +2 -0
  43. package/dist/types/src/components/CellGrid/state/viewport.test.d.ts.map +1 -0
  44. package/dist/types/src/components/FPS.d.ts.map +1 -1
  45. package/dist/types/src/components/Grid/Grid.d.ts +2 -2
  46. package/dist/types/src/components/Grid/Grid.d.ts.map +1 -1
  47. package/dist/types/src/components/Grid/Grid.stories.d.ts +1 -1
  48. package/dist/types/src/components/Grid/Grid.stories.d.ts.map +1 -1
  49. package/dist/types/src/components/index.d.ts +1 -0
  50. package/dist/types/src/components/index.d.ts.map +1 -1
  51. package/dist/types/src/hooks/index.d.ts +1 -0
  52. package/dist/types/src/hooks/index.d.ts.map +1 -1
  53. package/dist/types/src/hooks/projection.d.ts.map +1 -1
  54. package/dist/types/src/hooks/useDrag.d.ts +6 -0
  55. package/dist/types/src/hooks/useDrag.d.ts.map +1 -0
  56. package/dist/types/src/hooks/useWheel.d.ts.map +1 -1
  57. package/dist/types/src/util/svg.d.ts +1 -1
  58. package/dist/types/src/util/svg.d.ts.map +1 -1
  59. package/dist/types/src/util/svg.stories.d.ts.map +1 -1
  60. package/dist/types/src/util/util.d.ts.map +1 -1
  61. package/dist/types/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +26 -26
  63. package/src/components/Canvas/Canvas.stories.tsx +6 -6
  64. package/src/components/Canvas/Canvas.tsx +4 -4
  65. package/src/components/CellGrid/CellGrid.stories.tsx +238 -0
  66. package/src/components/CellGrid/CellGrid.tsx +266 -0
  67. package/src/components/CellGrid/headers/Ruler.tsx +71 -0
  68. package/src/components/CellGrid/headers/TrackHeader.tsx +58 -0
  69. package/src/components/CellGrid/headers/index.ts +6 -0
  70. package/src/components/CellGrid/index.ts +9 -0
  71. package/src/components/CellGrid/input/index.ts +6 -0
  72. package/src/components/CellGrid/input/pointer.ts +208 -0
  73. package/src/components/CellGrid/input/wheel.ts +68 -0
  74. package/src/components/CellGrid/render/index.ts +6 -0
  75. package/src/components/CellGrid/render/overlay-layer.ts +66 -0
  76. package/src/components/CellGrid/render/static-layer.ts +112 -0
  77. package/src/components/CellGrid/state/atoms.ts +43 -0
  78. package/src/components/CellGrid/state/index.ts +7 -0
  79. package/src/components/CellGrid/state/types.ts +40 -0
  80. package/src/components/CellGrid/state/viewport.test.ts +50 -0
  81. package/src/components/CellGrid/state/viewport.ts +94 -0
  82. package/src/components/FPS.tsx +2 -2
  83. package/src/components/Grid/Grid.stories.tsx +2 -3
  84. package/src/components/Grid/Grid.tsx +13 -15
  85. package/src/components/index.ts +1 -0
  86. package/src/hooks/index.ts +1 -0
  87. package/src/hooks/useDrag.tsx +96 -0
  88. package/src/hooks/useWheel.tsx +0 -28
  89. package/src/util/svg.stories.tsx +2 -2
  90. package/src/util/svg.tsx +1 -1
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-canvas",
3
- "version": "0.8.4-main.ead640a",
3
+ "version": "0.8.4-main.f466a3d56e",
4
4
  "description": "A canvas component.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
- "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
8
12
  "author": "DXOS.org",
9
13
  "type": "module",
10
14
  "exports": {
@@ -16,45 +20,41 @@
16
20
  }
17
21
  },
18
22
  "types": "dist/types/src/index.d.ts",
19
- "typesVersions": {
20
- "*": {}
21
- },
22
23
  "files": [
23
24
  "dist",
24
25
  "src"
25
26
  ],
26
27
  "dependencies": {
27
- "@preact-signals/safe-react": "^0.9.0",
28
- "@preact/signals-core": "^1.12.1",
28
+ "@effect-atom/atom-react": "^0.5.0",
29
29
  "@radix-ui/react-context": "1.1.1",
30
30
  "bind-event-listener": "^3.0.0",
31
31
  "d3": "^7.9.0",
32
32
  "react-resize-detector": "^11.0.1",
33
33
  "transformation-matrix": "^2.16.1",
34
- "@dxos/debug": "0.8.4-main.ead640a",
35
- "@dxos/invariant": "0.8.4-main.ead640a",
36
- "@dxos/log": "0.8.4-main.ead640a",
37
- "@dxos/util": "0.8.4-main.ead640a"
34
+ "@dxos/debug": "0.8.4-main.f466a3d56e",
35
+ "@dxos/log": "0.8.4-main.f466a3d56e",
36
+ "@dxos/invariant": "0.8.4-main.f466a3d56e",
37
+ "@dxos/util": "0.8.4-main.f466a3d56e"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/d3": "^7.4.3",
41
- "@types/react": "~19.2.2",
42
- "@types/react-dom": "~19.2.1",
43
- "effect": "3.18.3",
44
- "react": "~19.2.0",
45
- "react-dom": "~19.2.0",
46
- "vite": "7.1.9",
47
- "@dxos/random": "0.8.4-main.ead640a",
48
- "@dxos/react-ui": "0.8.4-main.ead640a",
49
- "@dxos/react-ui-theme": "0.8.4-main.ead640a",
50
- "@dxos/storybook-utils": "0.8.4-main.ead640a"
41
+ "@types/react": "~19.2.7",
42
+ "@types/react-dom": "~19.2.3",
43
+ "effect": "3.20.0",
44
+ "react": "~19.2.3",
45
+ "react-dom": "~19.2.3",
46
+ "vite": "^8.0.13",
47
+ "@dxos/random": "0.8.4-main.f466a3d56e",
48
+ "@dxos/react-ui": "0.8.4-main.f466a3d56e",
49
+ "@dxos/storybook-utils": "0.8.4-main.f466a3d56e",
50
+ "@dxos/ui-theme": "0.8.4-main.f466a3d56e"
51
51
  },
52
52
  "peerDependencies": {
53
- "effect": "3.13.3",
54
- "react": "^19.0.0",
55
- "react-dom": "^19.0.0",
56
- "@dxos/react-ui": "0.8.4-main.ead640a",
57
- "@dxos/react-ui-theme": "0.8.4-main.ead640a"
53
+ "effect": "3.20.0",
54
+ "react": "~19.2.3",
55
+ "react-dom": "~19.2.3",
56
+ "@dxos/ui-theme": "0.8.4-main.f466a3d56e",
57
+ "@dxos/react-ui": "0.8.4-main.f466a3d56e"
58
58
  },
59
59
  "publishConfig": {
60
60
  "access": "public"
@@ -5,13 +5,12 @@
5
5
  import type { Meta, StoryObj } from '@storybook/react-vite';
6
6
  import React from 'react';
7
7
 
8
- import { withTheme } from '@dxos/react-ui/testing';
8
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
9
9
 
10
- import { useCanvasContext, useWheel } from '../../hooks';
10
+ import { useCanvasContext, useDrag, useWheel } from '../../hooks';
11
11
  import { type Point } from '../../types';
12
12
  import { testId } from '../../util';
13
13
  import { Grid, type GridProps } from '../Grid';
14
-
15
14
  import { Canvas } from './Canvas';
16
15
 
17
16
  const size = 128;
@@ -32,7 +31,7 @@ const DefaultStory = (props: GridProps) => {
32
31
 
33
32
  const TwoCanvases = (props: GridProps) => {
34
33
  return (
35
- <div className='grid grid-cols-2 gap-2 w-full h-full'>
34
+ <div className='grid grid-cols-2 gap-2 h-full w-full'>
36
35
  <div className='h-full relative'>
37
36
  <Canvas>
38
37
  <Grid {...props} />
@@ -51,6 +50,7 @@ const TwoCanvases = (props: GridProps) => {
51
50
 
52
51
  const Content = () => {
53
52
  useWheel();
53
+ useDrag();
54
54
  return (
55
55
  <div>
56
56
  {points.map(({ x, y }, i) => (
@@ -91,7 +91,7 @@ const meta = {
91
91
  title: 'ui/react-ui-canvas/Canvas',
92
92
  component: Grid,
93
93
  render: DefaultStory,
94
- decorators: [withTheme],
94
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
95
95
  parameters: {
96
96
  layout: 'fullscreen',
97
97
  },
@@ -106,6 +106,6 @@ export const Default: Story = {
106
106
  };
107
107
 
108
108
  export const SideBySide: Story = {
109
- args: { size: 16 },
110
109
  render: TwoCanvases,
110
+ args: { size: 16 },
111
111
  };
@@ -15,7 +15,7 @@ import React, {
15
15
  import { useResizeDetector } from 'react-resize-detector';
16
16
 
17
17
  import { type ThemedClassName } from '@dxos/react-ui';
18
- import { mx } from '@dxos/react-ui-theme';
18
+ import { mx } from '@dxos/ui-theme';
19
19
 
20
20
  import { CanvasContext, ProjectionMapper, type ProjectionState, defaultOrigin } from '../../hooks';
21
21
 
@@ -30,7 +30,7 @@ export type CanvasProps = ThemedClassName<PropsWithChildren<Partial<ProjectionSt
30
30
  * Manages CSS projection.
31
31
  */
32
32
  export const Canvas = forwardRef<CanvasController, CanvasProps>(
33
- ({ children, classNames, scale: _scale = 1, offset: _offset = defaultOrigin, ...props }, forwardedRef) => {
33
+ ({ children, classNames, scale: scaleProp = 1, offset: offsetProp = defaultOrigin, ...props }, forwardedRef) => {
34
34
  // Size.
35
35
  const { ref, width = 0, height = 0 } = useResizeDetector();
36
36
 
@@ -38,7 +38,7 @@ export const Canvas = forwardRef<CanvasController, CanvasProps>(
38
38
  const [ready, setReady] = useState(false);
39
39
 
40
40
  // Projection.
41
- const [{ scale, offset }, setProjection] = useState<ProjectionState>({ scale: _scale, offset: _offset });
41
+ const [{ scale, offset }, setProjection] = useState<ProjectionState>({ scale: scaleProp, offset: offsetProp });
42
42
  useEffect(() => {
43
43
  if (width && height && offset === defaultOrigin) {
44
44
  setProjection({ scale, offset: { x: width / 2, y: height / 2 } });
@@ -76,7 +76,7 @@ export const Canvas = forwardRef<CanvasController, CanvasProps>(
76
76
  <CanvasContext.Provider
77
77
  value={{ root: ref.current, ready, width, height, scale, offset, styles, projection, setProjection }}
78
78
  >
79
- <div role='none' {...props} className={mx('absolute inset-0 overflow-hidden', classNames)} ref={ref}>
79
+ <div {...props} className={mx('absolute inset-0 overflow-hidden', classNames)} ref={ref}>
80
80
  {ready ? children : null}
81
81
  </div>
82
82
  </CanvasContext.Provider>
@@ -0,0 +1,238 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { RegistryContext, Registry } from '@effect-atom/atom-react';
6
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
7
+ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
8
+
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { CellGrid, type CellGridProps } from './CellGrid';
12
+ import { toggleCell } from './input';
13
+ import { type RenderCell } from './render';
14
+ import { createCellGridAtoms } from './state/atoms';
15
+ import type { Cell, CellCoord, Row, Tool } from './state/types';
16
+
17
+ type SequencerData = { velocity: number };
18
+
19
+ const trackColors = [
20
+ '#ef4444', // red
21
+ '#f97316', // orange
22
+ '#eab308', // yellow
23
+ '#22c55e', // green
24
+ '#06b6d4', // cyan
25
+ '#3b82f6', // blue
26
+ '#a855f7', // purple
27
+ '#ec4899', // pink
28
+ ];
29
+
30
+ const renderSequencerCell: RenderCell<SequencerData> = ({ ctx, x, y, w, h, cell }) => {
31
+ const inset = 1;
32
+ const radius = 4;
33
+ const color = trackColors[cell.row % trackColors.length];
34
+ const velocity = cell.data?.velocity ?? 0.8;
35
+ ctx.fillStyle = color;
36
+ ctx.globalAlpha = 0.3 + velocity * 0.7;
37
+ roundedRect(ctx, x + inset, y + inset, w - inset * 2, h - inset * 2, radius);
38
+ ctx.fill();
39
+ ctx.globalAlpha = 1;
40
+ };
41
+
42
+ const renderDataVizCell: RenderCell<{ magnitude: number }> = ({ ctx, x, y, w, h, cell }) => {
43
+ const magnitude = cell.data?.magnitude ?? 0.5;
44
+ const cx = x + w / 2;
45
+ const cy = y + h / 2;
46
+ const r = Math.max(2, (Math.min(w, h) / 2 - 2) * magnitude);
47
+ ctx.fillStyle = `hsl(${(cell.row * 30) % 360}, 70%, 55%)`;
48
+ ctx.globalAlpha = 0.85;
49
+ ctx.beginPath();
50
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
51
+ ctx.fill();
52
+ ctx.globalAlpha = 1;
53
+ };
54
+
55
+ const roundedRect = (ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void => {
56
+ const radius = Math.min(r, w / 2, h / 2);
57
+ ctx.beginPath();
58
+ ctx.moveTo(x + radius, y);
59
+ ctx.lineTo(x + w - radius, y);
60
+ ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
61
+ ctx.lineTo(x + w, y + h - radius);
62
+ ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
63
+ ctx.lineTo(x + radius, y + h);
64
+ ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
65
+ ctx.lineTo(x, y + radius);
66
+ ctx.quadraticCurveTo(x, y, x + radius, y);
67
+ ctx.closePath();
68
+ };
69
+
70
+ // Deterministic 0..1 random — Linear Congruential Generator. Used in story
71
+ // seeding so the same args produce the same grid every render.
72
+ const makeLcg = (seed: number) => {
73
+ let state = seed >>> 0;
74
+ return () => {
75
+ state = (state * 1664525 + 1013904223) >>> 0;
76
+ return state / 0xffffffff;
77
+ };
78
+ };
79
+
80
+ type Variant = 'sequencer' | 'data-viz';
81
+
82
+ type StoryProps = Pick<CellGridProps, 'headers'> & {
83
+ variant: Variant;
84
+ tool: Tool;
85
+ numCols: number;
86
+ numRows: number;
87
+ cellWidth: number;
88
+ cellHeight: number;
89
+ playback: boolean;
90
+ };
91
+
92
+ const DefaultStory = ({ variant, tool, numCols, numRows, cellWidth, cellHeight, playback, headers }: StoryProps) => {
93
+ const registry = useContext(RegistryContext);
94
+
95
+ const atoms = useMemo(
96
+ () => createCellGridAtoms<SequencerData | { magnitude: number }>({ cellWidth, cellHeight }),
97
+ [cellWidth, cellHeight],
98
+ );
99
+ const rows: Row[] = useMemo(
100
+ () =>
101
+ Array.from({ length: numRows }, (_, index) => ({
102
+ id: `r${index}`,
103
+ label: variant === 'sequencer' ? `Track ${index + 1}` : `Series ${index + 1}`,
104
+ })),
105
+ [numRows, variant],
106
+ );
107
+
108
+ // Set tool whenever it changes.
109
+ useEffect(() => {
110
+ registry.set(atoms.tool, tool);
111
+ }, [registry, atoms.tool, tool]);
112
+
113
+ // Seed with sample data. Use a deterministic LCG instead of Math.random so
114
+ // story renders are stable across runs (helpful for visual review / snapshots).
115
+ useEffect(() => {
116
+ const next = new Map<string, Cell<SequencerData | { magnitude: number }>>();
117
+ const lcg = makeLcg(0xc0ffee);
118
+ if (variant === 'sequencer') {
119
+ for (let row = 0; row < numRows; row++) {
120
+ for (let col = 0; col < numCols; col += row + 2) {
121
+ if (col % (row + 1) === 0) {
122
+ next.set(`${col},${row}`, { col, row, length: 1, data: { velocity: 0.5 + lcg() * 0.5 } });
123
+ }
124
+ }
125
+ }
126
+ } else {
127
+ for (let row = 0; row < numRows; row++) {
128
+ for (let col = 0; col < numCols; col++) {
129
+ if (lcg() < 0.35) {
130
+ next.set(`${col},${row}`, { col, row, length: 1, data: { magnitude: lcg() } });
131
+ }
132
+ }
133
+ }
134
+ }
135
+ registry.set(atoms.cells, next);
136
+ }, [registry, atoms.cells, variant, numCols, numRows]);
137
+
138
+ // Animated playhead.
139
+ const playRef = useRef<number | null>(null);
140
+ useEffect(() => {
141
+ if (!playback) {
142
+ registry.set(atoms.playhead, null);
143
+ return;
144
+ }
145
+ const start = performance.now();
146
+ const period = 4_000; // ms to traverse numCols.
147
+ const tick = (now: number) => {
148
+ const t = ((now - start) % period) / period;
149
+ registry.set(atoms.playhead, t * numCols);
150
+ playRef.current = requestAnimationFrame(tick);
151
+ };
152
+ playRef.current = requestAnimationFrame(tick);
153
+ return () => {
154
+ if (playRef.current !== null) {
155
+ cancelAnimationFrame(playRef.current);
156
+ }
157
+ registry.set(atoms.playhead, null);
158
+ };
159
+ }, [registry, atoms.playhead, playback, numCols]);
160
+
161
+ const renderCell = variant === 'sequencer' ? (renderSequencerCell as RenderCell) : (renderDataVizCell as RenderCell);
162
+
163
+ const handleToggle = (coord: CellCoord) => {
164
+ toggleCell(registry, atoms, coord, ({ col, row }) => ({
165
+ col,
166
+ row,
167
+ length: 1,
168
+ data: variant === 'sequencer' ? { velocity: 0.8 } : { magnitude: 0.7 },
169
+ }));
170
+ };
171
+
172
+ return (
173
+ <div className='absolute inset-0'>
174
+ <CellGrid
175
+ atoms={atoms as any}
176
+ rows={rows}
177
+ renderCell={renderCell}
178
+ headers={headers}
179
+ onCellToggle={handleToggle}
180
+ />
181
+ </div>
182
+ );
183
+ };
184
+
185
+ const RegistryWrapper = ({ children }: { children: React.ReactNode }) => {
186
+ const [registry] = useState(() => Registry.make());
187
+ return <RegistryContext.Provider value={registry}>{children}</RegistryContext.Provider>;
188
+ };
189
+
190
+ const meta: Meta<typeof DefaultStory> = {
191
+ title: 'ui/react-ui-canvas/CellGrid',
192
+ component: DefaultStory,
193
+ render: (args) => (
194
+ <RegistryWrapper>
195
+ <DefaultStory {...args} />
196
+ </RegistryWrapper>
197
+ ),
198
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
199
+ parameters: { layout: 'fullscreen' },
200
+ argTypes: {
201
+ variant: { control: { type: 'inline-radio' }, options: ['sequencer', 'data-viz'] },
202
+ // 'resize' is deferred (PR description "Deferred / out of v1") — hide from controls.
203
+ tool: { control: { type: 'inline-radio' }, options: ['toggle', 'select'] },
204
+ numCols: { control: { type: 'number', min: 16, max: 1024, step: 16 } },
205
+ numRows: { control: { type: 'number', min: 1, max: 64, step: 1 } },
206
+ cellWidth: { control: { type: 'number', min: 8, max: 64, step: 1 } },
207
+ cellHeight: { control: { type: 'number', min: 12, max: 64, step: 1 } },
208
+ playback: { control: 'boolean' },
209
+ },
210
+ };
211
+
212
+ export default meta;
213
+
214
+ type Story = StoryObj<typeof meta>;
215
+
216
+ export const Sequencer: Story = {
217
+ args: {
218
+ variant: 'sequencer',
219
+ tool: 'toggle',
220
+ numCols: 64,
221
+ numRows: 8,
222
+ cellWidth: 24,
223
+ cellHeight: 24,
224
+ playback: true,
225
+ },
226
+ };
227
+
228
+ export const DataViz: Story = {
229
+ args: {
230
+ variant: 'data-viz',
231
+ tool: 'select',
232
+ numCols: 256,
233
+ numRows: 12,
234
+ cellWidth: 20,
235
+ cellHeight: 20,
236
+ playback: false,
237
+ },
238
+ };
@@ -0,0 +1,266 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { RegistryContext } from '@effect-atom/atom-react';
6
+ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { useResizeDetector } from 'react-resize-detector';
8
+
9
+ import type { ThemedClassName } from '@dxos/react-ui';
10
+ import { mx } from '@dxos/ui-theme';
11
+
12
+ import { Ruler, TrackHeader } from './headers';
13
+ import { attachPointerHandlers, attachWheelHandlers, type PointerHandlers } from './input';
14
+ import { drawCells, drawOverlay, type OverlayStyle, type RenderCell, type StaticLayerStyle } from './render';
15
+ import type { CellGridAtoms } from './state/atoms';
16
+ import type { Cell, Headers, Row } from './state/types';
17
+
18
+ export type CellGridProps<T = unknown> = ThemedClassName<
19
+ PointerHandlers & {
20
+ atoms: CellGridAtoms<T>;
21
+ rows: ReadonlyArray<Row>;
22
+ renderCell: RenderCell<T>;
23
+ headers?: Partial<Headers> | false;
24
+ staticStyle?: Partial<StaticLayerStyle>;
25
+ overlayStyle?: Partial<OverlayStyle>;
26
+ }
27
+ >;
28
+
29
+ const defaultHeaders: Headers = { left: 80, top: 24 };
30
+
31
+ const defaultStaticStyle: StaticLayerStyle = {
32
+ gridLine: 'rgba(128,128,128,0.25)',
33
+ rowBand: 'rgba(128,128,128,0.06)',
34
+ };
35
+
36
+ const defaultOverlayStyle: OverlayStyle = {
37
+ playhead: 'rgb(220, 38, 38)',
38
+ selectionFill: 'rgba(59, 130, 246, 0.15)',
39
+ selectionStroke: 'rgb(59, 130, 246)',
40
+ };
41
+
42
+ const setupCanvas = (canvas: HTMLCanvasElement, width: number, height: number): CanvasRenderingContext2D | null => {
43
+ const dpr = window.devicePixelRatio || 1;
44
+ canvas.width = Math.max(1, Math.floor(width * dpr));
45
+ canvas.height = Math.max(1, Math.floor(height * dpr));
46
+ canvas.style.width = `${width}px`;
47
+ canvas.style.height = `${height}px`;
48
+ const ctx = canvas.getContext('2d');
49
+ if (!ctx) {
50
+ return null;
51
+ }
52
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
53
+ return ctx;
54
+ };
55
+
56
+ /**
57
+ * Canvas-based 2D grid where cells contain pluggable shapes. Suitable for music sequencers,
58
+ * time-series data viz, and similar workloads at ~1k visible cells per viewport.
59
+ */
60
+ export const CellGrid = <T,>({
61
+ atoms,
62
+ rows,
63
+ renderCell,
64
+ headers: headersProp,
65
+ staticStyle: staticStyleProp,
66
+ overlayStyle: overlayStyleProp,
67
+ classNames,
68
+ onCellToggle,
69
+ onSelectionCommit,
70
+ }: CellGridProps<T>) => {
71
+ const registry = useContext(RegistryContext);
72
+
73
+ const headers = useMemo<Headers>(() => {
74
+ if (headersProp === false) {
75
+ return { left: 0, top: 0 };
76
+ }
77
+ return { ...defaultHeaders, ...(headersProp ?? {}) };
78
+ }, [headersProp]);
79
+
80
+ const staticStyle = useMemo<StaticLayerStyle>(
81
+ () => ({ ...defaultStaticStyle, ...(staticStyleProp ?? {}) }),
82
+ [staticStyleProp],
83
+ );
84
+ const overlayStyle = useMemo<OverlayStyle>(
85
+ () => ({ ...defaultOverlayStyle, ...(overlayStyleProp ?? {}) }),
86
+ [overlayStyleProp],
87
+ );
88
+
89
+ const { ref: containerRef, width = 0, height = 0 } = useResizeDetector<HTMLDivElement>();
90
+ const staticCanvasRef = useRef<HTMLCanvasElement>(null);
91
+ const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
92
+ const overlayInputRef = useRef<HTMLDivElement>(null);
93
+
94
+ const [staticCtx, setStaticCtx] = useState<CanvasRenderingContext2D | null>(null);
95
+ const [overlayCtx, setOverlayCtx] = useState<CanvasRenderingContext2D | null>(null);
96
+
97
+ // Track header rerenders for the React-side ruler/track-header.
98
+ const [viewportState, setViewportState] = useState(() => registry.get(atoms.viewport));
99
+
100
+ // Resize canvases on container resize.
101
+ useEffect(() => {
102
+ if (!width || !height) {
103
+ return;
104
+ }
105
+ if (staticCanvasRef.current) {
106
+ const ctx = setupCanvas(staticCanvasRef.current, width, height);
107
+ setStaticCtx(ctx);
108
+ }
109
+ if (overlayCanvasRef.current) {
110
+ const ctx = setupCanvas(overlayCanvasRef.current, width, height);
111
+ setOverlayCtx(ctx);
112
+ }
113
+ }, [width, height]);
114
+
115
+ // Mirror viewport into React state so headers re-render on scroll/zoom.
116
+ useEffect(() => registry.subscribe(atoms.viewport, (next) => setViewportState(next)), [registry, atoms.viewport]);
117
+
118
+ // Static-layer redraw on (cells, viewport, rows, headers, style, size) change.
119
+ useEffect(() => {
120
+ if (!staticCtx || !width || !height) {
121
+ return;
122
+ }
123
+ let raf: number | null = null;
124
+ const schedule = () => {
125
+ if (raf !== null) {
126
+ return;
127
+ }
128
+ raf = requestAnimationFrame(() => {
129
+ raf = null;
130
+ drawCells({
131
+ ctx: staticCtx,
132
+ size: { width, height },
133
+ viewport: registry.get(atoms.viewport),
134
+ headers,
135
+ rows,
136
+ cells: registry.get(atoms.cells),
137
+ renderCell,
138
+ style: staticStyle,
139
+ });
140
+ });
141
+ };
142
+ schedule();
143
+ const unsubCells = registry.subscribe(atoms.cells, schedule);
144
+ const unsubViewport = registry.subscribe(atoms.viewport, schedule);
145
+ return () => {
146
+ if (raf !== null) {
147
+ cancelAnimationFrame(raf);
148
+ }
149
+ unsubCells();
150
+ unsubViewport();
151
+ };
152
+ }, [staticCtx, width, height, registry, atoms.cells, atoms.viewport, headers, rows, renderCell, staticStyle]);
153
+
154
+ // Overlay rAF loop while playhead or active selection drag.
155
+ useEffect(() => {
156
+ if (!overlayCtx || !width || !height) {
157
+ return;
158
+ }
159
+ let raf: number | null = null;
160
+ let stopped = false;
161
+
162
+ const paint = () => {
163
+ drawOverlay({
164
+ ctx: overlayCtx,
165
+ size: { width, height },
166
+ viewport: registry.get(atoms.viewport),
167
+ headers,
168
+ selection: registry.get(atoms.selection),
169
+ playhead: registry.get(atoms.playhead),
170
+ style: overlayStyle,
171
+ });
172
+ };
173
+
174
+ const isAnimating = () => registry.get(atoms.playhead) !== null;
175
+
176
+ const loop = () => {
177
+ if (stopped) {
178
+ return;
179
+ }
180
+ paint();
181
+ if (isAnimating()) {
182
+ raf = requestAnimationFrame(loop);
183
+ } else {
184
+ raf = null;
185
+ }
186
+ };
187
+
188
+ const kick = () => {
189
+ paint();
190
+ if (raf === null && isAnimating()) {
191
+ raf = requestAnimationFrame(loop);
192
+ }
193
+ };
194
+
195
+ kick();
196
+ const unsubSelection = registry.subscribe(atoms.selection, () => paint());
197
+ const unsubPlayhead = registry.subscribe(atoms.playhead, kick);
198
+ const unsubViewport = registry.subscribe(atoms.viewport, () => paint());
199
+
200
+ return () => {
201
+ stopped = true;
202
+ if (raf !== null) {
203
+ cancelAnimationFrame(raf);
204
+ }
205
+ unsubSelection();
206
+ unsubPlayhead();
207
+ unsubViewport();
208
+ };
209
+ }, [overlayCtx, width, height, registry, atoms.selection, atoms.playhead, atoms.viewport, headers, overlayStyle]);
210
+
211
+ // Input wiring. Keep the callbacks in a ref so the listener attachment is stable
212
+ // across consumer re-renders — otherwise an in-progress drag is torn down when
213
+ // the parent's onCellToggle identity changes (e.g. after the very mutation we
214
+ // just triggered), and subsequent pointermove events see drag === null.
215
+ const callbacksRef = useRef<PointerHandlers>({ onCellToggle, onSelectionCommit });
216
+ callbacksRef.current = { onCellToggle, onSelectionCommit };
217
+ useEffect(() => {
218
+ const element = overlayInputRef.current;
219
+ if (!element) {
220
+ return;
221
+ }
222
+ const detachPointer = attachPointerHandlers(element, {
223
+ registry,
224
+ atoms,
225
+ headers,
226
+ handlers: {
227
+ onCellToggle: (coord, mode) => callbacksRef.current.onCellToggle?.(coord, mode),
228
+ onSelectionCommit: (range) => callbacksRef.current.onSelectionCommit?.(range),
229
+ },
230
+ });
231
+ const detachWheel = attachWheelHandlers(element, { registry, atoms, headers });
232
+ return () => {
233
+ detachPointer();
234
+ detachWheel();
235
+ };
236
+ }, [registry, atoms, headers]);
237
+
238
+ return (
239
+ <div ref={containerRef} className={mx('relative w-full h-full overflow-hidden bg-baseSurface', classNames)}>
240
+ {/*
241
+ Canvases are nudged up + left 1 CSS pixel so the gridlines (drawn at the TOP
242
+ and LEFT edges of each cell) sit on top of the frozen header dividers — the
243
+ TrackHeader's right border and bottom-row box-shadow, which both render 1px
244
+ inside the box. Without this offset the canvas gridlines land 1px down/right
245
+ of the header dividers and the columns read as misaligned.
246
+ */}
247
+ <canvas ref={staticCanvasRef} className='absolute inset-0 pointer-events-none' style={{ top: -1, left: -1 }} />
248
+ <canvas ref={overlayCanvasRef} className='absolute inset-0 pointer-events-none' style={{ top: -1, left: -1 }} />
249
+ <div
250
+ ref={overlayInputRef}
251
+ className='absolute inset-0 touch-none'
252
+ style={{ paddingLeft: headers.left, paddingTop: headers.top }}
253
+ />
254
+ {headers.top > 0 && <Ruler viewport={viewportState} headers={headers} width={width} />}
255
+ {headers.left > 0 && <TrackHeader viewport={viewportState} headers={headers} rows={rows} height={height} />}
256
+ {headers.top > 0 && headers.left > 0 && (
257
+ <div
258
+ className='absolute top-0 left-0 border-b border-r border-neutral-200 dark:border-neutral-700 bg-baseSurface'
259
+ style={{ width: headers.left, height: headers.top }}
260
+ />
261
+ )}
262
+ </div>
263
+ );
264
+ };
265
+
266
+ export type { Cell };