@ankhorage/zora 25.5.0 → 25.6.2

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 (106) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +2 -1
  3. package/dist/capabilities/index.d.ts +2 -74
  4. package/dist/capabilities/index.d.ts.map +1 -1
  5. package/dist/capabilities/index.js +6158 -59
  6. package/dist/capabilities/index.js.map +118 -1
  7. package/dist/cli/index.d.ts +2 -74
  8. package/dist/cli/index.d.ts.map +1 -1
  9. package/dist/cli/index.js +2 -1
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/features/grid-view/adapters/inbound/GridView.d.ts +2 -7
  12. package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
  13. package/dist/features/grid-view/adapters/inbound/GridView.js +309 -51
  14. package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
  15. package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts +1 -1
  16. package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts.map +1 -1
  17. package/dist/features/grid-view/adapters/inbound/TileGrid.js +11 -4
  18. package/dist/features/grid-view/adapters/inbound/TileGrid.js.map +1 -1
  19. package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.d.ts +5 -0
  20. package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.d.ts.map +1 -0
  21. package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.js +59 -0
  22. package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.js.map +1 -0
  23. package/dist/features/matrix-grid/application/resolveMatrixGridContentSize.d.ts +7 -0
  24. package/dist/features/matrix-grid/application/resolveMatrixGridContentSize.d.ts.map +1 -0
  25. package/dist/features/matrix-grid/application/resolveMatrixGridContentSize.js +24 -0
  26. package/dist/features/matrix-grid/application/resolveMatrixGridContentSize.js.map +1 -0
  27. package/dist/features/matrix-grid/application/resolveMatrixGridSelection.d.ts +5 -0
  28. package/dist/features/matrix-grid/application/resolveMatrixGridSelection.d.ts.map +1 -0
  29. package/dist/features/matrix-grid/application/resolveMatrixGridSelection.js +8 -0
  30. package/dist/features/matrix-grid/application/resolveMatrixGridSelection.js.map +1 -0
  31. package/dist/features/matrix-grid/meta.d.ts +11 -0
  32. package/dist/features/matrix-grid/meta.d.ts.map +1 -0
  33. package/dist/features/matrix-grid/meta.js +11 -0
  34. package/dist/features/matrix-grid/meta.js.map +1 -0
  35. package/dist/features/matrix-grid/public.d.ts +4 -0
  36. package/dist/features/matrix-grid/public.d.ts.map +1 -0
  37. package/dist/features/matrix-grid/public.js +3 -0
  38. package/dist/features/matrix-grid/public.js.map +1 -0
  39. package/dist/features/registry/ZORA_COMPONENT_REGISTRY.d.ts.map +1 -1
  40. package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js +2 -0
  41. package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js.map +1 -1
  42. package/dist/features/registry/componentMeta.d.ts.map +1 -1
  43. package/dist/features/registry/componentMeta.js +2 -0
  44. package/dist/features/registry/componentMeta.js.map +1 -1
  45. package/dist/features/registry/createEventCapabilities.d.ts +4 -0
  46. package/dist/features/registry/createEventCapabilities.d.ts.map +1 -0
  47. package/dist/features/registry/createEventCapabilities.js +76 -0
  48. package/dist/features/registry/createEventCapabilities.js.map +1 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/types/grid-view.d.ts +18 -1
  54. package/dist/types/grid-view.d.ts.map +1 -1
  55. package/dist/types/grid-view.js.map +1 -1
  56. package/dist/types/matrix-grid.d.ts +23 -0
  57. package/dist/types/matrix-grid.d.ts.map +1 -0
  58. package/dist/types/matrix-grid.js +2 -0
  59. package/dist/types/matrix-grid.js.map +1 -0
  60. package/package.json +1822 -4
  61. package/src/capabilities/index.test.ts +87 -15
  62. package/src/capabilities/index.ts +5 -2
  63. package/src/cli/index.test.ts +5 -3
  64. package/src/cli/index.ts +5 -2
  65. package/src/features/grid-view/adapters/inbound/GridView.interaction.test.tsx +585 -0
  66. package/src/features/grid-view/adapters/inbound/GridView.test.tsx +74 -0
  67. package/src/features/grid-view/adapters/inbound/GridView.tsx +506 -77
  68. package/src/features/grid-view/adapters/inbound/TileGrid.tsx +26 -3
  69. package/src/features/matrix-grid/adapters/inbound/MatrixGrid.test.tsx +232 -0
  70. package/src/features/matrix-grid/adapters/inbound/MatrixGrid.tsx +142 -0
  71. package/src/features/matrix-grid/application/resolveMatrixGridContentSize.test.ts +32 -0
  72. package/src/features/matrix-grid/application/resolveMatrixGridContentSize.ts +28 -0
  73. package/src/features/matrix-grid/application/resolveMatrixGridSelection.test.ts +10 -0
  74. package/src/features/matrix-grid/application/resolveMatrixGridSelection.ts +15 -0
  75. package/src/features/matrix-grid/application/resolveMatrixGridVisibleCells.test.ts +35 -0
  76. package/src/features/matrix-grid/meta.ts +12 -0
  77. package/src/features/matrix-grid/public.ts +3 -0
  78. package/src/features/registry/ZORA_COMPONENT_REGISTRY.ts +2 -0
  79. package/src/features/registry/componentMeta.ts +2 -0
  80. package/src/features/registry/createEventCapabilities.ts +108 -0
  81. package/src/features/time-grid/adapters/inbound/TimeGrid.test.tsx +35 -8
  82. package/src/index.ts +2 -0
  83. package/src/structureOwnership.test.ts +5 -0
  84. package/src/types/grid-view.ts +27 -1
  85. package/src/types/matrix-grid.ts +29 -0
  86. package/web-dist/chunks/{Accordion-d6azgz09.js → Accordion-2r0gqk3x.js} +1 -1
  87. package/web-dist/chunks/Accordion-a6551mh4.js +515 -0
  88. package/web-dist/chunks/{Accordion-0my2z6qe.js → Accordion-bz04casa.js} +132 -3
  89. package/web-dist/chunks/{Accordion-7qfd4cb4.js → Accordion-jh72azm2.js} +1 -1
  90. package/web-dist/components/explorer/Explorer.js +2 -2
  91. package/web-dist/components/file-explorer/FileExplorer.js +2 -2
  92. package/web-dist/components/grid-line-overlay/GridLineOverlay.js +1 -1
  93. package/web-dist/components/grid-ruler/GridRuler.js +1 -1
  94. package/web-dist/components/grid-view/GridView.js +1 -1
  95. package/web-dist/components/matrix-grid/MatrixGrid.d.ts +3 -0
  96. package/web-dist/components/matrix-grid/MatrixGrid.js +153 -0
  97. package/web-dist/components/matrix-grid/index.d.ts +1 -0
  98. package/web-dist/components/matrix-grid/index.js +2 -0
  99. package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
  100. package/web-dist/components/reader/Reader.js +1 -1
  101. package/web-dist/components/spatial-grid/SpatialGrid.js +1 -1
  102. package/web-dist/components/tile-grid/TileGrid.js +1 -1
  103. package/web-dist/components/time-grid/TimeGrid.js +2 -2
  104. package/web-dist/components/uploader/Uploader.js +2 -2
  105. package/web-dist/manifest.json +53 -24
  106. package/web-dist/chunks/Accordion-gxj5qbpr.js +0 -194
@@ -1,26 +1,50 @@
1
1
  import {
2
+ constrainViewport,
2
3
  getVisibleGridItems,
4
+ type GridPoint,
3
5
  type GridViewport,
6
+ type GridViewportConstraints,
7
+ panViewport,
4
8
  revealWorldRect,
5
9
  worldToViewport,
10
+ zoomViewportAt,
6
11
  } from '@ankhorage/grid-view';
7
12
  import React from 'react';
8
- import { ScrollView as NativeScrollView, View as NativeView } from 'react-native';
13
+ import {
14
+ Pressable as NativePressable,
15
+ ScrollView as NativeScrollView,
16
+ Text as NativeText,
17
+ View as NativeView,
18
+ } from 'react-native';
9
19
 
10
20
  import type { GridViewProps } from '../../../../types/grid-view';
11
21
 
12
- /***
13
- * Renders world-positioned items on native and web using the canonical viewport/culling engine.
14
- *
15
- * This first renderer uses nested native scroll regions. Only visible items are mounted;
16
- * logical cells and invisible elements are never rendered.
17
- */
22
+ type ScrollPosition = Readonly<{ x: number; y: number }>;
23
+ type PinchGesture = Readonly<{ distance: number; focalPoint: GridPoint }>;
24
+ type ScrollAxisMapping = Readonly<{ contentSize: number; origin: number; position: number }>;
25
+ type ScrollMapping = Readonly<{
26
+ horizontal: ScrollAxisMapping;
27
+ vertical: ScrollAxisMapping;
28
+ }>;
29
+ type ViewportOrigin = Readonly<{ x: number; y: number }>;
30
+ type FocusRevealProposal = Readonly<{
31
+ focusedItemId: string;
32
+ sourceViewport: GridViewport;
33
+ viewport: GridViewport;
34
+ }>;
35
+ type PinchTouch = Readonly<{ pageX: number; pageY: number }>;
36
+
37
+ /*** Renders a controlled or uncontrolled, virtualized 2D world through the canonical grid viewport engine. */
18
38
  export function GridView({
19
39
  items,
20
40
  contentWidth,
21
41
  contentHeight,
22
42
  width,
23
43
  height,
44
+ viewport: controlledViewport,
45
+ defaultViewport,
46
+ viewportConstraints,
47
+ zoomLimits,
24
48
  zoom = 1,
25
49
  overscanPixels = 160,
26
50
  focusedItemId,
@@ -31,22 +55,30 @@ export function GridView({
31
55
  renderItem,
32
56
  testID,
33
57
  }: GridViewProps) {
34
- const [scrollX, setScrollX] = React.useState(0);
35
- const [scrollY, setScrollY] = React.useState(0);
58
+ const [uncontrolledViewport, setUncontrolledViewport] = React.useState(() =>
59
+ createInitialViewport(width, height, zoom, defaultViewport),
60
+ );
36
61
  const horizontalScrollRef = React.useRef<NativeScrollView>(null);
37
62
  const verticalScrollRef = React.useRef<NativeScrollView>(null);
38
- const scale = Math.max(0.01, zoom);
39
- const viewport = React.useMemo<GridViewport>(
40
- () => ({
41
- width,
42
- height,
43
- offsetX: scrollX / scale,
44
- offsetY: scrollY / scale,
45
- pixelsPerUnitX: scale,
46
- pixelsPerUnitY: scale,
47
- }),
48
- [height, scale, scrollX, scrollY, width],
63
+ const gridRef = React.useRef<React.ComponentRef<typeof NativeView>>(null);
64
+ const scrollPositionRef = React.useRef<ScrollPosition>({ x: 0, y: 0 });
65
+ const pinchGestureRef = React.useRef<PinchGesture | undefined>(undefined);
66
+ const pinchMeasurementRef = React.useRef(0);
67
+ const pinchSessionRef = React.useRef(0);
68
+ const viewportOriginRef = React.useRef<ViewportOrigin>({ x: 0, y: 0 });
69
+ const focusRevealProposalRef = React.useRef<FocusRevealProposal | undefined>(undefined);
70
+ const focusRevealViewportRef = React.useRef<GridViewport | undefined>(undefined);
71
+ const [isPinching, setIsPinching] = React.useState(false);
72
+ const constraints = React.useMemo(
73
+ () => viewportConstraints ?? createContentConstraints(contentWidth, contentHeight),
74
+ [contentHeight, contentWidth, viewportConstraints],
49
75
  );
76
+ const sourceViewport = controlledViewport ?? uncontrolledViewport;
77
+ const viewport = React.useMemo(
78
+ () => constrainViewport({ ...sourceViewport, height, width }, constraints),
79
+ [constraints, height, sourceViewport, width],
80
+ );
81
+ const viewportRef = React.useRef(viewport);
50
82
  const visibleItems = React.useMemo(
51
83
  () => getVisibleGridItems(items, viewport, overscanPixels),
52
84
  [items, overscanPixels, viewport],
@@ -55,7 +87,86 @@ export function GridView({
55
87
  () => getVisibleGridItems(visibleItems, viewport).map((item) => item.id),
56
88
  [viewport, visibleItems],
57
89
  );
90
+ const focusedItem = items.find((item) => item.id === focusedItemId);
91
+ const focusedItemHeight = focusedItem?.height;
92
+ const focusedItemIdForReveal = focusedItem?.id;
93
+ const focusedItemWidth = focusedItem?.width;
94
+ const focusedItemX = focusedItem?.x;
95
+ const focusedItemY = focusedItem?.y;
58
96
  const previousVisibleIdsRef = React.useRef<readonly string[]>([]);
97
+ const uncontrolled = controlledViewport === undefined;
98
+ const interactive = interactionPolicy !== 'passive';
99
+
100
+ const publish = React.useCallback(
101
+ (nextViewport: GridViewport) => {
102
+ if (areViewportsEqual(viewportRef.current, nextViewport)) return;
103
+ viewportRef.current = nextViewport;
104
+ if (uncontrolled) setUncontrolledViewport(nextViewport);
105
+ onViewportChange?.(nextViewport);
106
+ },
107
+ [onViewportChange, uncontrolled],
108
+ );
109
+ const publishRef = React.useRef(publish);
110
+ const pan = (displacement: GridPoint) =>
111
+ publish(panViewport(viewportRef.current, displacement, constraints));
112
+ const zoomAt = (focalPoint: GridPoint, factor: number) =>
113
+ publish(
114
+ zoomViewportAt(
115
+ viewportRef.current,
116
+ focalPoint,
117
+ {
118
+ pixelsPerUnitX: viewportRef.current.pixelsPerUnitX * factor,
119
+ pixelsPerUnitY: viewportRef.current.pixelsPerUnitY * factor,
120
+ },
121
+ zoomLimits,
122
+ constraints,
123
+ ),
124
+ );
125
+
126
+ React.useEffect(() => {
127
+ viewportRef.current = viewport;
128
+ }, [viewport]);
129
+
130
+ React.useEffect(() => {
131
+ publishRef.current = publish;
132
+ }, [publish]);
133
+
134
+ const refreshViewportOrigin = React.useCallback(() => {
135
+ gridRef.current?.measureInWindow((x, y) => {
136
+ viewportOriginRef.current = { x, y };
137
+ });
138
+ }, []);
139
+
140
+ const measurePinchGesture = React.useCallback(
141
+ (
142
+ touches: readonly PinchTouch[],
143
+ session: number,
144
+ onMeasured: (pinch: PinchGesture | undefined) => void,
145
+ ) => {
146
+ const measurement = pinchMeasurementRef.current + 1;
147
+ pinchMeasurementRef.current = measurement;
148
+ gridRef.current?.measureInWindow((x, y) => {
149
+ if (measurement !== pinchMeasurementRef.current || session !== pinchSessionRef.current) {
150
+ return;
151
+ }
152
+ viewportOriginRef.current = { x, y };
153
+ onMeasured(getPinchGesture(touches, width, height, { x, y }));
154
+ });
155
+ },
156
+ [height, width],
157
+ );
158
+
159
+ React.useLayoutEffect(() => {
160
+ refreshViewportOrigin();
161
+ }, [height, refreshViewportOrigin, width]);
162
+
163
+ React.useEffect(
164
+ () => () => {
165
+ pinchMeasurementRef.current += 1;
166
+ pinchSessionRef.current += 1;
167
+ },
168
+ [],
169
+ );
59
170
 
60
171
  React.useEffect(() => {
61
172
  if (!onVisibleItemIdsChange) return;
@@ -71,73 +182,391 @@ export function GridView({
71
182
  }, [onVisibleItemIdsChange, viewportVisibleIds]);
72
183
 
73
184
  React.useEffect(() => {
74
- onViewportChange?.(viewport);
75
- }, [onViewportChange, viewport]);
185
+ const position = createScrollMapping(viewport, constraints);
186
+ const nextPosition = {
187
+ x: position.horizontal.position,
188
+ y: position.vertical.position,
189
+ };
190
+ if (areScrollPositionsEqual(scrollPositionRef.current, nextPosition)) return;
191
+ scrollPositionRef.current = nextPosition;
192
+ const animated =
193
+ focusRevealViewportRef.current !== undefined &&
194
+ areViewportsEqual(viewport, focusRevealViewportRef.current);
195
+ horizontalScrollRef.current?.scrollTo({ animated, x: nextPosition.x });
196
+ verticalScrollRef.current?.scrollTo({ animated, y: nextPosition.y });
197
+ if (animated) focusRevealViewportRef.current = undefined;
198
+ }, [constraints, viewport]);
76
199
 
77
- const viewportRef = React.useRef(viewport);
78
200
  React.useEffect(() => {
79
- viewportRef.current = viewport;
80
- }, [viewport]);
81
-
82
- React.useEffect(() => {
83
- const currentViewport = viewportRef.current;
84
- const focusedItem = items.find((item) => item.id === focusedItemId);
85
- if (!focusedItem) return;
86
- const revealed = revealWorldRect(currentViewport, focusedItem, revealPaddingPixels);
87
- if (revealed.offsetX !== currentViewport.offsetX) {
88
- horizontalScrollRef.current?.scrollTo({ x: revealed.offsetX * scale, animated: true });
201
+ if (
202
+ focusedItemHeight === undefined ||
203
+ focusedItemIdForReveal === undefined ||
204
+ focusedItemWidth === undefined ||
205
+ focusedItemX === undefined ||
206
+ focusedItemY === undefined
207
+ ) {
208
+ focusRevealProposalRef.current = undefined;
209
+ return;
89
210
  }
90
- if (revealed.offsetY !== currentViewport.offsetY) {
91
- verticalScrollRef.current?.scrollTo({ y: revealed.offsetY * scale, animated: true });
211
+ const sourceViewport = viewportRef.current;
212
+ const revealed = revealWorldRect(
213
+ sourceViewport,
214
+ {
215
+ height: focusedItemHeight,
216
+ width: focusedItemWidth,
217
+ x: focusedItemX,
218
+ y: focusedItemY,
219
+ },
220
+ revealPaddingPixels,
221
+ constraints,
222
+ );
223
+ if (
224
+ isRepeatedControlledFocusRevealProposal({
225
+ controlled: !uncontrolled,
226
+ focusedItemId: focusedItemIdForReveal,
227
+ previous: focusRevealProposalRef.current,
228
+ sourceViewport,
229
+ viewport: revealed,
230
+ })
231
+ ) {
232
+ return;
92
233
  }
93
- }, [focusedItemId, height, items, revealPaddingPixels, scale, width]);
234
+ focusRevealProposalRef.current = {
235
+ focusedItemId: focusedItemIdForReveal,
236
+ sourceViewport,
237
+ viewport: revealed,
238
+ };
239
+ focusRevealViewportRef.current = areViewportsEqual(sourceViewport, revealed)
240
+ ? undefined
241
+ : revealed;
242
+ publishRef.current(revealed);
243
+ }, [
244
+ constraints,
245
+ focusedItemHeight,
246
+ focusedItemIdForReveal,
247
+ focusedItemWidth,
248
+ focusedItemX,
249
+ focusedItemY,
250
+ revealPaddingPixels,
251
+ uncontrolled,
252
+ ]);
253
+
254
+ const scrollMapping = createScrollMapping(viewport, constraints);
255
+
256
+ const syncScrollPosition = () => {
257
+ const nextMapping = createScrollMapping(viewportRef.current, constraints);
258
+ const nextPosition = {
259
+ x: nextMapping.horizontal.position,
260
+ y: nextMapping.vertical.position,
261
+ };
262
+ scrollPositionRef.current = nextPosition;
263
+ horizontalScrollRef.current?.scrollTo({ animated: false, x: nextPosition.x });
264
+ verticalScrollRef.current?.scrollTo({ animated: false, y: nextPosition.y });
265
+ };
94
266
 
95
267
  return (
96
- <NativeScrollView
97
- ref={horizontalScrollRef}
98
- horizontal
99
- scrollEnabled={interactionPolicy !== 'passive'}
100
- scrollEventThrottle={32}
101
- showsHorizontalScrollIndicator
102
- style={{ width, height }}
268
+ <NativeView
269
+ ref={gridRef}
270
+ accessibilityLabel="Interactive grid viewport"
271
+ onLayout={refreshViewportOrigin}
272
+ onTouchEnd={() => {
273
+ const wasPinching = pinchGestureRef.current !== undefined;
274
+ pinchMeasurementRef.current += 1;
275
+ pinchSessionRef.current += 1;
276
+ pinchGestureRef.current = undefined;
277
+ setIsPinching(false);
278
+ if (wasPinching) syncScrollPosition();
279
+ }}
280
+ onTouchCancel={() => {
281
+ const wasPinching = pinchGestureRef.current !== undefined;
282
+ pinchMeasurementRef.current += 1;
283
+ pinchSessionRef.current += 1;
284
+ pinchGestureRef.current = undefined;
285
+ setIsPinching(false);
286
+ if (wasPinching) syncScrollPosition();
287
+ }}
288
+ onTouchMove={(event) => {
289
+ if (!interactive) return;
290
+ measurePinchGesture([...event.nativeEvent.touches], pinchSessionRef.current, (pinch) => {
291
+ const previous = pinchGestureRef.current;
292
+ pinchGestureRef.current = pinch;
293
+ setIsPinching(pinch !== undefined);
294
+ if (!pinch || !previous) return;
295
+ zoomAt(pinch.focalPoint, pinch.distance / previous.distance);
296
+ });
297
+ }}
298
+ onTouchStart={(event) => {
299
+ const session = pinchSessionRef.current + 1;
300
+ pinchSessionRef.current = session;
301
+ measurePinchGesture([...event.nativeEvent.touches], session, (pinch) => {
302
+ pinchGestureRef.current = pinch;
303
+ setIsPinching(pinch !== undefined);
304
+ });
305
+ }}
306
+ style={{ height, overflow: 'hidden', width }}
103
307
  testID={testID}
104
- onScroll={(event) => setScrollX(event.nativeEvent.contentOffset.x)}
105
308
  >
106
309
  <NativeScrollView
107
- ref={verticalScrollRef}
108
- scrollEnabled={interactionPolicy !== 'passive'}
109
- scrollEventThrottle={32}
110
- showsVerticalScrollIndicator
111
- style={{ width: contentWidth * scale, height }}
112
- onScroll={(event) => setScrollY(event.nativeEvent.contentOffset.y)}
310
+ ref={horizontalScrollRef}
311
+ testID={testID ? `${testID}-horizontal-scroll` : undefined}
312
+ horizontal
313
+ scrollEnabled={interactive && !isPinching}
314
+ scrollEventThrottle={16}
315
+ showsHorizontalScrollIndicator
316
+ style={{ height, width }}
317
+ onScroll={(event) => {
318
+ if (pinchGestureRef.current) return;
319
+ const { x } = event.nativeEvent.contentOffset;
320
+ const displacement = x - scrollPositionRef.current.x;
321
+ scrollPositionRef.current = { ...scrollPositionRef.current, x };
322
+ if (displacement !== 0) pan({ x: -displacement, y: 0 });
323
+ }}
113
324
  >
114
- <NativeView style={{ width: contentWidth * scale, height: contentHeight * scale }}>
115
- {visibleItems.map((item) => {
116
- const position = worldToViewport(
117
- { x: item.x, y: item.y },
118
- {
119
- ...viewport,
120
- offsetX: 0,
121
- offsetY: 0,
122
- },
123
- );
124
- return (
125
- <NativeView
126
- key={item.id}
127
- style={{
128
- position: 'absolute',
129
- left: position.x,
130
- top: position.y,
131
- width: item.width * scale,
132
- height: item.height * scale,
133
- }}
134
- >
135
- {renderItem(item)}
136
- </NativeView>
137
- );
138
- })}
139
- </NativeView>
325
+ <NativeScrollView
326
+ ref={verticalScrollRef}
327
+ testID={testID ? `${testID}-vertical-scroll` : undefined}
328
+ scrollEnabled={interactive && !isPinching}
329
+ scrollEventThrottle={16}
330
+ showsVerticalScrollIndicator
331
+ style={{ height, width: scrollMapping.horizontal.contentSize }}
332
+ onScroll={(event) => {
333
+ if (pinchGestureRef.current) return;
334
+ const { y } = event.nativeEvent.contentOffset;
335
+ const displacement = y - scrollPositionRef.current.y;
336
+ scrollPositionRef.current = { ...scrollPositionRef.current, y };
337
+ if (displacement !== 0) pan({ x: 0, y: -displacement });
338
+ }}
339
+ >
340
+ <NativeView
341
+ style={{
342
+ height: scrollMapping.vertical.contentSize,
343
+ width: scrollMapping.horizontal.contentSize,
344
+ }}
345
+ >
346
+ {visibleItems.map((item) => {
347
+ const position = worldToViewport(
348
+ { x: item.x, y: item.y },
349
+ { ...viewport, offsetX: 0, offsetY: 0 },
350
+ );
351
+ return (
352
+ <NativeView
353
+ key={item.id}
354
+ style={{
355
+ height: item.height * viewport.pixelsPerUnitY,
356
+ left: position.x + scrollMapping.horizontal.origin,
357
+ position: 'absolute',
358
+ top: position.y + scrollMapping.vertical.origin,
359
+ width: item.width * viewport.pixelsPerUnitX,
360
+ }}
361
+ >
362
+ {renderItem(item)}
363
+ </NativeView>
364
+ );
365
+ })}
366
+ </NativeView>
367
+ </NativeScrollView>
140
368
  </NativeScrollView>
141
- </NativeScrollView>
369
+ {interactive ? (
370
+ <NativeView accessibilityLabel="Viewport controls" style={controlsStyle}>
371
+ <NativePressable
372
+ accessibilityLabel="Pan left"
373
+ accessibilityRole="button"
374
+ onPress={() => pan({ x: 48, y: 0 })}
375
+ >
376
+ <NativeText>←</NativeText>
377
+ </NativePressable>
378
+ <NativePressable
379
+ accessibilityLabel="Pan right"
380
+ accessibilityRole="button"
381
+ onPress={() => pan({ x: -48, y: 0 })}
382
+ >
383
+ <NativeText>→</NativeText>
384
+ </NativePressable>
385
+ <NativePressable
386
+ accessibilityLabel="Pan up"
387
+ accessibilityRole="button"
388
+ onPress={() => pan({ x: 0, y: 48 })}
389
+ >
390
+ <NativeText>↑</NativeText>
391
+ </NativePressable>
392
+ <NativePressable
393
+ accessibilityLabel="Pan down"
394
+ accessibilityRole="button"
395
+ onPress={() => pan({ x: 0, y: -48 })}
396
+ >
397
+ <NativeText>↓</NativeText>
398
+ </NativePressable>
399
+ <NativePressable
400
+ accessibilityLabel="Zoom in"
401
+ accessibilityRole="button"
402
+ onPress={() => zoomAt({ x: width / 2, y: height / 2 }, 1.2)}
403
+ >
404
+ <NativeText>+</NativeText>
405
+ </NativePressable>
406
+ <NativePressable
407
+ accessibilityLabel="Zoom out"
408
+ accessibilityRole="button"
409
+ onPress={() => zoomAt({ x: width / 2, y: height / 2 }, 1 / 1.2)}
410
+ >
411
+ <NativeText>−</NativeText>
412
+ </NativePressable>
413
+ </NativeView>
414
+ ) : null}
415
+ </NativeView>
416
+ );
417
+ }
418
+
419
+ const controlsStyle = { bottom: 8, gap: 4, position: 'absolute' as const, right: 8 };
420
+
421
+ /*** Builds the initial world position and independent scales for uncontrolled rendering. */
422
+ function createInitialViewport(
423
+ width: number,
424
+ height: number,
425
+ zoom: number,
426
+ viewport: GridViewProps['defaultViewport'],
427
+ ): GridViewport {
428
+ return {
429
+ height,
430
+ offsetX: viewport?.offsetX ?? 0,
431
+ offsetY: viewport?.offsetY ?? 0,
432
+ pixelsPerUnitX: viewport?.pixelsPerUnitX ?? zoom,
433
+ pixelsPerUnitY: viewport?.pixelsPerUnitY ?? zoom,
434
+ width,
435
+ };
436
+ }
437
+
438
+ /*** Uses the rendered content extent as the default finite world boundary. */
439
+ function createContentConstraints(
440
+ contentWidth: number,
441
+ contentHeight: number,
442
+ ): GridViewportConstraints {
443
+ return { world: { height: contentHeight, width: contentWidth, x: 0, y: 0 } };
444
+ }
445
+
446
+ /*** Maps engine-constrained world offsets to nonnegative native scroll coordinates. */
447
+ function createScrollMapping(
448
+ viewport: GridViewport,
449
+ constraints: GridViewportConstraints,
450
+ ): ScrollMapping {
451
+ const horizontalMinimum = constrainViewport(
452
+ { ...viewport, offsetX: -Number.MAX_SAFE_INTEGER },
453
+ constraints,
454
+ ).offsetX;
455
+ const horizontalMaximum = constrainViewport(
456
+ { ...viewport, offsetX: Number.MAX_SAFE_INTEGER },
457
+ constraints,
458
+ ).offsetX;
459
+ const verticalMinimum = constrainViewport(
460
+ { ...viewport, offsetY: -Number.MAX_SAFE_INTEGER },
461
+ constraints,
462
+ ).offsetY;
463
+ const verticalMaximum = constrainViewport(
464
+ { ...viewport, offsetY: Number.MAX_SAFE_INTEGER },
465
+ constraints,
466
+ ).offsetY;
467
+ return {
468
+ horizontal: createScrollAxisMapping({
469
+ maximum: horizontalMaximum,
470
+ minimum: horizontalMinimum,
471
+ offset: viewport.offsetX,
472
+ scale: viewport.pixelsPerUnitX,
473
+ viewportSize: viewport.width,
474
+ }),
475
+ vertical: createScrollAxisMapping({
476
+ maximum: verticalMaximum,
477
+ minimum: verticalMinimum,
478
+ offset: viewport.offsetY,
479
+ scale: viewport.pixelsPerUnitY,
480
+ viewportSize: viewport.height,
481
+ }),
482
+ };
483
+ }
484
+
485
+ type ScrollAxisMappingInput = Readonly<{
486
+ maximum: number;
487
+ minimum: number;
488
+ offset: number;
489
+ scale: number;
490
+ viewportSize: number;
491
+ }>;
492
+
493
+ /*** Derives a physical scroll axis from the published engine constraint result. */
494
+ function createScrollAxisMapping({
495
+ maximum,
496
+ minimum,
497
+ offset,
498
+ scale,
499
+ viewportSize,
500
+ }: ScrollAxisMappingInput): ScrollAxisMapping {
501
+ const range = (maximum - minimum) * scale;
502
+ return {
503
+ contentSize: viewportSize + range,
504
+ origin: -minimum * scale,
505
+ position: (offset - minimum) * scale,
506
+ };
507
+ }
508
+
509
+ /*** Compares canonical viewport fields without treating equal proposals as state changes. */
510
+ function areViewportsEqual(left: GridViewport, right: GridViewport): boolean {
511
+ return (
512
+ left.width === right.width &&
513
+ left.height === right.height &&
514
+ left.offsetX === right.offsetX &&
515
+ left.offsetY === right.offsetY &&
516
+ left.pixelsPerUnitX === right.pixelsPerUnitX &&
517
+ left.pixelsPerUnitY === right.pixelsPerUnitY
142
518
  );
143
519
  }
520
+
521
+ /*** Avoids requesting native scrolling again when the physical position is already current. */
522
+ function areScrollPositionsEqual(left: ScrollPosition, right: ScrollPosition): boolean {
523
+ return left.x === right.x && left.y === right.y;
524
+ }
525
+
526
+ /*** Skips a rejected controlled focus proposal until its focus or incoming viewport changes. */
527
+ function isRepeatedControlledFocusRevealProposal({
528
+ controlled,
529
+ focusedItemId,
530
+ previous,
531
+ sourceViewport,
532
+ viewport,
533
+ }: Readonly<{
534
+ controlled: boolean;
535
+ focusedItemId: string | undefined;
536
+ previous: FocusRevealProposal | undefined;
537
+ sourceViewport: GridViewport;
538
+ viewport: GridViewport;
539
+ }>): boolean {
540
+ return (
541
+ controlled &&
542
+ focusedItemId !== undefined &&
543
+ previous?.focusedItemId === focusedItemId &&
544
+ areViewportsEqual(previous.sourceViewport, sourceViewport) &&
545
+ areViewportsEqual(previous.viewport, viewport)
546
+ );
547
+ }
548
+
549
+ /*** Extracts a stable two-finger focal point in the measured outer viewport coordinate space. */
550
+ function getPinchGesture(
551
+ touches: readonly Readonly<{
552
+ pageX: number;
553
+ pageY: number;
554
+ }>[],
555
+ width: number,
556
+ height: number,
557
+ viewportOrigin: ViewportOrigin,
558
+ ): PinchGesture | undefined {
559
+ const [first, second] = touches;
560
+ if (!first || !second) return undefined;
561
+ const deltaX = second.pageX - first.pageX;
562
+ const deltaY = second.pageY - first.pageY;
563
+ const distance = Math.hypot(deltaX, deltaY);
564
+ if (!Number.isFinite(distance) || distance === 0) return undefined;
565
+ return {
566
+ distance,
567
+ focalPoint: {
568
+ x: Math.min(width, Math.max(0, (first.pageX + second.pageX) / 2 - viewportOrigin.x)),
569
+ y: Math.min(height, Math.max(0, (first.pageY + second.pageY) / 2 - viewportOrigin.y)),
570
+ },
571
+ };
572
+ }