@bytebrand/fe-ui-core-autobahn 1.0.127 → 1.0.130

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/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. `isMobile` (both portrait and
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
- // Desktop swipe-to-navigate — the original behaviour, unchanged: a plain touch delta on the
638
- // whole overlay, no pan.
639
- const tx = useRef<number | null>(null);
640
-
641
- // Mobile: one pointer-drag gesture serves two purposes depending on zoom level: at 1x it's a
642
- // left/right swipe to change photo (mouse OR touch); zoomed in, it pans the enlarged photo
643
- // around instead (that's the whole point of zooming in — being able to look at every part of
644
- // it, not just the centered crop). Pointer Events cover mouse + touch + pen in one handler set.
645
- const dragRef = useRef<{ x: number; y: number; panStart: { x: number; y: number } } | null>(null);
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 || zoom <= ZOOM_MIN) return;
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
- <img
840
- src={images[i]}
841
- alt=""
842
- draggable={false}
843
- style={{
844
- position: 'absolute',
845
- inset: 0,
846
- width: '100%',
847
- height: '100%',
848
- objectFit: 'contain',
849
- transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
850
- // pinchRef too, not just dragRef — without it every pinch pointermove still
851
- // kicked off a 200ms eased transition toward a target that moves again before
852
- // it finishes, which reads as the image flickering/jittering bigger-smaller
853
- // instead of tracking the fingers 1:1 (drag already had this right).
854
- transition: (dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
855
- cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
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
- <img
1122
- src={images[i]}
1123
- alt=""
1124
- draggable={false}
1125
- style={{
1126
- position: 'absolute',
1127
- inset: 0,
1128
- width: '100%',
1129
- height: '100%',
1130
- objectFit: 'cover',
1131
- transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
1132
- // pinchRef too, not just dragRef — without it every pinch pointermove still
1133
- // kicked off a 200ms eased transition toward a target that moves again before
1134
- // it finishes, which reads as the image flickering/jittering bigger-smaller
1135
- // instead of tracking the fingers 1:1 (drag already had this right).
1136
- transition: (dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
1137
- cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
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 style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden' }}>
1533
- <img
1534
- src={images[i]}
1535
- alt=""
1536
- style={{
1537
- position: 'absolute',
1538
- inset: 0,
1539
- width: '100%',
1540
- height: '100%',
1541
- objectFit: 'contain',
1542
- transform: `scale(${zoom})`,
1543
- transition: 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
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 }}