@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/package.json +1 -1
- package/theme/md3.css +22 -7
- package/ui/CardCarousel.tsx +284 -22
- package/ui/Dropdown.tsx +467 -461
- package/vdp/VdpViewer.tsx +351 -78
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 }}
|