@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.
- package/LICENSE +102 -5
- package/dist/lib/browser/index.mjs +1131 -388
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +1131 -388
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/Canvas/Canvas.d.ts +2 -2
- package/dist/types/src/components/Canvas/Canvas.stories.d.ts.map +1 -1
- package/dist/types/src/components/CellGrid/CellGrid.d.ts +21 -0
- package/dist/types/src/components/CellGrid/CellGrid.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/CellGrid.stories.d.ts +21 -0
- package/dist/types/src/components/CellGrid/CellGrid.stories.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/headers/Ruler.d.ts +15 -0
- package/dist/types/src/components/CellGrid/headers/Ruler.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/headers/TrackHeader.d.ts +19 -0
- package/dist/types/src/components/CellGrid/headers/TrackHeader.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/headers/index.d.ts +3 -0
- package/dist/types/src/components/CellGrid/headers/index.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/index.d.ts +6 -0
- package/dist/types/src/components/CellGrid/index.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/input/index.d.ts +3 -0
- package/dist/types/src/components/CellGrid/input/index.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/input/pointer.d.ts +29 -0
- package/dist/types/src/components/CellGrid/input/pointer.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/input/wheel.d.ts +14 -0
- package/dist/types/src/components/CellGrid/input/wheel.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/render/index.d.ts +3 -0
- package/dist/types/src/components/CellGrid/render/index.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/render/overlay-layer.d.ts +21 -0
- package/dist/types/src/components/CellGrid/render/overlay-layer.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/render/static-layer.d.ts +36 -0
- package/dist/types/src/components/CellGrid/render/static-layer.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/state/atoms.d.ts +23 -0
- package/dist/types/src/components/CellGrid/state/atoms.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/state/index.d.ts +4 -0
- package/dist/types/src/components/CellGrid/state/index.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/state/types.d.ts +39 -0
- package/dist/types/src/components/CellGrid/state/types.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/state/viewport.d.ts +52 -0
- package/dist/types/src/components/CellGrid/state/viewport.d.ts.map +1 -0
- package/dist/types/src/components/CellGrid/state/viewport.test.d.ts +2 -0
- package/dist/types/src/components/CellGrid/state/viewport.test.d.ts.map +1 -0
- package/dist/types/src/components/FPS.d.ts.map +1 -1
- package/dist/types/src/components/Grid/Grid.d.ts +2 -2
- package/dist/types/src/components/Grid/Grid.d.ts.map +1 -1
- package/dist/types/src/components/Grid/Grid.stories.d.ts +1 -1
- package/dist/types/src/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +1 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/projection.d.ts.map +1 -1
- package/dist/types/src/hooks/useDrag.d.ts +6 -0
- package/dist/types/src/hooks/useDrag.d.ts.map +1 -0
- package/dist/types/src/hooks/useWheel.d.ts.map +1 -1
- package/dist/types/src/util/svg.d.ts +1 -1
- package/dist/types/src/util/svg.d.ts.map +1 -1
- package/dist/types/src/util/svg.stories.d.ts.map +1 -1
- package/dist/types/src/util/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +26 -26
- package/src/components/Canvas/Canvas.stories.tsx +6 -6
- package/src/components/Canvas/Canvas.tsx +4 -4
- package/src/components/CellGrid/CellGrid.stories.tsx +238 -0
- package/src/components/CellGrid/CellGrid.tsx +266 -0
- package/src/components/CellGrid/headers/Ruler.tsx +71 -0
- package/src/components/CellGrid/headers/TrackHeader.tsx +58 -0
- package/src/components/CellGrid/headers/index.ts +6 -0
- package/src/components/CellGrid/index.ts +9 -0
- package/src/components/CellGrid/input/index.ts +6 -0
- package/src/components/CellGrid/input/pointer.ts +208 -0
- package/src/components/CellGrid/input/wheel.ts +68 -0
- package/src/components/CellGrid/render/index.ts +6 -0
- package/src/components/CellGrid/render/overlay-layer.ts +66 -0
- package/src/components/CellGrid/render/static-layer.ts +112 -0
- package/src/components/CellGrid/state/atoms.ts +43 -0
- package/src/components/CellGrid/state/index.ts +7 -0
- package/src/components/CellGrid/state/types.ts +40 -0
- package/src/components/CellGrid/state/viewport.test.ts +50 -0
- package/src/components/CellGrid/state/viewport.ts +94 -0
- package/src/components/FPS.tsx +2 -2
- package/src/components/Grid/Grid.stories.tsx +2 -3
- package/src/components/Grid/Grid.tsx +13 -15
- package/src/components/index.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDrag.tsx +96 -0
- package/src/hooks/useWheel.tsx +0 -28
- package/src/util/svg.stories.tsx +2 -2
- 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.
|
|
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
|
-
"
|
|
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
|
-
"@
|
|
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.
|
|
35
|
-
"@dxos/
|
|
36
|
-
"@dxos/
|
|
37
|
-
"@dxos/util": "0.8.4-main.
|
|
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.
|
|
42
|
-
"@types/react-dom": "~19.2.
|
|
43
|
-
"effect": "3.
|
|
44
|
-
"react": "~19.2.
|
|
45
|
-
"react-dom": "~19.2.
|
|
46
|
-
"vite": "
|
|
47
|
-
"@dxos/random": "0.8.4-main.
|
|
48
|
-
"@dxos/react-ui": "0.8.4-main.
|
|
49
|
-
"@dxos/
|
|
50
|
-
"@dxos/
|
|
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.
|
|
54
|
-
"react": "
|
|
55
|
-
"react-dom": "
|
|
56
|
-
"@dxos/
|
|
57
|
-
"@dxos/react-ui
|
|
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
|
|
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/
|
|
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:
|
|
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:
|
|
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
|
|
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 };
|