@bytebrand/fe-ui-core-autobahn 1.0.127 → 1.0.129
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/package.json +1 -1
- package/ui/CardCarousel.tsx +284 -22
- package/vdp/VdpViewer.tsx +351 -78
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebrand/fe-ui-core-autobahn",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.129",
|
|
4
4
|
"description": "auto.de md3 (Autobahn) design system — UI primitives, cards, chrome, VDP modules, theme CSS and locales. Ships raw TypeScript source (like fe-ui-core before it); each consumer's bundler compiles it.",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"license": "UNLICENSED",
|
package/ui/CardCarousel.tsx
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// Spring constants for the track's slide animation — critically damped (damping = 2·√stiffness,
|
|
4
|
+
// so it approaches the target with no overshoot/bounce, matching what sampling mobile.de's own
|
|
5
|
+
// VDP gallery showed — see the spring loop's doc comment below) and tuned so a single ±1 move
|
|
6
|
+
// settles in ~800-850ms, matching mobile.de's measured settle time for the same move.
|
|
7
|
+
const SPRING_STIFFNESS = 64;
|
|
8
|
+
const SPRING_DAMPING = 16;
|
|
9
|
+
// Below these thresholds the spring is considered "arrived": stop stepping it (saves CPU while
|
|
10
|
+
// idle instead of running a rAF loop forever) and snap the last fractional pixel so the resting
|
|
11
|
+
// position is exact, not just "close enough". Both are small enough that the final snap is
|
|
12
|
+
// sub-perceptible (0.015 of a slide's width is single-digit pixels at any real card size, at a
|
|
13
|
+
// point where residual velocity is already crawling) — chosen by MEASURING actual settle time
|
|
14
|
+
// live and tuning until it landed at mobile.de's own measured ~800-850ms, rather than picking
|
|
15
|
+
// numbers that merely looked reasonable: 0.001/0.01 both measured over 1s to hit in practice
|
|
16
|
+
// (VELOCITY decays slower than position for a critically damped spring, so it — not position —
|
|
17
|
+
// ends up the binding constraint), well past the ~800ms the motion is already imperceptible.
|
|
18
|
+
const SETTLE_POS_EPS = 0.015;
|
|
19
|
+
const SETTLE_VEL_EPS = 0.09;
|
|
2
20
|
|
|
3
21
|
export interface CardCarouselProps {
|
|
4
22
|
/** ready-to-use image URLs (already resolved by the caller — no asset/window lookup here). */
|
|
@@ -34,7 +52,6 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
|
|
|
34
52
|
fit = 'cover',
|
|
35
53
|
padded = true,
|
|
36
54
|
}) => {
|
|
37
|
-
const [i, setI] = useState(0);
|
|
38
55
|
const n = images.length;
|
|
39
56
|
// MOBX-CQ-2: reset to the first slide whenever the caller hands us a
|
|
40
57
|
// DIFFERENT gallery (this instance reused for another car) — otherwise a
|
|
@@ -44,17 +61,242 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
|
|
|
44
61
|
// every arrow click whose onChange triggered a parent re-render (the
|
|
45
62
|
// counter advanced while the track snapped back to slide 0).
|
|
46
63
|
const gallerySignature = images.join('|');
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
// Loop-wrap (last→first / first→last): the flex row gets one extra CLONE slide at each
|
|
65
|
+
// end — clone of the LAST photo before the first, clone of the FIRST photo after the last
|
|
66
|
+
// — so a swipe/click past either edge keeps animating in the SAME direction into a
|
|
67
|
+
// real-looking next photo (mobile.de's own VDP gallery does the same) instead of jump-
|
|
68
|
+
// cutting or animating backward through every photo in between.
|
|
69
|
+
const extendedImages = n > 1 ? [images[n - 1], ...images, images[0]] : images;
|
|
70
|
+
// `trackIndex` (0..n+1, extended-array units; real slides live at 1..n) is the spring's
|
|
71
|
+
// TARGET and the only piece of animation state React itself renders from — used below to
|
|
72
|
+
// derive `i` (0..n-1, the "real" active slide for dots/onChange/the photo-count pill).
|
|
73
|
+
const [trackIndex, setTrackIndex] = useState(n > 1 ? 1 : 0);
|
|
74
|
+
// Mirrors `trackIndex`, updated SYNCHRONOUSLY inside go()/drag itself — not via React state,
|
|
75
|
+
// which only becomes readable to the NEXT interaction after a render has committed. Reading
|
|
76
|
+
// this (not the closured `trackIndex` state, and not a functional setState updater) is what
|
|
77
|
+
// makes go() correct no matter how soon after the last call it runs — see go()'s doc comment.
|
|
78
|
+
const trackIndexRef = useRef(trackIndex);
|
|
79
|
+
// The ANIMATED position, same 0..n+1 units as trackIndex but a continuous float — a ref, not
|
|
80
|
+
// state: the spring loop below writes it up to 60×/sec straight into the DOM via trackRef,
|
|
81
|
+
// never through React's render cycle (far too slow for smooth 60fps motion, and it would
|
|
82
|
+
// fight with the loop's own direct writes if React re-rendered the same style prop).
|
|
83
|
+
const visualPosRef = useRef(trackIndex);
|
|
84
|
+
const velocityRef = useRef(0);
|
|
85
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
86
|
+
const rafIdRef = useRef<number | null>(null);
|
|
87
|
+
// `n` read fresh inside the spring loop's closure would go stale if the gallery changes while
|
|
88
|
+
// a loop iteration is still in flight (a reused SRP card instance recycled mid-animation) —
|
|
89
|
+
// the gallery-reset effect below already forces a fresh start in that case, but mirroring `n`
|
|
90
|
+
// into a ref too costs nothing and removes the edge case outright.
|
|
91
|
+
const nRef = useRef(n);
|
|
92
|
+
nRef.current = n;
|
|
93
|
+
|
|
94
|
+
// Plain modulo, not clamped into [0, n+1] first: trackIndex is intentionally UNBOUNDED (see
|
|
95
|
+
// go()'s doc comment) — several rapid clicks can leave it well past a single clone-width
|
|
96
|
+
// before the spring loop's own rebase (below) has had a chance to catch up, and modulo maps
|
|
97
|
+
// ANY integer straight to the right real slide regardless, so `i`/the dots/the photo-count
|
|
98
|
+
// pill are correct the instant a click fires, never waiting on the animation to catch up.
|
|
99
|
+
const i = n > 1 ? (((trackIndex - 1) % n) + n) % n : 0;
|
|
100
|
+
|
|
101
|
+
const writeTransform = (pos: number) => {
|
|
102
|
+
const el = trackRef.current;
|
|
103
|
+
if (el) el.style.transform = `translateX(${(-pos * 100).toFixed(4)}%)`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// The spring loop: steps visualPosRef toward trackIndexRef every frame via real spring-damper
|
|
107
|
+
// physics (F = -k·x - c·v), not a fixed-duration easing curve. That's the whole reason this
|
|
108
|
+
// component moved off a CSS transition: a CSS transition RESTARTS from zero velocity on every
|
|
109
|
+
// RETARGET (a new target set before the old transition finishes — arrows don't debounce, so
|
|
110
|
+
// this happens on anything faster than one click per ~800ms), and this curve's fast start
|
|
111
|
+
// meant every retarget produced a visible little "kick" of re-acceleration — read as jitter,
|
|
112
|
+
// a wrong-direction flicker, or an uneven stutter under fast repeat clicking (confirmed live:
|
|
113
|
+
// sampling the track's transform frame-by-frame during a burst of real clicks showed exactly
|
|
114
|
+
// this). A spring's velocity is a real, continuous state variable carried across retargets —
|
|
115
|
+
// clicking again before the previous move settles blends smoothly into the new target instead
|
|
116
|
+
// of restarting. This also matches mobile.de's own gallery: sampling ITS transform earlier
|
|
117
|
+
// showed a front-loaded, no-overshoot, ~800-900ms settle that a fixed-duration bezier curve
|
|
118
|
+
// could only approximate for an ISOLATED move — under retargeting a spring is what actually
|
|
119
|
+
// reproduces it, not just the single-move numbers.
|
|
120
|
+
const stepLoop = (lastT: number) => {
|
|
121
|
+
rafIdRef.current = requestAnimationFrame((now) => {
|
|
122
|
+
const dt = Math.min((now - lastT) / 1000, 1 / 30); // cap dt so a lag spike can't overshoot
|
|
123
|
+
const n2 = nRef.current;
|
|
124
|
+
const accel = SPRING_STIFFNESS * (trackIndexRef.current - visualPosRef.current) - SPRING_DAMPING * velocityRef.current;
|
|
125
|
+
velocityRef.current += accel * dt;
|
|
126
|
+
visualPosRef.current += velocityRef.current * dt;
|
|
127
|
+
|
|
128
|
+
// Loop-wrap rebase: whenever the ANIMATED position itself crosses a clone boundary,
|
|
129
|
+
// invisibly shift BOTH trackIndex and visualPos by ∓n into the matching REAL position —
|
|
130
|
+
// same photo either side, so each shift is imperceptible — keeping visualPos inside the
|
|
131
|
+
// single-clone-wide extended array this exact frame. Triggered by visualPos crossing 0 /
|
|
132
|
+
// n+1, NOT by trackIndex reaching them: trackIndex (the spring's TARGET) is intentionally
|
|
133
|
+
// UNBOUNDED — go() below no longer clamps it — so several clicks landing faster than the
|
|
134
|
+
// spring can visually catch up leave it arbitrarily far past a single clone-width. An
|
|
135
|
+
// earlier version rebased the moment the TARGET reached a boundary, shifting visualPos by
|
|
136
|
+
// the same ∓n regardless of where it actually was — correct ONLY if visualPos had already
|
|
137
|
+
// caught up to that boundary too; under a rapid-click burst it usually hadn't (confirmed
|
|
138
|
+
// live: sampling mid-burst caught visualPos at position ~2.9 of 4 getting yanked to ~-0.1
|
|
139
|
+
// by a rebase meant for position 4), producing exactly the kind of jarring cross-carousel
|
|
140
|
+
// jump this whole rewrite exists to eliminate. Rebasing on visualPos's own crossing avoids
|
|
141
|
+
// that by construction: it only ever fires within one frame's worth of motion of the
|
|
142
|
+
// boundary, however far past it trackIndex itself has drifted. A `while`, not `if`, so an
|
|
143
|
+
// extreme burst (or a large dt from a lag spike) rebases as many times in one frame as
|
|
144
|
+
// visualPos has actually crossed — real motion the array can only render one clone-width
|
|
145
|
+
// of at a time, not something to skip over in a single jump.
|
|
146
|
+
while (n2 > 1 && visualPosRef.current >= n2 + 1) {
|
|
147
|
+
trackIndexRef.current -= n2;
|
|
148
|
+
visualPosRef.current -= n2;
|
|
149
|
+
setTrackIndex(trackIndexRef.current);
|
|
150
|
+
}
|
|
151
|
+
while (n2 > 1 && visualPosRef.current <= 0) {
|
|
152
|
+
trackIndexRef.current += n2;
|
|
153
|
+
visualPosRef.current += n2;
|
|
154
|
+
setTrackIndex(trackIndexRef.current);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
writeTransform(visualPosRef.current);
|
|
158
|
+
|
|
159
|
+
const atRest = Math.abs(trackIndexRef.current - visualPosRef.current) < SETTLE_POS_EPS && Math.abs(velocityRef.current) < SETTLE_VEL_EPS;
|
|
160
|
+
if (atRest) {
|
|
161
|
+
visualPosRef.current = trackIndexRef.current;
|
|
162
|
+
velocityRef.current = 0;
|
|
163
|
+
writeTransform(visualPosRef.current);
|
|
164
|
+
rafIdRef.current = null;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
stepLoop(now);
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
const ensureLoopRunning = () => {
|
|
171
|
+
if (rafIdRef.current == null) stepLoop(performance.now());
|
|
172
|
+
};
|
|
173
|
+
useEffect(
|
|
174
|
+
() => () => {
|
|
175
|
+
if (rafIdRef.current != null) cancelAnimationFrame(rafIdRef.current);
|
|
176
|
+
},
|
|
177
|
+
[],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Gallery-swap reset — instant, no spring: jump straight to slide 0 with zero velocity so a
|
|
181
|
+
// reused instance (SRP list) never slides through a stale gallery's photos. useLayoutEffect
|
|
182
|
+
// (not useEffect) so the DOM is corrected before paint — no one-frame flash of the old
|
|
183
|
+
// position with the new gallery's images already swapped in underneath it.
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
if (rafIdRef.current != null) {
|
|
186
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
187
|
+
rafIdRef.current = null;
|
|
188
|
+
}
|
|
189
|
+
const start = n > 1 ? 1 : 0;
|
|
190
|
+
trackIndexRef.current = start;
|
|
191
|
+
visualPosRef.current = start;
|
|
192
|
+
velocityRef.current = 0;
|
|
193
|
+
setTrackIndex(start);
|
|
194
|
+
writeTransform(start);
|
|
195
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
49
196
|
}, [gallerySignature]);
|
|
50
|
-
|
|
197
|
+
// Fires onChange whenever the DERIVED active slide actually changes — not on every
|
|
198
|
+
// trackIndex change, so the loop-wrap's snap-back (n+1→1 / 0→n, same photo either side)
|
|
199
|
+
// doesn't re-fire it. Reads onChange through a ref instead of listing it as a dependency:
|
|
200
|
+
// the VDP hands in a fresh inline arrow function every render, and depending on it directly
|
|
201
|
+
// would re-fire this effect (with the SAME `i`) on every unrelated parent re-render instead
|
|
202
|
+
// of only on an actual slide change.
|
|
203
|
+
const onChangeRef = useRef(onChange);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
onChangeRef.current = onChange;
|
|
206
|
+
});
|
|
207
|
+
const isFirstRender = useRef(true);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (isFirstRender.current) {
|
|
210
|
+
isFirstRender.current = false;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
onChangeRef.current && onChangeRef.current(i);
|
|
214
|
+
}, [i]);
|
|
215
|
+
// Every ±1 move goes through here, whether from a click or a released drag past the
|
|
216
|
+
// threshold. Reads/writes trackIndexRef synchronously (see its own doc comment above) so two
|
|
217
|
+
// calls landing back to back — realistic under fast repeat clicking — never race. Deliberately
|
|
218
|
+
// does NOT clamp/rebase trackIndex to stay within the single clone this component renders on
|
|
219
|
+
// either end — see the spring loop's rebase, which handles that (correctly: only once
|
|
220
|
+
// visualPos itself has actually caught up to a boundary, not the instant the discrete target
|
|
221
|
+
// passes one) — so several rapid clicks just keep pushing the target further out, and the
|
|
222
|
+
// spring keeps chasing it, rebasing its own coordinate system as needed along the way.
|
|
223
|
+
const go = (d: number, e?: React.SyntheticEvent) => {
|
|
224
|
+
if (n <= 1) return;
|
|
51
225
|
e && e.stopPropagation();
|
|
52
|
-
|
|
53
|
-
|
|
226
|
+
trackIndexRef.current += d;
|
|
227
|
+
setTrackIndex(trackIndexRef.current);
|
|
228
|
+
ensureLoopRunning();
|
|
229
|
+
};
|
|
230
|
+
// Pointer Events (not separate touch/mouse handlers) so mouse-drag on desktop works the exact
|
|
231
|
+
// same way touch-swipe already did — same pattern as VdpViewer's onStagePointer* handlers.
|
|
232
|
+
// Live-follow: visualPosRef tracks the pointer 1:1 while the gesture is in progress — same
|
|
233
|
+
// feel as mobile.de's / most gallery sliders' drag, instead of only reacting on release. The
|
|
234
|
+
// spring loop is paused for the duration (grabbing mid-flight kills any residual velocity —
|
|
235
|
+
// picking the track up should stop it dead, not have it keep drifting under your finger) and
|
|
236
|
+
// resumes on release, either toward a new target (go(), past the 40px threshold) or back to
|
|
237
|
+
// the unchanged one (springing back into place — also covers a plain click/tap, dx never
|
|
238
|
+
// moves, which still bubbles to the card as before since stopPropagation only happens in go()).
|
|
239
|
+
const dragRef = useRef<{ x: number; startPos: number } | null>(null);
|
|
240
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
241
|
+
// Set for one tick after a drag that crossed the 40px threshold — see onClick below for why.
|
|
242
|
+
const justSwipedRef = useRef(false);
|
|
243
|
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
244
|
+
if (n <= 1 || (e.target as HTMLElement).closest('button')) return;
|
|
245
|
+
dragRef.current = { x: e.clientX, startPos: visualPosRef.current };
|
|
246
|
+
velocityRef.current = 0;
|
|
247
|
+
if (rafIdRef.current != null) {
|
|
248
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
249
|
+
rafIdRef.current = null;
|
|
250
|
+
}
|
|
251
|
+
setIsDragging(true);
|
|
252
|
+
try {
|
|
253
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
254
|
+
} catch {
|
|
255
|
+
// same "nice to have" as VdpViewer's stage capture — safe to ignore if rejected.
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
259
|
+
const start = dragRef.current;
|
|
260
|
+
if (!start) return;
|
|
261
|
+
const width = e.currentTarget.clientWidth || 1;
|
|
262
|
+
visualPosRef.current = start.startPos - (e.clientX - start.x) / width;
|
|
263
|
+
writeTransform(visualPosRef.current);
|
|
264
|
+
};
|
|
265
|
+
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
266
|
+
const start = dragRef.current;
|
|
267
|
+
dragRef.current = null;
|
|
268
|
+
setIsDragging(false);
|
|
269
|
+
if (!start) return;
|
|
270
|
+
const dx = e.clientX - start.x;
|
|
271
|
+
if (Math.abs(dx) > 40) {
|
|
272
|
+
justSwipedRef.current = true;
|
|
273
|
+
go(dx > 0 ? -1 : 1, e);
|
|
274
|
+
} else {
|
|
275
|
+
ensureLoopRunning(); // spring back to the unchanged trackIndex
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const onPointerCancel = () => {
|
|
279
|
+
dragRef.current = null;
|
|
280
|
+
setIsDragging(false);
|
|
281
|
+
ensureLoopRunning();
|
|
282
|
+
};
|
|
283
|
+
// A completed drag/swipe (mousedown-move-up or touchstart-move-end, all on this same element)
|
|
284
|
+
// makes the browser fire a `click` right after pointerup REGARDLESS of how far the pointer
|
|
285
|
+
// moved in between — go()'s own e.stopPropagation() (called from onPointerUp, above) only
|
|
286
|
+
// stops THAT pointerup event; it can't reach forward to stop a separate click the browser
|
|
287
|
+
// hasn't dispatched yet. Left unguarded, that click still bubbles to a card this carousel
|
|
288
|
+
// lives inside (CarCard/CarRow wrap it in an onClick-to-navigate `<article>`) — swiping
|
|
289
|
+
// through a "similar cars" card's photos was navigating to that car's own page on release.
|
|
290
|
+
// Catching it here, one bubble-phase step before it would reach the card, and swallowing it
|
|
291
|
+
// ONLY when justSwipedRef says a real swipe (not a plain tap, which should still navigate —
|
|
292
|
+
// matches the click/tap-bubbles-through behavior noted in onPointerUp's own doc comment)
|
|
293
|
+
// just happened fixes it without the card needing to know anything about drag state itself.
|
|
294
|
+
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
295
|
+
if (justSwipedRef.current) {
|
|
296
|
+
justSwipedRef.current = false;
|
|
297
|
+
e.stopPropagation();
|
|
298
|
+
}
|
|
54
299
|
};
|
|
55
|
-
const go = (d: number, e?: React.SyntheticEvent) => jump((i + d + n) % n, e);
|
|
56
|
-
const trackRef = useRef<HTMLDivElement>(null);
|
|
57
|
-
const tx = useRef<number | null>(null);
|
|
58
300
|
|
|
59
301
|
// 'auto' mode only diverges from plain 'contain' at desktop widths — below 601px it resolves
|
|
60
302
|
// to 'contain' unconditionally, so mobile's existing rendering is untouched either way.
|
|
@@ -73,33 +315,53 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
|
|
|
73
315
|
return (
|
|
74
316
|
<div
|
|
75
317
|
className="cardcar"
|
|
76
|
-
style={{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
318
|
+
style={{
|
|
319
|
+
position: 'relative',
|
|
320
|
+
height,
|
|
321
|
+
overflow: 'hidden',
|
|
322
|
+
borderRadius: radius,
|
|
323
|
+
cursor: n > 1 ? (isDragging ? 'grabbing' : 'grab') : undefined,
|
|
324
|
+
// Without this, mobile browsers treat a horizontal touch-drag on this element as an
|
|
325
|
+
// ambiguous gesture and can hand it to their OWN native scroll/pan handling before our
|
|
326
|
+
// onPointerMove ever sees it (a pointercancel fires instead) — swipe-to-flip silently
|
|
327
|
+
// does nothing on a real touch device even though desktop mouse-drag (unaffected by
|
|
328
|
+
// touch-action) works fine. 'pan-y', not 'none': tells the browser only HORIZONTAL
|
|
329
|
+
// panning is ours to handle, so a mostly-vertical drag that starts on the photo still
|
|
330
|
+
// scrolls the page normally instead of getting swallowed. VdpViewer's full-screen stage
|
|
331
|
+
// already sets this (as `none` — no page scroll to preserve there); this was the one
|
|
332
|
+
// carousel surface that never got it.
|
|
333
|
+
touchAction: n > 1 ? 'pan-y' : undefined,
|
|
85
334
|
}}
|
|
335
|
+
onPointerDown={onPointerDown}
|
|
336
|
+
onPointerMove={onPointerMove}
|
|
337
|
+
onPointerUp={onPointerUp}
|
|
338
|
+
onPointerCancel={onPointerCancel}
|
|
339
|
+
onClick={onClick}
|
|
86
340
|
>
|
|
341
|
+
{/* transform is intentionally NOT part of this style object — it's owned entirely by
|
|
342
|
+
writeTransform() (the spring loop + drag handlers above), written straight to the DOM
|
|
343
|
+
node via trackRef. Letting React manage it here too, even with matching values, would
|
|
344
|
+
mean every trackIndex-driven re-render (a click, a rebase) re-applies React's own
|
|
345
|
+
understanding of the style — fighting the loop's per-frame writes for the same
|
|
346
|
+
property. The useLayoutEffect above sets the correct INITIAL value before first paint. */}
|
|
87
347
|
<div
|
|
88
348
|
ref={trackRef}
|
|
89
349
|
style={{
|
|
90
350
|
display: 'flex',
|
|
91
351
|
height: '100%',
|
|
92
|
-
transform: `translateX(-${i * 100}%)`,
|
|
93
|
-
transition: 'transform var(--dur-3) var(--ease-emphasized)',
|
|
94
352
|
}}
|
|
95
353
|
>
|
|
96
|
-
{
|
|
354
|
+
{extendedImages.map((src, k) => (
|
|
97
355
|
<img
|
|
98
356
|
key={k}
|
|
99
357
|
src={src}
|
|
100
358
|
alt=""
|
|
101
359
|
loading="lazy"
|
|
102
360
|
decoding="async"
|
|
361
|
+
// otherwise the browser's own native image-drag (ghost preview + a dragstart
|
|
362
|
+
// that swallows the following pointer events) hijacks the gesture instead of
|
|
363
|
+
// our onPointerMove/onPointerUp swipe logic above.
|
|
364
|
+
draggable={false}
|
|
103
365
|
style={
|
|
104
366
|
autoDesktop
|
|
105
367
|
? {
|
package/vdp/VdpViewer.tsx
CHANGED
|
@@ -51,6 +51,24 @@ import logoUrl from '../assets/autode-logo.svg';
|
|
|
51
51
|
const ZOOM_MIN = 1;
|
|
52
52
|
const ZOOM_MAX = 3;
|
|
53
53
|
const ZOOM_STEP = 0.5;
|
|
54
|
+
// Spring constants for the slide-to-neighbor-photo animation, shared by the desktop AND mobile
|
|
55
|
+
// (portrait/landscape) stages below — same physics, same tuning, just two separate refs/loops
|
|
56
|
+
// per stage (each only ever mounts one at a time, but the constants don't need duplicating).
|
|
57
|
+
// Critically damped (damping = 2·√stiffness, so it approaches with no overshoot/bounce) and
|
|
58
|
+
// tuned so a single ±1 move settles in ~800-850ms — matches mobile.de's own gallery, measured by
|
|
59
|
+
// sampling ITS transform frame-by-frame; see ui/CardCarousel.tsx's spring loop doc comment (the
|
|
60
|
+
// first place this pattern was built) for the full derivation and why a spring instead of a CSS
|
|
61
|
+
// transition at all: a fixed-duration curve restarts from zero velocity on every retarget (a new
|
|
62
|
+
// swipe/click before the previous one settles), which reads as jitter under fast repeat use — a
|
|
63
|
+
// spring's velocity carries continuously across retargets instead.
|
|
64
|
+
const SPRING_STIFFNESS = 64;
|
|
65
|
+
const SPRING_DAMPING = 16;
|
|
66
|
+
// Below these thresholds the spring is considered "arrived": stop stepping it (saves CPU while
|
|
67
|
+
// idle) and snap the last fractional pixel so the resting position is exact, not just "close
|
|
68
|
+
// enough" — see CardCarousel's own copy of these same two constants for the measured reasoning
|
|
69
|
+
// behind the specific numbers (velocity, not position, turned out to be the binding constraint).
|
|
70
|
+
const SETTLE_POS_EPS = 0.015;
|
|
71
|
+
const SETTLE_VEL_EPS = 0.09;
|
|
54
72
|
const THUMB_W = 64;
|
|
55
73
|
const THUMB_H = 50;
|
|
56
74
|
const THUMB_GAP = 8;
|
|
@@ -540,9 +558,161 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
540
558
|
const [zoom, setZoom] = useState(ZOOM_MIN);
|
|
541
559
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
542
560
|
const n = images.length;
|
|
543
|
-
const go = (d: number) => setI((p) => (p + d + n) % n);
|
|
544
561
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
545
562
|
|
|
563
|
+
// Desktop layout's photo-to-photo animation — a live-drag + spring slide between the current
|
|
564
|
+
// photo and its neighbor, matching CardCarousel's (ui/CardCarousel.tsx) carousels elsewhere in
|
|
565
|
+
// the app. Before this, a desktop swipe/arrow-click just swapped `images[i]` outright (only the
|
|
566
|
+
// zoom `scale()` had a transition) — no slide motion at all, and mouse-drag didn't even exist
|
|
567
|
+
// (the old mechanism was touch-only, on the whole overlay, not this stage). Mobile (portrait/
|
|
568
|
+
// landscape) gets the identical treatment further below (`mobileTrack*`), folded into its
|
|
569
|
+
// EXISTING single pointer-drag gesture instead of a separate handler set — see that block's
|
|
570
|
+
// own doc comment for why. Kept as two parallel sets of refs/handlers (`desktopTrack*` /
|
|
571
|
+
// `mobileTrack*`), not one shared implementation: only one of the two stages is ever actually
|
|
572
|
+
// mounted at a time (layout is either 'desktop' or portrait/landscape), and desktop's simpler
|
|
573
|
+
// gesture (always a swipe, zoom never pans — see clampPan's own doc comment) doesn't need
|
|
574
|
+
// threading through mobile's zoom-gated pan/pinch/swipe branch, or vice versa.
|
|
575
|
+
//
|
|
576
|
+
// Structural difference from CardCarousel: that one renders ALL n images in one extended
|
|
577
|
+
// [clone-last, ...images, clone-first] track and walks a single persistent index through it.
|
|
578
|
+
// Here there's already a single `i` (0..n-1) as the source of truth (arrows/keyboard/thumbnails/
|
|
579
|
+
// the "all photos" sheet all set it), so instead the track always renders exactly 3 images —
|
|
580
|
+
// [prev, current, next], RECOMPUTED every time `i` changes — with the spring's target FIXED at
|
|
581
|
+
// 1 (the center slot, i.e. "showing `images[i]`") rather than walking to a new target each time.
|
|
582
|
+
// A ±1 step (arrow/drag-release, both go through go() below) doesn't move the target — it moves
|
|
583
|
+
// the WINDOW: `i` advances and visualPos is compensated by the same ∓1 so the two together
|
|
584
|
+
// still describe the exact same on-screen position the instant `i` changes (old-current becomes
|
|
585
|
+
// new-prev or new-next, so a `visualPos` that was e.g. 30% of the way toward old-next reads as
|
|
586
|
+
// exactly 30% of the way FROM new-prev toward new-current) — the spring then keeps animating,
|
|
587
|
+
// uninterrupted, toward the same fixed target 1 it always had.
|
|
588
|
+
const desktopTrackRef = useRef<HTMLDivElement | null>(null);
|
|
589
|
+
const desktopVisualPosRef = useRef(1);
|
|
590
|
+
const desktopVelocityRef = useRef(0);
|
|
591
|
+
const desktopRafIdRef = useRef<number | null>(null);
|
|
592
|
+
const writeDesktopTransform = (pos: number) => {
|
|
593
|
+
const el = desktopTrackRef.current;
|
|
594
|
+
if (el) el.style.transform = `translateX(${(-pos * 100).toFixed(4)}%)`;
|
|
595
|
+
};
|
|
596
|
+
const stepDesktopLoop = (lastT: number) => {
|
|
597
|
+
desktopRafIdRef.current = requestAnimationFrame((now) => {
|
|
598
|
+
const dt = Math.min((now - lastT) / 1000, 1 / 30); // cap dt so a lag spike can't overshoot
|
|
599
|
+
const accel = SPRING_STIFFNESS * (1 - desktopVisualPosRef.current) - SPRING_DAMPING * desktopVelocityRef.current;
|
|
600
|
+
desktopVelocityRef.current += accel * dt;
|
|
601
|
+
desktopVisualPosRef.current += desktopVelocityRef.current * dt;
|
|
602
|
+
writeDesktopTransform(desktopVisualPosRef.current);
|
|
603
|
+
const atRest = Math.abs(1 - desktopVisualPosRef.current) < SETTLE_POS_EPS && Math.abs(desktopVelocityRef.current) < SETTLE_VEL_EPS;
|
|
604
|
+
if (atRest) {
|
|
605
|
+
desktopVisualPosRef.current = 1;
|
|
606
|
+
desktopVelocityRef.current = 0;
|
|
607
|
+
writeDesktopTransform(1);
|
|
608
|
+
desktopRafIdRef.current = null;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
stepDesktopLoop(now);
|
|
612
|
+
});
|
|
613
|
+
};
|
|
614
|
+
const ensureDesktopLoopRunning = () => {
|
|
615
|
+
if (desktopRafIdRef.current == null) stepDesktopLoop(performance.now());
|
|
616
|
+
};
|
|
617
|
+
useEffect(
|
|
618
|
+
() => () => {
|
|
619
|
+
if (desktopRafIdRef.current != null) cancelAnimationFrame(desktopRafIdRef.current);
|
|
620
|
+
},
|
|
621
|
+
[],
|
|
622
|
+
);
|
|
623
|
+
// Safety net: Chrome fully SUSPENDS requestAnimationFrame while the tab is hidden and does NOT
|
|
624
|
+
// auto-resume it on its own once the tab is visible again (confirmed empirically — a spring
|
|
625
|
+
// mid-flight when visibility is lost is left frozen exactly where it was, forever, with no
|
|
626
|
+
// further frames ever requested). A swipe released right before the tab backgrounds — e.g.
|
|
627
|
+
// switching apps mid-gesture, an everyday mobile habit — would otherwise strand the photo
|
|
628
|
+
// between two slides indefinitely. Nudges the loop back on if it's stuck mid-animation
|
|
629
|
+
// (rafIdRef null, i.e. no frame currently scheduled, AND not yet at the target) the moment the
|
|
630
|
+
// tab becomes visible again.
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
const onVisibilityChange = () => {
|
|
633
|
+
if (document.visibilityState !== 'visible') return;
|
|
634
|
+
if (desktopRafIdRef.current == null && Math.abs(1 - desktopVisualPosRef.current) > SETTLE_POS_EPS) {
|
|
635
|
+
ensureDesktopLoopRunning();
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
639
|
+
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
640
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
641
|
+
}, []);
|
|
642
|
+
// Every ±1 step — arrow click OR a released drag past the threshold (desktopOnPointerUp
|
|
643
|
+
// below) — goes through here; keyboard nav already calls this too (goRef.current(±1) further
|
|
644
|
+
// down), so it gets the same animation for free. Thumbnail clicks and the "all photos" sheet
|
|
645
|
+
// deliberately DON'T (they call setI(k) directly) — an arbitrary jump has no single "slide
|
|
646
|
+
// direction" to animate, so the effect below just snaps the track for those instead.
|
|
647
|
+
const changedViaGoRef = useRef(false);
|
|
648
|
+
const go = (d: number) => {
|
|
649
|
+
changedViaGoRef.current = true;
|
|
650
|
+
desktopVisualPosRef.current -= d;
|
|
651
|
+
mobileVisualPosRef.current -= d;
|
|
652
|
+
setI((p) => (p + d + n) % n);
|
|
653
|
+
if (desktopTrackRef.current) ensureDesktopLoopRunning();
|
|
654
|
+
if (mobileTrackRef.current) ensureMobileLoopRunning();
|
|
655
|
+
};
|
|
656
|
+
useEffect(() => {
|
|
657
|
+
if (changedViaGoRef.current) {
|
|
658
|
+
changedViaGoRef.current = false;
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (desktopRafIdRef.current != null) {
|
|
662
|
+
cancelAnimationFrame(desktopRafIdRef.current);
|
|
663
|
+
desktopRafIdRef.current = null;
|
|
664
|
+
}
|
|
665
|
+
desktopVisualPosRef.current = 1;
|
|
666
|
+
desktopVelocityRef.current = 0;
|
|
667
|
+
writeDesktopTransform(1);
|
|
668
|
+
if (mobileRafIdRef.current != null) {
|
|
669
|
+
cancelAnimationFrame(mobileRafIdRef.current);
|
|
670
|
+
mobileRafIdRef.current = null;
|
|
671
|
+
}
|
|
672
|
+
mobileVisualPosRef.current = 1;
|
|
673
|
+
mobileVelocityRef.current = 0;
|
|
674
|
+
writeMobileTransform(1);
|
|
675
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
676
|
+
}, [i]);
|
|
677
|
+
const desktopDragRef = useRef<{ x: number; startPos: number } | null>(null);
|
|
678
|
+
const [isDesktopDragging, setIsDesktopDragging] = useState(false);
|
|
679
|
+
const desktopOnPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
680
|
+
if (n <= 1 || (e.target as HTMLElement).closest('button')) return;
|
|
681
|
+
desktopDragRef.current = { x: e.clientX, startPos: desktopVisualPosRef.current };
|
|
682
|
+
desktopVelocityRef.current = 0;
|
|
683
|
+
if (desktopRafIdRef.current != null) {
|
|
684
|
+
cancelAnimationFrame(desktopRafIdRef.current);
|
|
685
|
+
desktopRafIdRef.current = null;
|
|
686
|
+
}
|
|
687
|
+
setIsDesktopDragging(true);
|
|
688
|
+
try {
|
|
689
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
690
|
+
} catch {
|
|
691
|
+
// same "nice to have" as the mobile stage's own capture below — safe to ignore if rejected.
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const desktopOnPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
695
|
+
const start = desktopDragRef.current;
|
|
696
|
+
if (!start) return;
|
|
697
|
+
const width = e.currentTarget.clientWidth || 1;
|
|
698
|
+
desktopVisualPosRef.current = start.startPos - (e.clientX - start.x) / width;
|
|
699
|
+
writeDesktopTransform(desktopVisualPosRef.current);
|
|
700
|
+
};
|
|
701
|
+
const desktopOnPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
702
|
+
const start = desktopDragRef.current;
|
|
703
|
+
desktopDragRef.current = null;
|
|
704
|
+
setIsDesktopDragging(false);
|
|
705
|
+
if (!start) return;
|
|
706
|
+
const dx = e.clientX - start.x;
|
|
707
|
+
if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
|
|
708
|
+
else ensureDesktopLoopRunning(); // spring back to the unchanged photo
|
|
709
|
+
};
|
|
710
|
+
const desktopOnPointerCancel = () => {
|
|
711
|
+
desktopDragRef.current = null;
|
|
712
|
+
setIsDesktopDragging(false);
|
|
713
|
+
ensureDesktopLoopRunning();
|
|
714
|
+
};
|
|
715
|
+
|
|
546
716
|
// "Alle Fotos" bottom sheet (mobile only — see PhotoSheet above). Opened from the "+N" thumbnail
|
|
547
717
|
// tile in portrait/landscape; picking a photo in it just reuses `i`/`setI`, so the counter and
|
|
548
718
|
// main image update for free. Read via a ref inside the keydown handler below (registered once
|
|
@@ -560,9 +730,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
560
730
|
// open. Both 'resize' (fires on rotation in every current mobile browser) and 'orientationchange'
|
|
561
731
|
// are listened for: some browsers deliver 'orientationchange' a tick before window.innerWidth
|
|
562
732
|
// has actually updated to the new orientation's value, so that handler re-reads it on the next
|
|
563
|
-
// frame rather than trusting the value at event-fire time.
|
|
564
|
-
// landscape) is kept as a derived boolean for the one spot that still needs it: the desktop-only
|
|
565
|
-
// whole-overlay touch-swipe guard below.
|
|
733
|
+
// frame rather than trusting the value at event-fire time.
|
|
566
734
|
const [layout, setLayout] = useState<Layout>(() => (typeof window !== 'undefined' ? layoutForWidth(window.innerWidth) : 'portrait'));
|
|
567
735
|
useEffect(() => {
|
|
568
736
|
const onResize = (): void => setLayout(layoutForWidth(window.innerWidth));
|
|
@@ -576,7 +744,6 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
576
744
|
window.removeEventListener('orientationchange', onOrientationChange);
|
|
577
745
|
};
|
|
578
746
|
}, []);
|
|
579
|
-
const isMobile = layout !== 'desktop';
|
|
580
747
|
|
|
581
748
|
// Reset zoom (and pan) on every slide change — a lingering zoom/pan from the previous photo
|
|
582
749
|
// reads as a stuck/broken control on the new one.
|
|
@@ -634,15 +801,63 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
634
801
|
const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));
|
|
635
802
|
const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));
|
|
636
803
|
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
const
|
|
804
|
+
// Mobile (portrait/landscape) photo-to-photo animation — same live-drag + spring slide as the
|
|
805
|
+
// desktop stage above (see its own doc comment for the full "why a spring" reasoning), just
|
|
806
|
+
// sharing this stage's SINGLE pointer-drag gesture with pan-when-zoomed and pinch instead of
|
|
807
|
+
// having its own separate handlers: at zoom<=1 a drag is a swipe (this loop), zoomed in it
|
|
808
|
+
// pans the enlarged photo around instead (the existing setPan branch, untouched) — the two
|
|
809
|
+
// are mutually exclusive per-gesture (checked at move/up time), so there's nothing to
|
|
810
|
+
// reconcile between them beyond that branch.
|
|
811
|
+
const mobileTrackRef = useRef<HTMLDivElement | null>(null);
|
|
812
|
+
const mobileVisualPosRef = useRef(1);
|
|
813
|
+
const mobileVelocityRef = useRef(0);
|
|
814
|
+
const mobileRafIdRef = useRef<number | null>(null);
|
|
815
|
+
const writeMobileTransform = (pos: number) => {
|
|
816
|
+
const el = mobileTrackRef.current;
|
|
817
|
+
if (el) el.style.transform = `translateX(${(-pos * 100).toFixed(4)}%)`;
|
|
818
|
+
};
|
|
819
|
+
const stepMobileLoop = (lastT: number) => {
|
|
820
|
+
mobileRafIdRef.current = requestAnimationFrame((now) => {
|
|
821
|
+
const dt = Math.min((now - lastT) / 1000, 1 / 30); // cap dt so a lag spike can't overshoot
|
|
822
|
+
const accel = SPRING_STIFFNESS * (1 - mobileVisualPosRef.current) - SPRING_DAMPING * mobileVelocityRef.current;
|
|
823
|
+
mobileVelocityRef.current += accel * dt;
|
|
824
|
+
mobileVisualPosRef.current += mobileVelocityRef.current * dt;
|
|
825
|
+
writeMobileTransform(mobileVisualPosRef.current);
|
|
826
|
+
const atRest = Math.abs(1 - mobileVisualPosRef.current) < SETTLE_POS_EPS && Math.abs(mobileVelocityRef.current) < SETTLE_VEL_EPS;
|
|
827
|
+
if (atRest) {
|
|
828
|
+
mobileVisualPosRef.current = 1;
|
|
829
|
+
mobileVelocityRef.current = 0;
|
|
830
|
+
writeMobileTransform(1);
|
|
831
|
+
mobileRafIdRef.current = null;
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
stepMobileLoop(now);
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
const ensureMobileLoopRunning = () => {
|
|
838
|
+
if (mobileRafIdRef.current == null) stepMobileLoop(performance.now());
|
|
839
|
+
};
|
|
840
|
+
useEffect(
|
|
841
|
+
() => () => {
|
|
842
|
+
if (mobileRafIdRef.current != null) cancelAnimationFrame(mobileRafIdRef.current);
|
|
843
|
+
},
|
|
844
|
+
[],
|
|
845
|
+
);
|
|
846
|
+
// Same rAF-suspended-while-hidden safety net as the desktop stage's own copy above — see its
|
|
847
|
+
// doc comment for why (a swipe released right before the tab backgrounds, e.g. switching apps
|
|
848
|
+
// mid-gesture, would otherwise strand the photo frozen mid-slide forever).
|
|
849
|
+
useEffect(() => {
|
|
850
|
+
const onVisibilityChange = () => {
|
|
851
|
+
if (document.visibilityState !== 'visible') return;
|
|
852
|
+
if (mobileRafIdRef.current == null && Math.abs(1 - mobileVisualPosRef.current) > SETTLE_POS_EPS) {
|
|
853
|
+
ensureMobileLoopRunning();
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
857
|
+
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
858
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
859
|
+
}, []);
|
|
860
|
+
const dragRef = useRef<{ x: number; y: number; panStart: { x: number; y: number }; startPos: number } | null>(null);
|
|
646
861
|
// Pinch-to-zoom: every active pointer is tracked by id so a genuine 2nd finger landing can be
|
|
647
862
|
// told apart from the single-pointer pan/swipe drag above and switch into pinch mode instead.
|
|
648
863
|
// The ratio between the CURRENT and STARTING distance between the two touch points scales zoom
|
|
@@ -672,7 +887,14 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
672
887
|
return;
|
|
673
888
|
}
|
|
674
889
|
if (pointersRef.current.size === 1) {
|
|
675
|
-
dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan };
|
|
890
|
+
dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan, startPos: mobileVisualPosRef.current };
|
|
891
|
+
if (zoom <= ZOOM_MIN) {
|
|
892
|
+
mobileVelocityRef.current = 0;
|
|
893
|
+
if (mobileRafIdRef.current != null) {
|
|
894
|
+
cancelAnimationFrame(mobileRafIdRef.current);
|
|
895
|
+
mobileRafIdRef.current = null;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
676
898
|
}
|
|
677
899
|
};
|
|
678
900
|
const onStagePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
@@ -703,7 +925,13 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
703
925
|
return;
|
|
704
926
|
}
|
|
705
927
|
const start = dragRef.current;
|
|
706
|
-
if (!start
|
|
928
|
+
if (!start) return;
|
|
929
|
+
if (zoom <= ZOOM_MIN) {
|
|
930
|
+
const width = e.currentTarget.clientWidth || 1;
|
|
931
|
+
mobileVisualPosRef.current = start.startPos - (e.clientX - start.x) / width;
|
|
932
|
+
writeMobileTransform(mobileVisualPosRef.current);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
707
935
|
const dx = e.clientX - start.x;
|
|
708
936
|
const dy = e.clientY - start.y;
|
|
709
937
|
setPan(clampPan({ x: start.panStart.x + dx, y: start.panStart.y + dy }, zoom));
|
|
@@ -720,6 +948,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
720
948
|
if (zoom <= ZOOM_MIN) {
|
|
721
949
|
const dx = e.clientX - start.x;
|
|
722
950
|
if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
|
|
951
|
+
else ensureMobileLoopRunning(); // spring back to the unchanged photo
|
|
723
952
|
}
|
|
724
953
|
};
|
|
725
954
|
|
|
@@ -773,16 +1002,6 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
773
1002
|
display: 'flex',
|
|
774
1003
|
flexDirection: 'column',
|
|
775
1004
|
}}
|
|
776
|
-
onTouchStart={(e) => {
|
|
777
|
-
if (isMobile) return; // mobile pans/swipes via the stage's own pointer handlers instead.
|
|
778
|
-
tx.current = e.touches[0].clientX;
|
|
779
|
-
}}
|
|
780
|
-
onTouchEnd={(e) => {
|
|
781
|
-
if (isMobile || tx.current == null) return;
|
|
782
|
-
const dx = e.changedTouches[0].clientX - tx.current;
|
|
783
|
-
tx.current = null;
|
|
784
|
-
if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
|
|
785
|
-
}}
|
|
786
1005
|
>
|
|
787
1006
|
{layout === 'portrait' ? (
|
|
788
1007
|
<>
|
|
@@ -836,25 +1055,38 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
836
1055
|
onPointerUp={onStagePointerUp}
|
|
837
1056
|
onPointerCancel={onStagePointerUp}
|
|
838
1057
|
>
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1058
|
+
{/* mobileTrackRef's transform is owned entirely by writeMobileTransform() (the
|
|
1059
|
+
spring loop + the swipe branch of onStagePointerMove above) — same reasoning as
|
|
1060
|
+
the desktop track: React re-applying a matching value here on every `i`-driven
|
|
1061
|
+
re-render would fight the loop's own per-frame writes to this same property. */}
|
|
1062
|
+
<div ref={mobileTrackRef} style={{ position: 'absolute', inset: 0, display: 'flex', height: '100%' }}>
|
|
1063
|
+
{[images[(i - 1 + n) % n], images[i], images[(i + 1) % n]].map((src, k) => (
|
|
1064
|
+
<img
|
|
1065
|
+
key={k}
|
|
1066
|
+
src={src}
|
|
1067
|
+
alt=""
|
|
1068
|
+
draggable={false}
|
|
1069
|
+
style={{
|
|
1070
|
+
flex: '0 0 100%',
|
|
1071
|
+
width: '100%',
|
|
1072
|
+
height: '100%',
|
|
1073
|
+
objectFit: 'contain',
|
|
1074
|
+
// Zoom/pan only ever apply to the CENTER slot (k===1, the actually-
|
|
1075
|
+
// displayed photo) — prev/next are only ever visible mid-slide, and both
|
|
1076
|
+
// reset on every `i` change anyway (see the effect above), so by the time
|
|
1077
|
+
// either could become the new center they're already back to 1x/centered.
|
|
1078
|
+
transform: k === 1 ? `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` : undefined,
|
|
1079
|
+
// pinchRef too, not just dragRef — without it every pinch pointermove still
|
|
1080
|
+
// kicked off a 200ms eased transition toward a target that moves again before
|
|
1081
|
+
// it finishes, which reads as the image flickering/jittering bigger-smaller
|
|
1082
|
+
// instead of tracking the fingers 1:1 (drag already had this right).
|
|
1083
|
+
transition:
|
|
1084
|
+
k === 1 ? ((dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)') : undefined,
|
|
1085
|
+
cursor: k === 1 && zoom > ZOOM_MIN ? 'grab' : undefined,
|
|
1086
|
+
}}
|
|
1087
|
+
/>
|
|
1088
|
+
))}
|
|
1089
|
+
</div>
|
|
858
1090
|
|
|
859
1091
|
<div style={{ ...pillStyle, left: 12 }} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
|
|
860
1092
|
<span className="num viewer-counter-num">
|
|
@@ -1118,25 +1350,34 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
1118
1350
|
onPointerUp={onStagePointerUp}
|
|
1119
1351
|
onPointerCancel={onStagePointerUp}
|
|
1120
1352
|
>
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1353
|
+
{/* mobileTrackRef is SHARED with portrait above (same ref, same spring loop, same
|
|
1354
|
+
onStagePointer* handlers) — only one of portrait/landscape is ever mounted at a
|
|
1355
|
+
time, so there's no conflict; see portrait's copy of this comment for why
|
|
1356
|
+
transform isn't set here directly. */}
|
|
1357
|
+
<div ref={mobileTrackRef} style={{ position: 'absolute', inset: 0, display: 'flex', height: '100%' }}>
|
|
1358
|
+
{[images[(i - 1 + n) % n], images[i], images[(i + 1) % n]].map((src, k) => (
|
|
1359
|
+
<img
|
|
1360
|
+
key={k}
|
|
1361
|
+
src={src}
|
|
1362
|
+
alt=""
|
|
1363
|
+
draggable={false}
|
|
1364
|
+
style={{
|
|
1365
|
+
flex: '0 0 100%',
|
|
1366
|
+
width: '100%',
|
|
1367
|
+
height: '100%',
|
|
1368
|
+
objectFit: 'cover',
|
|
1369
|
+
transform: k === 1 ? `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` : undefined,
|
|
1370
|
+
// pinchRef too, not just dragRef — without it every pinch pointermove still
|
|
1371
|
+
// kicked off a 200ms eased transition toward a target that moves again before
|
|
1372
|
+
// it finishes, which reads as the image flickering/jittering bigger-smaller
|
|
1373
|
+
// instead of tracking the fingers 1:1 (drag already had this right).
|
|
1374
|
+
transition:
|
|
1375
|
+
k === 1 ? ((dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)') : undefined,
|
|
1376
|
+
cursor: k === 1 && zoom > ZOOM_MIN ? 'grab' : undefined,
|
|
1377
|
+
}}
|
|
1378
|
+
/>
|
|
1379
|
+
))}
|
|
1380
|
+
</div>
|
|
1140
1381
|
</div>
|
|
1141
1382
|
|
|
1142
1383
|
{/* logo — Figma node 424:9330 "auto-de_RGB (2) 1": the actual asset there is the
|
|
@@ -1529,20 +1770,52 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
1529
1770
|
<IconButton name="x" size={24} className="vdp-viewer-light" label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
|
|
1530
1771
|
</div>
|
|
1531
1772
|
</div>
|
|
1532
|
-
<div
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1773
|
+
<div
|
|
1774
|
+
style={{
|
|
1775
|
+
flex: 1,
|
|
1776
|
+
position: 'relative',
|
|
1777
|
+
minHeight: 0,
|
|
1778
|
+
background: '#fff',
|
|
1779
|
+
overflow: 'hidden',
|
|
1780
|
+
cursor: n > 1 ? (isDesktopDragging ? 'grabbing' : 'grab') : undefined,
|
|
1781
|
+
// Without this, a touch-capable desktop (a tablet reporting >860px, or Chrome's
|
|
1782
|
+
// own device toolbar) hands a horizontal drag to the browser's native scroll/pan
|
|
1783
|
+
// handling before onPointerMove ever sees it — same fix as CardCarousel's
|
|
1784
|
+
// touch-action (see its own doc comment) applied here for the same reason.
|
|
1785
|
+
touchAction: n > 1 ? 'pan-y' : undefined,
|
|
1786
|
+
}}
|
|
1787
|
+
onPointerDown={desktopOnPointerDown}
|
|
1788
|
+
onPointerMove={desktopOnPointerMove}
|
|
1789
|
+
onPointerUp={desktopOnPointerUp}
|
|
1790
|
+
onPointerCancel={desktopOnPointerCancel}
|
|
1791
|
+
>
|
|
1792
|
+
{/* transform is NOT set here — desktopTrackRef's owned entirely by
|
|
1793
|
+
writeDesktopTransform() (the spring loop + drag handlers above), written
|
|
1794
|
+
straight to the DOM node. Same reasoning as CardCarousel's own track: letting
|
|
1795
|
+
React re-apply a matching value here too would fight the loop's per-frame
|
|
1796
|
+
writes for the same property. */}
|
|
1797
|
+
<div ref={desktopTrackRef} style={{ display: 'flex', height: '100%' }}>
|
|
1798
|
+
{[images[(i - 1 + n) % n], images[i], images[(i + 1) % n]].map((src, k) => (
|
|
1799
|
+
<img
|
|
1800
|
+
key={k}
|
|
1801
|
+
src={src}
|
|
1802
|
+
alt=""
|
|
1803
|
+
draggable={false}
|
|
1804
|
+
style={{
|
|
1805
|
+
flex: '0 0 100%',
|
|
1806
|
+
width: '100%',
|
|
1807
|
+
height: '100%',
|
|
1808
|
+
objectFit: 'contain',
|
|
1809
|
+
// Zoom only ever applies to the CENTER slot (the actually-displayed photo,
|
|
1810
|
+
// k===1) — prev/next are only ever visible mid-slide, and zoom always
|
|
1811
|
+
// resets to 1x on every `i` change anyway (see the effect above), so by the
|
|
1812
|
+
// time either of them could become the new center they're back to unzoomed.
|
|
1813
|
+
transform: k === 1 ? `scale(${zoom})` : undefined,
|
|
1814
|
+
transition: k === 1 ? 'transform var(--dur-2, 200ms) var(--ease-standard, ease)' : undefined,
|
|
1815
|
+
}}
|
|
1816
|
+
/>
|
|
1817
|
+
))}
|
|
1818
|
+
</div>
|
|
1546
1819
|
<button
|
|
1547
1820
|
className="car-arrow car-arrow--l hide-mobile"
|
|
1548
1821
|
style={{ opacity: 1 }}
|