@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { mx } from '@dxos/ui-theme';
|
|
8
|
+
|
|
9
|
+
import type { Headers, Viewport } from '../state/types';
|
|
10
|
+
import { cellWidth } from '../state/viewport';
|
|
11
|
+
|
|
12
|
+
export type RulerProps = {
|
|
13
|
+
viewport: Viewport;
|
|
14
|
+
headers: Headers;
|
|
15
|
+
width: number;
|
|
16
|
+
/** Number of columns to label between major ticks. */
|
|
17
|
+
majorEvery?: number;
|
|
18
|
+
classNames?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Frozen top ruler. Ticks reflect the current viewport scroll and zoom.
|
|
23
|
+
*/
|
|
24
|
+
export const Ruler = ({ viewport, headers, width, majorEvery = 4, classNames }: RulerProps) => {
|
|
25
|
+
// Clamp to >= 1 — a zero or negative `majorEvery` makes `col % majorEvery`
|
|
26
|
+
// NaN / always-zero and breaks major-tick detection.
|
|
27
|
+
const safeMajorEvery = Math.max(1, Math.floor(majorEvery));
|
|
28
|
+
const ticks = useMemo(() => {
|
|
29
|
+
const w = cellWidth(viewport);
|
|
30
|
+
if (w < 1 || width <= headers.left) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const innerWidth = width - headers.left;
|
|
34
|
+
const startCol = Math.floor(viewport.scrollX / w);
|
|
35
|
+
const endCol = Math.ceil((viewport.scrollX + innerWidth) / w);
|
|
36
|
+
const result: Array<{ col: number; x: number; major: boolean }> = [];
|
|
37
|
+
for (let col = startCol; col <= endCol; col++) {
|
|
38
|
+
result.push({
|
|
39
|
+
col,
|
|
40
|
+
x: headers.left + col * w - viewport.scrollX,
|
|
41
|
+
major: col % safeMajorEvery === 0,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}, [viewport, headers.left, width, safeMajorEvery]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className={mx(
|
|
50
|
+
'absolute top-0 left-0 right-0 border-b border-neutral-200 dark:border-neutral-700 bg-baseSurface select-none overflow-hidden',
|
|
51
|
+
classNames,
|
|
52
|
+
)}
|
|
53
|
+
style={{ height: headers.top }}
|
|
54
|
+
>
|
|
55
|
+
{ticks.map(({ col, x, major }) => (
|
|
56
|
+
<div
|
|
57
|
+
key={col}
|
|
58
|
+
className={mx(
|
|
59
|
+
'absolute top-0 bottom-0 text-[10px] text-neutral-500 dark:text-neutral-400',
|
|
60
|
+
major
|
|
61
|
+
? 'border-l border-neutral-400 dark:border-neutral-500'
|
|
62
|
+
: 'border-l border-neutral-200 dark:border-neutral-700',
|
|
63
|
+
)}
|
|
64
|
+
style={{ transform: `translateX(${x}px)` }}
|
|
65
|
+
>
|
|
66
|
+
{major ? <span className='absolute left-1 top-0'>{col}</span> : null}
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import { mx } from '@dxos/ui-theme';
|
|
8
|
+
|
|
9
|
+
import type { Headers, Row, Viewport } from '../state/types';
|
|
10
|
+
|
|
11
|
+
export type TrackHeaderProps = {
|
|
12
|
+
viewport: Viewport;
|
|
13
|
+
headers: Headers;
|
|
14
|
+
rows: ReadonlyArray<Row>;
|
|
15
|
+
height: number;
|
|
16
|
+
classNames?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Frozen left column listing row labels. Translates vertically in sync with viewport scroll.
|
|
21
|
+
*
|
|
22
|
+
* Row dividers and alternating shading intentionally MATCH the canvas — opaque borders
|
|
23
|
+
* and opaque alternating fills make the labels look out of phase with the cell area
|
|
24
|
+
* even when the y-positions align. We mirror the canvas's transparent-overlay model
|
|
25
|
+
* here so the frozen column reads as a direct continuation of the grid.
|
|
26
|
+
*/
|
|
27
|
+
export const TrackHeader = ({ viewport, headers, rows, height, classNames }: TrackHeaderProps) => {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={mx(
|
|
31
|
+
'absolute left-0 border-r border-neutral-200 dark:border-neutral-700 select-none overflow-hidden',
|
|
32
|
+
classNames,
|
|
33
|
+
)}
|
|
34
|
+
style={{ top: headers.top, width: headers.left, height: Math.max(0, height - headers.top) }}
|
|
35
|
+
>
|
|
36
|
+
<div style={{ transform: `translateY(${-viewport.scrollY}px)` }}>
|
|
37
|
+
{rows.map((row, index) => (
|
|
38
|
+
<div
|
|
39
|
+
key={row.id}
|
|
40
|
+
className='flex items-center px-2 text-xs text-neutral-700 dark:text-neutral-300'
|
|
41
|
+
style={{
|
|
42
|
+
height: viewport.cellHeight,
|
|
43
|
+
// Match the canvas's row-band: a translucent gray overlay on odd rows,
|
|
44
|
+
// transparent on even rows. The container's overall background bleeds
|
|
45
|
+
// through, so the labels stay legible in both themes.
|
|
46
|
+
backgroundColor: index % 2 === 0 ? 'transparent' : 'rgba(128, 128, 128, 0.08)',
|
|
47
|
+
// Match the canvas gridline color (rgba(128, 128, 128, 0.25)). Use a
|
|
48
|
+
// half-pixel inset to keep crisp single-pixel rendering on retina.
|
|
49
|
+
boxShadow: 'inset 0 -1px 0 rgba(128, 128, 128, 0.25)',
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{row.label ?? row.id}
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import type { Registry } from '@effect-atom/atom-react';
|
|
6
|
+
|
|
7
|
+
import type { CellGridAtoms } from '../state/atoms';
|
|
8
|
+
import type { Cell, CellCoord, Headers, SelectionRange, Tool } from '../state/types';
|
|
9
|
+
import { cellKey, hitTestCell } from '../state/viewport';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 'set' / 'unset' are idempotent — the receiver must add or remove the cell
|
|
13
|
+
* regardless of current state. 'toggle' flips it. Drag operations always pick
|
|
14
|
+
* a fixed mode (set or unset) based on the cell under the initial pointerdown
|
|
15
|
+
* so the user paints a uniform stroke instead of flipping each cell.
|
|
16
|
+
*/
|
|
17
|
+
export type ToggleMode = 'set' | 'unset' | 'toggle';
|
|
18
|
+
|
|
19
|
+
export type PointerHandlers = {
|
|
20
|
+
onCellToggle?: (coord: CellCoord, mode: ToggleMode) => void;
|
|
21
|
+
onSelectionCommit?: (range: SelectionRange) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type DragState =
|
|
25
|
+
| { kind: 'toggle'; mode: 'set' | 'unset'; touched: Set<string> }
|
|
26
|
+
| { kind: 'select'; origin: CellCoord }
|
|
27
|
+
| { kind: 'pan'; lastX: number; lastY: number };
|
|
28
|
+
|
|
29
|
+
export type PointerControllerOptions<T> = {
|
|
30
|
+
registry: Registry.Registry;
|
|
31
|
+
atoms: CellGridAtoms<T>;
|
|
32
|
+
headers: Headers;
|
|
33
|
+
handlers: PointerHandlers;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Attach pointer handlers to an element. Returns an unsubscribe.
|
|
38
|
+
*/
|
|
39
|
+
export const attachPointerHandlers = <T>(
|
|
40
|
+
element: HTMLElement,
|
|
41
|
+
{ registry, atoms, headers, handlers }: PointerControllerOptions<T>,
|
|
42
|
+
): (() => void) => {
|
|
43
|
+
let drag: DragState | null = null;
|
|
44
|
+
|
|
45
|
+
const local = (event: PointerEvent) => {
|
|
46
|
+
const rect = element.getBoundingClientRect();
|
|
47
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// setPointerCapture throws for synthetic / untrusted PointerEvents (e.g. those
|
|
51
|
+
// dispatched by tests). Capture is a best-effort UX nicety — never let it abort
|
|
52
|
+
// the click path.
|
|
53
|
+
const tryCapture = (pointerId: number) => {
|
|
54
|
+
try {
|
|
55
|
+
element.setPointerCapture(pointerId);
|
|
56
|
+
} catch {
|
|
57
|
+
// Ignore — drag tracking still works without capture.
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
62
|
+
// Middle-mouse or space-held pan: pan tool regardless of `tool` atom.
|
|
63
|
+
if (event.button === 1 || (event.button === 0 && event.altKey)) {
|
|
64
|
+
drag = { kind: 'pan', lastX: event.clientX, lastY: event.clientY };
|
|
65
|
+
tryCapture(event.pointerId);
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (event.button !== 0) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const viewport = registry.get(atoms.viewport);
|
|
75
|
+
const point = local(event);
|
|
76
|
+
const coord = hitTestCell(viewport, headers, point);
|
|
77
|
+
if (!coord) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tool = registry.get(atoms.tool) as Tool;
|
|
82
|
+
tryCapture(event.pointerId);
|
|
83
|
+
|
|
84
|
+
switch (tool) {
|
|
85
|
+
case 'toggle':
|
|
86
|
+
case 'resize': {
|
|
87
|
+
// Inspect the cells atom under the pointer to decide whether the gesture
|
|
88
|
+
// is a paint (set) or an erase (unset). Subsequent drag movements apply the
|
|
89
|
+
// same operation idempotently to every cell the cursor crosses.
|
|
90
|
+
const cells = registry.get(atoms.cells) as ReadonlyMap<string, unknown>;
|
|
91
|
+
const key = cellKey(coord.col, coord.row);
|
|
92
|
+
const mode: 'set' | 'unset' = cells.has(key) ? 'unset' : 'set';
|
|
93
|
+
handlers.onCellToggle?.(coord, mode);
|
|
94
|
+
drag = { kind: 'toggle', mode, touched: new Set([key]) };
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'select': {
|
|
98
|
+
drag = { kind: 'select', origin: coord };
|
|
99
|
+
registry.set(atoms.selection, {
|
|
100
|
+
range: { col0: coord.col, row0: coord.row, col1: coord.col, row1: coord.row },
|
|
101
|
+
});
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
108
|
+
if (!drag) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (drag.kind === 'pan') {
|
|
112
|
+
const dx = event.clientX - drag.lastX;
|
|
113
|
+
const dy = event.clientY - drag.lastY;
|
|
114
|
+
drag.lastX = event.clientX;
|
|
115
|
+
drag.lastY = event.clientY;
|
|
116
|
+
registry.update(atoms.viewport, (current) => ({
|
|
117
|
+
...current,
|
|
118
|
+
scrollX: Math.max(0, current.scrollX - dx),
|
|
119
|
+
scrollY: Math.max(0, current.scrollY - dy),
|
|
120
|
+
}));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const viewport = registry.get(atoms.viewport);
|
|
125
|
+
const coord = hitTestCell(viewport, headers, local(event));
|
|
126
|
+
if (!coord) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (drag.kind === 'toggle') {
|
|
131
|
+
// Apply the drag's chosen mode to every cell the cursor enters, across rows,
|
|
132
|
+
// de-duplicating per cell so we don't fire the same coord twice.
|
|
133
|
+
const key = cellKey(coord.col, coord.row);
|
|
134
|
+
if (!drag.touched.has(key)) {
|
|
135
|
+
drag.touched.add(key);
|
|
136
|
+
handlers.onCellToggle?.(coord, drag.mode);
|
|
137
|
+
}
|
|
138
|
+
} else if (drag.kind === 'select') {
|
|
139
|
+
registry.set(atoms.selection, {
|
|
140
|
+
range: { col0: drag.origin.col, row0: drag.origin.row, col1: coord.col, row1: coord.row },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const releaseCapture = (event: PointerEvent) => {
|
|
146
|
+
if (element.hasPointerCapture(event.pointerId)) {
|
|
147
|
+
element.releasePointerCapture(event.pointerId);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
152
|
+
if (!drag) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (drag.kind === 'select') {
|
|
156
|
+
const range = registry.get(atoms.selection).range;
|
|
157
|
+
if (range) {
|
|
158
|
+
handlers.onSelectionCommit?.(range);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
drag = null;
|
|
162
|
+
releaseCapture(event);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// pointercancel signals an interrupted gesture (system gesture, palm rejection,
|
|
166
|
+
// capture lost). Per the Pointer Events spec this should clean up local state
|
|
167
|
+
// ONLY and not be treated as a successful completion — so we never commit a
|
|
168
|
+
// selection from cancel.
|
|
169
|
+
const onPointerCancel = (event: PointerEvent) => {
|
|
170
|
+
drag = null;
|
|
171
|
+
releaseCapture(event);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
element.addEventListener('pointerdown', onPointerDown);
|
|
175
|
+
element.addEventListener('pointermove', onPointerMove);
|
|
176
|
+
element.addEventListener('pointerup', onPointerUp);
|
|
177
|
+
element.addEventListener('pointercancel', onPointerCancel);
|
|
178
|
+
|
|
179
|
+
return () => {
|
|
180
|
+
element.removeEventListener('pointerdown', onPointerDown);
|
|
181
|
+
element.removeEventListener('pointermove', onPointerMove);
|
|
182
|
+
element.removeEventListener('pointerup', onPointerUp);
|
|
183
|
+
element.removeEventListener('pointercancel', onPointerCancel);
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Utility for consumers: toggle, set, or unset membership of a cell in the cells atom.
|
|
189
|
+
*/
|
|
190
|
+
export const toggleCell = <T>(
|
|
191
|
+
registry: Registry.Registry,
|
|
192
|
+
atoms: CellGridAtoms<T>,
|
|
193
|
+
coord: CellCoord,
|
|
194
|
+
factory: (coord: CellCoord) => Cell<T>,
|
|
195
|
+
mode: ToggleMode = 'toggle',
|
|
196
|
+
): void => {
|
|
197
|
+
registry.update(atoms.cells, (current) => {
|
|
198
|
+
const next = new Map(current);
|
|
199
|
+
const key = cellKey(coord.col, coord.row);
|
|
200
|
+
const exists = next.has(key);
|
|
201
|
+
if (mode === 'set' || (mode === 'toggle' && !exists)) {
|
|
202
|
+
next.set(key, factory(coord));
|
|
203
|
+
} else if (mode === 'unset' || (mode === 'toggle' && exists)) {
|
|
204
|
+
next.delete(key);
|
|
205
|
+
}
|
|
206
|
+
return next;
|
|
207
|
+
});
|
|
208
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import type { Registry } from '@effect-atom/atom-react';
|
|
6
|
+
|
|
7
|
+
import type { CellGridAtoms } from '../state/atoms';
|
|
8
|
+
import type { Headers } from '../state/types';
|
|
9
|
+
import { cellWidth } from '../state/viewport';
|
|
10
|
+
|
|
11
|
+
const MIN_ZOOM = 0.25;
|
|
12
|
+
const MAX_ZOOM = 8;
|
|
13
|
+
|
|
14
|
+
export type WheelControllerOptions<T> = {
|
|
15
|
+
registry: Registry.Registry;
|
|
16
|
+
atoms: CellGridAtoms<T>;
|
|
17
|
+
headers: Headers;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Attach wheel handlers. Vertical wheel scrolls y; shift+wheel scrolls x;
|
|
22
|
+
* cmd/ctrl+wheel zooms x around the cursor.
|
|
23
|
+
*/
|
|
24
|
+
export const attachWheelHandlers = <T>(
|
|
25
|
+
element: HTMLElement,
|
|
26
|
+
{ registry, atoms, headers }: WheelControllerOptions<T>,
|
|
27
|
+
): (() => void) => {
|
|
28
|
+
const onWheel = (event: WheelEvent) => {
|
|
29
|
+
if (event.ctrlKey || event.metaKey) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
// Zoom around cursor x.
|
|
32
|
+
const rect = element.getBoundingClientRect();
|
|
33
|
+
const x = event.clientX - rect.left;
|
|
34
|
+
const factor = Math.exp(-event.deltaY / 200);
|
|
35
|
+
registry.update(atoms.viewport, (current) => {
|
|
36
|
+
const nextZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, current.zoomX * factor));
|
|
37
|
+
if (nextZoom === current.zoomX) {
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
const w = cellWidth(current);
|
|
41
|
+
const worldX = (x - headers.left + current.scrollX) / w;
|
|
42
|
+
const nextW = current.baseCellWidth * nextZoom;
|
|
43
|
+
const nextScrollX = Math.max(0, worldX * nextW - (x - headers.left));
|
|
44
|
+
return { ...current, zoomX: nextZoom, scrollX: nextScrollX };
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const dx = event.shiftKey ? event.deltaY : event.deltaX;
|
|
50
|
+
const dy = event.shiftKey ? 0 : event.deltaY;
|
|
51
|
+
const current = registry.get(atoms.viewport);
|
|
52
|
+
const nextScrollX = Math.max(0, current.scrollX + dx);
|
|
53
|
+
const nextScrollY = Math.max(0, current.scrollY + dy);
|
|
54
|
+
|
|
55
|
+
// Only consume the wheel event if we're actually scrolling within the grid.
|
|
56
|
+
// When the user wheels up at the top (scrollY === 0 && dy < 0) or wheels left
|
|
57
|
+
// at the left edge, let the event bubble to the parent so the page or
|
|
58
|
+
// enclosing container can scroll instead of swallowing the gesture.
|
|
59
|
+
if (nextScrollX === current.scrollX && nextScrollY === current.scrollY) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
registry.set(atoms.viewport, { ...current, scrollX: nextScrollX, scrollY: nextScrollY });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
element.addEventListener('wheel', onWheel, { passive: false });
|
|
67
|
+
return () => element.removeEventListener('wheel', onWheel);
|
|
68
|
+
};
|
|
@@ -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
|
+
});
|