@ankhorage/zora 25.5.0 → 25.6.0
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/CHANGELOG.md +6 -0
- package/README.md +2 -1
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts +2 -7
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/GridView.js +309 -51
- package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.js +11 -4
- package/dist/features/grid-view/adapters/inbound/TileGrid.js.map +1 -1
- package/dist/types/grid-view.d.ts +18 -1
- package/dist/types/grid-view.d.ts.map +1 -1
- package/dist/types/grid-view.js.map +1 -1
- package/package.json +1 -1
- package/src/features/grid-view/adapters/inbound/GridView.interaction.test.tsx +585 -0
- package/src/features/grid-view/adapters/inbound/GridView.test.tsx +74 -0
- package/src/features/grid-view/adapters/inbound/GridView.tsx +506 -77
- package/src/features/grid-view/adapters/inbound/TileGrid.tsx +26 -3
- package/src/features/time-grid/adapters/inbound/TimeGrid.test.tsx +35 -8
- package/src/types/grid-view.ts +27 -1
- package/web-dist/chunks/{Accordion-0my2z6qe.js → Accordion-ebghcyzj.js} +41 -1
- package/web-dist/chunks/{Accordion-d6azgz09.js → Accordion-f43jba23.js} +1 -1
- package/web-dist/chunks/Accordion-q8exgc3h.js +515 -0
- package/web-dist/chunks/{Accordion-7qfd4cb4.js → Accordion-szar8dt5.js} +1 -1
- package/web-dist/components/explorer/Explorer.js +2 -2
- package/web-dist/components/file-explorer/FileExplorer.js +2 -2
- package/web-dist/components/grid-line-overlay/GridLineOverlay.js +1 -1
- package/web-dist/components/grid-ruler/GridRuler.js +1 -1
- package/web-dist/components/grid-view/GridView.js +1 -1
- package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
- package/web-dist/components/spatial-grid/SpatialGrid.js +1 -1
- package/web-dist/components/tile-grid/TileGrid.js +1 -1
- package/web-dist/components/time-grid/TimeGrid.js +2 -2
- package/web-dist/components/uploader/Uploader.js +2 -2
- package/web-dist/manifest.json +24 -24
- 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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 [
|
|
35
|
-
|
|
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
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
97
|
-
ref={
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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={
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
<
|
|
115
|
-
{
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
+
}
|