feat(media): rework viewer paging into a persistent no-remount slide filmstrip with drag-anywhere-to-close and rounded sheet corners
This commit is contained in:
parent
b210f2b3a2
commit
4dcfe7fd1d
4 changed files with 475 additions and 214 deletions
|
|
@ -71,10 +71,11 @@ export const image = style({
|
||||||
imageRendering: 'auto',
|
imageRendering: 'auto',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Swipe-filmstrip neighbour layer — fills the stage, centres its image
|
// Shared filmstrip slide layer — one per ±1 window slot, fills the stage and
|
||||||
// like the current one, and ignores pointers (the current image owns the
|
// centres its image; the content (img/video) is translated by the parent per
|
||||||
// gesture surface). Translated by the parent so prev/next slide with the
|
// slot so the strip slides on a swipe. Base `pointer-events: none` is the
|
||||||
// drag.
|
// NEIGHBOUR default; the parent overrides it to `auto` on the ACTIVE slide
|
||||||
|
// (which owns the gesture surface — mouse-pan / native video controls).
|
||||||
export const previewLayer = style({
|
export const previewLayer = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@
|
||||||
// • Zoom for images (button row + `+`/`-`/`0` keys).
|
// • Zoom for images (button row + `+`/`-`/`0` keys).
|
||||||
// • Mouse-drag pan when zoomed, with bounds clamping so the
|
// • Mouse-drag pan when zoomed, with bounds clamping so the
|
||||||
// image can't be dragged off the stage.
|
// image can't be dragged off the stage.
|
||||||
// • Multi-media navigation: prev / next via on-screen chevrons,
|
// • Multi-media navigation: prev / next via on-screen chevrons
|
||||||
// keyboard arrows, and horizontal swipe (image only — video
|
// (web only), keyboard arrows, and horizontal swipe. Images glide
|
||||||
// keeps its native seekbar drag).
|
// on swipe; videos swipe too but only from above the native control
|
||||||
|
// band (so the seekbar drag still scrubs) and switch instantly.
|
||||||
// • Programmatic `<video>` play on each entry change.
|
// • Programmatic `<video>` play on each entry change.
|
||||||
// • Download via `FileSaver`.
|
// • Download via `FileSaver`.
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { isNativePlatform } from '../../utils/capacitor';
|
||||||
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
|
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
|
||||||
import {
|
import {
|
||||||
IImageContent,
|
IImageContent,
|
||||||
|
|
@ -53,6 +55,17 @@ const SWIPE_ANIM_MS = 280;
|
||||||
// pictures don't read as glued edge-to-edge while sliding. The panel
|
// pictures don't read as glued edge-to-edge while sliding. The panel
|
||||||
// «pitch» (centre-to-centre travel) is therefore stageWidth + this.
|
// «pitch» (centre-to-centre travel) is therefore stageWidth + this.
|
||||||
const SWIPE_GAP_PX = 24;
|
const SWIPE_GAP_PX = 24;
|
||||||
|
// Height of the bottom strip on a `<video controls>` reserved for the
|
||||||
|
// native control cluster. A horizontal page-swipe / drag-to-close must NOT
|
||||||
|
// start inside it or it would steal the seekbar's own horizontal drag. On
|
||||||
|
// Android Chrome the stacked cluster (timeline + button row) is ~96px and
|
||||||
|
// the seekbar itself is the bottom ~48px; 88px comfortably covers the
|
||||||
|
// seekbar plus most of the button row (a DRAG starting on a button would
|
||||||
|
// otherwise read as a swipe — a TAP still passes through, since nothing is
|
||||||
|
// claimed until the drag threshold). Clamped to 40% of the video's rendered
|
||||||
|
// height so a short clip stays swipeable above its controls. NB: the exact
|
||||||
|
// native band is browser/version-variable — worth an on-device sanity check.
|
||||||
|
const VIDEO_CONTROL_BAND_PX = 88;
|
||||||
|
|
||||||
// Walk the room's loaded timeline events and build the ordered set
|
// Walk the room's loaded timeline events and build the ordered set
|
||||||
// of viewable image + video events. Out-of-window media (older than
|
// of viewable image + video events. Out-of-window media (older than
|
||||||
|
|
@ -101,45 +114,109 @@ function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// A non-interactive neighbour image for the swipe «filmstrip». It resolves
|
// One slide of the viewer's persistent filmstrip. The ±1 window around the
|
||||||
// (and shares, via the parent's `resolveSrc` cache) its own decrypted src so
|
// current entry is always mounted and keyed by eventId, so paging is a track
|
||||||
// the prev / next image is already on-screen as the user drags, then slides
|
// re-translate + index shift: the element that was the neighbour BECOMES the
|
||||||
// to centre on commit. Image-only — videos keep instant navigation. Sits
|
// active slide without a remount — no decode-blank flash. Only the ACTIVE
|
||||||
// BEHIND the interactive current image (`pointer-events: none`).
|
// slide is interactive (zoom/pan via the parent's `activeTransform` + refs +
|
||||||
function SwipePreview({
|
// handlers); neighbours are static, decoded-ahead images one pitch to either
|
||||||
|
// side that slide in on a swipe. A video renders only while active (video
|
||||||
|
// steps are instant — a video never glides into view as a neighbour).
|
||||||
|
function Slide({
|
||||||
entry,
|
entry,
|
||||||
resolveSrc,
|
resolveSrc,
|
||||||
offsetPx,
|
isActive,
|
||||||
|
activeSrc,
|
||||||
|
slideTransform,
|
||||||
|
activeTransform,
|
||||||
transition,
|
transition,
|
||||||
|
panCursor,
|
||||||
|
imgRefCb,
|
||||||
|
videoRefCb,
|
||||||
|
onMouseDown,
|
||||||
|
onImageLoad,
|
||||||
}: {
|
}: {
|
||||||
entry: MediaViewerEntry;
|
entry: MediaViewerEntry;
|
||||||
resolveSrc: (e: MediaViewerEntry) => Promise<string>;
|
resolveSrc: (e: MediaViewerEntry) => Promise<string>;
|
||||||
offsetPx: number;
|
isActive: boolean;
|
||||||
|
activeSrc: string | undefined;
|
||||||
|
slideTransform: string;
|
||||||
|
activeTransform: string;
|
||||||
transition: string;
|
transition: string;
|
||||||
|
panCursor: 'grab' | 'grabbing' | 'initial';
|
||||||
|
imgRefCb: (el: HTMLImageElement | null) => void;
|
||||||
|
videoRefCb: (el: HTMLVideoElement | null) => void;
|
||||||
|
onMouseDown: MouseEventHandler<HTMLImageElement>;
|
||||||
|
onImageLoad: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
// Neighbours resolve their own decrypted src (shared cache) so they're
|
||||||
|
// decoded before they slide in. The active slide instead uses the parent's
|
||||||
|
// resolved `activeSrc` — the SAME cached blob, but routed through the parent
|
||||||
|
// so its loading / error / retry + download chrome stays in sync.
|
||||||
|
const [ownSrc, setOwnSrc] = useState<string | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
resolveSrc(entry)
|
resolveSrc(entry)
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (alive) setSrc(s);
|
if (alive) setOwnSrc(s);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* neighbour preview is best-effort */
|
/* best-effort — the parent's loader surfaces errors for the active slide */
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [entry, resolveSrc]);
|
}, [entry, resolveSrc]);
|
||||||
|
|
||||||
|
const src = isActive ? activeSrc : ownSrc;
|
||||||
|
|
||||||
|
if (entry.kind === 'video') {
|
||||||
|
// Neighbour videos never glide into view, so they render nothing; only the
|
||||||
|
// active video mounts (controls + muted autoplay). Switching to a video is
|
||||||
|
// instant (no slide), so this mount-on-activation reads fine.
|
||||||
|
if (!isActive || !src) return null;
|
||||||
return (
|
return (
|
||||||
<div className={css.previewLayer} aria-hidden="true">
|
<div className={css.previewLayer} style={{ pointerEvents: 'auto' }}>
|
||||||
{src && (
|
<video
|
||||||
<img
|
ref={videoRefCb}
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
title={entry.body}
|
||||||
|
className={css.image}
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
style={{ transform: slideTransform, transition }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css.previewLayer}
|
||||||
|
aria-hidden={!isActive}
|
||||||
|
style={{ pointerEvents: isActive ? 'auto' : 'none' }}
|
||||||
|
>
|
||||||
|
{src && (
|
||||||
|
// `onMouseDown` drives drag-pan when the active image is zoomed in.
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||||
|
<img
|
||||||
|
ref={isActive ? imgRefCb : undefined}
|
||||||
|
src={src}
|
||||||
|
alt={isActive ? entry.body : ''}
|
||||||
className={css.image}
|
className={css.image}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={{ transform: `translate3d(${offsetPx}px, 0, 0)`, transition }}
|
// Lets the parent's reset effect confirm `imgRef` points at the
|
||||||
|
// now-active image before deriving its zoom ceiling (see there).
|
||||||
|
data-event-id={entry.eventId}
|
||||||
|
style={{
|
||||||
|
transform: isActive ? activeTransform : slideTransform,
|
||||||
|
transition,
|
||||||
|
cursor: isActive ? panCursor : undefined,
|
||||||
|
}}
|
||||||
|
onMouseDown={isActive ? onMouseDown : undefined}
|
||||||
|
onLoad={isActive ? onImageLoad : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -155,9 +232,21 @@ type MediaViewerBodyProps = {
|
||||||
// there. The mobile horseshoe + (legacy) side pane leave it false to
|
// there. The mobile horseshoe + (legacy) side pane leave it false to
|
||||||
// keep the full toolbar.
|
// keep the full toolbar.
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
|
// Vertical drag-to-close sink. The mobile sheet passes this so a
|
||||||
|
// downward drag from ANYWHERE on the stage (zoom=1 only — a zoomed
|
||||||
|
// image pans instead) drives the sheet's close. Called live with the
|
||||||
|
// raw downward finger distance (`dragging: true`) and once on release
|
||||||
|
// (`dragging: false`); the sheet owns the rubber-band + commit
|
||||||
|
// threshold. The desktop window leaves it undefined → no close drag.
|
||||||
|
onCloseDrag?: (rawDeltaY: number, dragging: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewerBodyProps) {
|
export function MediaViewerBody({
|
||||||
|
entry,
|
||||||
|
requestClose,
|
||||||
|
hideHeader,
|
||||||
|
onCloseDrag,
|
||||||
|
}: MediaViewerBodyProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
@ -180,8 +269,11 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
// contain), so clamp is zero there and pan auto-resets via the
|
// contain), so clamp is zero there and pan auto-resets via the
|
||||||
// `useEffect` below that watches `zoom`.
|
// `useEffect` below that watches `zoom`.
|
||||||
const stageRef = useRef<HTMLDivElement>(null);
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
// `| null` (→ MutableRefObject) because the active slide assigns these via a
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
// ref callback (`setActiveImgRef` / `setActiveVideoRef`) rather than React's
|
||||||
|
// static `ref=` binding, so we write `.current` ourselves.
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');
|
const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');
|
||||||
|
|
||||||
|
|
@ -194,6 +286,12 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
// slide. Gesture detail is in the big stage-listener effect below.
|
// slide. Gesture detail is in the big stage-listener effect below.
|
||||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||||
const [swipeAnimating, setSwipeAnimating] = useState(false);
|
const [swipeAnimating, setSwipeAnimating] = useState(false);
|
||||||
|
// True for the single render after an INSTANT (non-glide) step so the
|
||||||
|
// persisted incoming slide swaps WITHOUT a transition — otherwise it would
|
||||||
|
// animate in from the neighbour slot (and, while zoomed, inherit the previous
|
||||||
|
// slide's zoom/pan and wobble back to fit). Cleared in the entry-change reset
|
||||||
|
// effect once the incoming slide is at centre.
|
||||||
|
const [instantStep, setInstantStep] = useState(false);
|
||||||
const [stageWidth, setStageWidth] = useState(0);
|
const [stageWidth, setStageWidth] = useState(0);
|
||||||
const commitTimerRef = useRef<number | null>(null);
|
const commitTimerRef = useRef<number | null>(null);
|
||||||
const prefersReducedMotion = useMemo(
|
const prefersReducedMotion = useMemo(
|
||||||
|
|
@ -254,6 +352,18 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed));
|
setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Point the parent's `imgRef` / `videoRef` at whichever slide is currently
|
||||||
|
// active. A ref callback (not a static `ref`) lets the persistent filmstrip
|
||||||
|
// hand the ref to the new element as the active slide changes, and ignoring
|
||||||
|
// the `null` detach keeps the ref pointing at the live active element across
|
||||||
|
// the swap (the attach of the new active element wins regardless of order).
|
||||||
|
const setActiveImgRef = useCallback((el: HTMLImageElement | null) => {
|
||||||
|
if (el) imgRef.current = el;
|
||||||
|
}, []);
|
||||||
|
const setActiveVideoRef = useCallback((el: HTMLVideoElement | null) => {
|
||||||
|
if (el) videoRef.current = el;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clampPan = useCallback((raw: { x: number; y: number }, currentZoom: number) => {
|
const clampPan = useCallback((raw: { x: number; y: number }, currentZoom: number) => {
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
const img = imgRef.current;
|
const img = imgRef.current;
|
||||||
|
|
@ -284,12 +394,34 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
setPan({ x: 0, y: 0 });
|
setPan({ x: 0, y: 0 });
|
||||||
|
// Re-derive the per-image zoom ceiling for the now-active image. In the
|
||||||
|
// persistent filmstrip it was usually already loaded as a neighbour (so its
|
||||||
|
// onLoad won't refire on activation) — read its natural size directly. The
|
||||||
|
// `dataset.eventId` check ensures `imgRef` actually points at the NOW-active
|
||||||
|
// image: the ref callback ignores the null detach, so when the new active
|
||||||
|
// slide's src isn't resolved yet it can still point at the prior image —
|
||||||
|
// then we fall back to the flat ceiling and its onLoad sets the real one.
|
||||||
|
const activeImg = imgRef.current;
|
||||||
|
if (
|
||||||
|
activeImg &&
|
||||||
|
activeImg.dataset.eventId === entry.eventId &&
|
||||||
|
activeImg.naturalWidth > 0 &&
|
||||||
|
activeImg.offsetWidth > 0
|
||||||
|
) {
|
||||||
|
setMaxZoom(
|
||||||
|
Math.min(ZOOM_CEILING_HARD_CAP, Math.max(2, activeImg.naturalWidth / activeImg.offsetWidth))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
setMaxZoom(ZOOM_CEILING_FALLBACK);
|
setMaxZoom(ZOOM_CEILING_FALLBACK);
|
||||||
|
}
|
||||||
if (commitTimerRef.current !== null) {
|
if (commitTimerRef.current !== null) {
|
||||||
window.clearTimeout(commitTimerRef.current);
|
window.clearTimeout(commitTimerRef.current);
|
||||||
commitTimerRef.current = null;
|
commitTimerRef.current = null;
|
||||||
}
|
}
|
||||||
setSwipeAnimating(false);
|
setSwipeAnimating(false);
|
||||||
|
// Re-enable transitions after an instant step (the incoming slide is now at
|
||||||
|
// centre, so flipping the transition back on doesn't retroactively animate).
|
||||||
|
setInstantStep(false);
|
||||||
}, [entry.eventId, setZoom]);
|
}, [entry.eventId, setZoom]);
|
||||||
|
|
||||||
// Track the stage width so the swipe filmstrip can position the
|
// Track the stage width so the swipe filmstrip can position the
|
||||||
|
|
@ -506,14 +638,25 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
entry.kind === 'image' &&
|
entry.kind === 'image' &&
|
||||||
target.kind === 'image';
|
target.kind === 'image';
|
||||||
if (!canSlide) {
|
if (!canSlide) {
|
||||||
|
// Instant switch (zoomed, reduced-motion, video, or unmeasured stage).
|
||||||
|
// The incoming slide is a PERSISTED neighbour, so reset its transform
|
||||||
|
// state synchronously and flag this commit as instant — without that it
|
||||||
|
// would animate in from the neighbour slot, and while zoomed it would
|
||||||
|
// inherit the previous slide's zoom/pan and wobble back to fit.
|
||||||
|
setInstantStep(true);
|
||||||
|
setZoom(1);
|
||||||
|
setPan({ x: 0, y: 0 });
|
||||||
setSwipeOffset(0);
|
setSwipeOffset(0);
|
||||||
openMediaViewer(target);
|
openMediaViewer(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSwipeAnimating(true);
|
setSwipeAnimating(true);
|
||||||
// delta +1 (next) slides content LEFT by one panel pitch (stage +
|
// delta +1 (next) slides the whole filmstrip LEFT by one panel pitch
|
||||||
// gutter) → current glides off-screen while the prefetched next
|
// (stage + gutter): the current slide glides off-screen while the
|
||||||
// panel (sitting one pitch to the right) glides to centre.
|
// already-mounted next slide glides to centre. At the commit below the
|
||||||
|
// window shifts (currentIndex += delta) and swipeOffset resets to 0 —
|
||||||
|
// each surviving slide's `slot*pitch + swipeOffset` is unchanged, so the
|
||||||
|
// next slide simply BECOMES the active one with no remount and no jump.
|
||||||
const pitch = stageW + SWIPE_GAP_PX;
|
const pitch = stageW + SWIPE_GAP_PX;
|
||||||
setSwipeOffset(delta === 1 ? -pitch : pitch);
|
setSwipeOffset(delta === 1 ? -pitch : pitch);
|
||||||
commitTimerRef.current = window.setTimeout(() => {
|
commitTimerRef.current = window.setTimeout(() => {
|
||||||
|
|
@ -523,7 +666,7 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
openMediaViewer(target);
|
openMediaViewer(target);
|
||||||
}, SWIPE_ANIM_MS);
|
}, SWIPE_ANIM_MS);
|
||||||
},
|
},
|
||||||
[currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion]
|
[currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion, setZoom]
|
||||||
);
|
);
|
||||||
|
|
||||||
const canPrev = currentIndex > 0;
|
const canPrev = currentIndex > 0;
|
||||||
|
|
@ -608,7 +751,8 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
startX: 0,
|
startX: 0,
|
||||||
startY: 0,
|
startY: 0,
|
||||||
lastDx: 0,
|
lastDx: 0,
|
||||||
claimed: 'none' as 'none' | 'swipe' | 'reject',
|
lastDy: 0,
|
||||||
|
claimed: 'none' as 'none' | 'swipe' | 'reject' | 'close',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Active touch pointers keyed by `pointerId`. Map (not array)
|
// Active touch pointers keyed by `pointerId`. Map (not array)
|
||||||
|
|
@ -663,6 +807,11 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
mediaSet[currentIndex + 1]?.kind === 'image';
|
mediaSet[currentIndex + 1]?.kind === 'image';
|
||||||
const animatedStepRef = useRef(animatedStep);
|
const animatedStepRef = useRef(animatedStep);
|
||||||
animatedStepRef.current = animatedStep;
|
animatedStepRef.current = animatedStep;
|
||||||
|
// Vertical-down drag-to-close sink (mobile sheet only; desktop window
|
||||||
|
// leaves it undefined). Mirrored for the once-installed gesture
|
||||||
|
// listeners; also gates whether the 'close' claim is even reachable.
|
||||||
|
const onCloseDragRef = useRef(onCloseDrag);
|
||||||
|
onCloseDragRef.current = onCloseDrag;
|
||||||
const entryKindRef = useRef(entry.kind);
|
const entryKindRef = useRef(entry.kind);
|
||||||
entryKindRef.current = entry.kind;
|
entryKindRef.current = entry.kind;
|
||||||
// `zoomRef` + `panRef` are already mirrored in the pan section
|
// `zoomRef` + `panRef` are already mirrored in the pan section
|
||||||
|
|
@ -687,6 +836,9 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
pointerCacheRef.current.clear();
|
pointerCacheRef.current.clear();
|
||||||
pinchStateRef.current = null;
|
pinchStateRef.current = null;
|
||||||
touchPanStateRef.current = null;
|
touchPanStateRef.current = null;
|
||||||
|
// Release the sheet if an entry swap lands mid close-drag — otherwise it
|
||||||
|
// would stay frozen at the dragged height with no release event coming.
|
||||||
|
if (swipeStateRef.current.claimed === 'close') onCloseDragRef.current?.(0, false);
|
||||||
swipeStateRef.current.active = false;
|
swipeStateRef.current.active = false;
|
||||||
swipeStateRef.current.claimed = 'none';
|
swipeStateRef.current.claimed = 'none';
|
||||||
setSwipeOffset(0);
|
setSwipeOffset(0);
|
||||||
|
|
@ -704,7 +856,38 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
// → pan; mouse drag at zoom=1 has no useful gesture. Skip
|
// → pan; mouse drag at zoom=1 has no useful gesture. Skip
|
||||||
// mouse here for everything.
|
// mouse here for everything.
|
||||||
if (e.pointerType === 'mouse') return;
|
if (e.pointerType === 'mouse') return;
|
||||||
if (entryKindRef.current !== 'image') return;
|
|
||||||
|
// Video entries keep their native `<video controls>`. Only a single-
|
||||||
|
// finger horizontal swipe pages between entries (no pinch / zoom / pan
|
||||||
|
// — videos don't zoom), and only when the touch starts clear of the
|
||||||
|
// bottom control band so dragging the seekbar still scrubs. Pointer
|
||||||
|
// capture is deferred to swipe-claim (in `pointermove`) so a plain tap
|
||||||
|
// still reaches the native controls (play/pause, show/hide overlay).
|
||||||
|
if (entryKindRef.current !== 'image') {
|
||||||
|
if (commitTimerRef.current !== null) return;
|
||||||
|
if (pointerCacheRef.current.size > 0) return; // already tracking a finger
|
||||||
|
const v = videoRef.current;
|
||||||
|
// `v.isConnected` guards a stale ref: the ref callback ignores the null
|
||||||
|
// detach, so while a freshly-active video is still loading (no <video>
|
||||||
|
// mounted yet) `videoRef` can point at the previous, now-detached
|
||||||
|
// element — whose getBoundingClientRect() is all-zero and would read as
|
||||||
|
// «touch is in the control band», wrongly blocking the swipe/close.
|
||||||
|
if (v && v.isConnected) {
|
||||||
|
const r = v.getBoundingClientRect();
|
||||||
|
const band = Math.min(VIDEO_CONTROL_BAND_PX, r.height * 0.4);
|
||||||
|
if (e.clientY >= r.bottom - band) return; // leave the seekbar to scrub
|
||||||
|
}
|
||||||
|
pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
const s = swipeStateRef.current;
|
||||||
|
s.active = true;
|
||||||
|
s.startX = e.clientX;
|
||||||
|
s.startY = e.clientY;
|
||||||
|
s.lastDx = 0;
|
||||||
|
s.lastDy = 0;
|
||||||
|
s.claimed = 'none';
|
||||||
|
setSwipeOffset(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Ignore new touches while a commit slide is gliding (the entry is
|
// Ignore new touches while a commit slide is gliding (the entry is
|
||||||
// about to swap). Without this, a fresh pointerdown mid-glide would
|
// about to swap). Without this, a fresh pointerdown mid-glide would
|
||||||
// run `setSwipeOffset(0)` while `swipeAnimating` is still on and
|
// run `setSwipeOffset(0)` while `swipeAnimating` is still on and
|
||||||
|
|
@ -753,7 +936,11 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
anchorImageY: (anchorY - p0.y) / z0,
|
anchorImageY: (anchorY - p0.y) / z0,
|
||||||
};
|
};
|
||||||
if (swipeStateRef.current.active) {
|
if (swipeStateRef.current.active) {
|
||||||
|
// A second finger cancels an in-flight swipe / close: spring the
|
||||||
|
// sheet back if a close drag was running, then drop into pinch.
|
||||||
|
if (swipeStateRef.current.claimed === 'close') onCloseDragRef.current?.(0, false);
|
||||||
swipeStateRef.current.active = false;
|
swipeStateRef.current.active = false;
|
||||||
|
swipeStateRef.current.claimed = 'none';
|
||||||
setSwipeOffset(0);
|
setSwipeOffset(0);
|
||||||
}
|
}
|
||||||
touchPanStateRef.current = null;
|
touchPanStateRef.current = null;
|
||||||
|
|
@ -768,6 +955,7 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
s.startX = e.clientX;
|
s.startX = e.clientX;
|
||||||
s.startY = e.clientY;
|
s.startY = e.clientY;
|
||||||
s.lastDx = 0;
|
s.lastDx = 0;
|
||||||
|
s.lastDy = 0;
|
||||||
s.claimed = 'none';
|
s.claimed = 'none';
|
||||||
setSwipeOffset(0);
|
setSwipeOffset(0);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -823,11 +1011,37 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
const dx = e.clientX - s.startX;
|
const dx = e.clientX - s.startX;
|
||||||
const dy = e.clientY - s.startY;
|
const dy = e.clientY - s.startY;
|
||||||
s.lastDx = dx;
|
s.lastDx = dx;
|
||||||
|
s.lastDy = dy;
|
||||||
if (s.claimed === 'none') {
|
if (s.claimed === 'none') {
|
||||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||||
if (Math.abs(dx) > Math.abs(dy) * SWIPE_DOMINANCE) s.claimed = 'swipe';
|
if (Math.abs(dx) > Math.abs(dy) * SWIPE_DOMINANCE) {
|
||||||
else s.claimed = 'reject';
|
s.claimed = 'swipe';
|
||||||
|
} else if (onCloseDragRef.current && dy > 0 && Math.abs(dy) > Math.abs(dx)) {
|
||||||
|
// Vertical-DOWN drag → drive the sheet's drag-to-close. Only
|
||||||
|
// when a close sink is wired (mobile sheet) and the motion
|
||||||
|
// clearly dominates vertical; an ambiguous diagonal stays
|
||||||
|
// 'reject' so it neither pages nor closes.
|
||||||
|
s.claimed = 'close';
|
||||||
|
} else {
|
||||||
|
s.claimed = 'reject';
|
||||||
}
|
}
|
||||||
|
// Once the gesture is a real swipe/close, capture the pointer so
|
||||||
|
// it tracks even if the finger leaves the stage. Images already
|
||||||
|
// captured on `pointerdown` (re-capture is a no-op); video / a
|
||||||
|
// tap that never resolves stays with the native controls.
|
||||||
|
if (s.claimed === 'swipe' || s.claimed === 'close') {
|
||||||
|
try {
|
||||||
|
stageEl.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore — capture is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.claimed === 'close') {
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
onCloseDragRef.current?.(Math.max(0, dy), true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (s.claimed !== 'swipe') return;
|
if (s.claimed !== 'swipe') return;
|
||||||
if (e.cancelable) e.preventDefault();
|
if (e.cancelable) e.preventDefault();
|
||||||
|
|
@ -883,6 +1097,11 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
const s = swipeStateRef.current;
|
const s = swipeStateRef.current;
|
||||||
if (s.active) {
|
if (s.active) {
|
||||||
s.active = false;
|
s.active = false;
|
||||||
|
if (s.claimed === 'close') {
|
||||||
|
// Hand the release to the sheet — it applies the rubber-band,
|
||||||
|
// checks the commit threshold, and either closes or springs back.
|
||||||
|
onCloseDragRef.current?.(Math.max(0, s.lastDy), false);
|
||||||
|
} else {
|
||||||
let committed = false;
|
let committed = false;
|
||||||
if (s.claimed === 'swipe') {
|
if (s.claimed === 'swipe') {
|
||||||
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) {
|
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) {
|
||||||
|
|
@ -897,6 +1116,7 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
// ±stageWidth); a non-committing release snaps back to centre.
|
// ±stageWidth); a non-committing release snaps back to centre.
|
||||||
if (!committed) setSwipeOffset(0);
|
if (!committed) setSwipeOffset(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
touchPanStateRef.current = null;
|
touchPanStateRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -975,53 +1195,73 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
FileSaver.saveAs(effectiveSrc, entry.body);
|
FileSaver.saveAs(effectiveSrc, entry.body);
|
||||||
}, [effectiveSrc, entry.body]);
|
}, [effectiveSrc, entry.body]);
|
||||||
|
|
||||||
// Video autoplay: the `<video>` element below ships with the
|
// Video autoplay: the active `<video>` ships `muted` + `autoPlay`. Muted
|
||||||
// `muted` + `autoPlay` HTML attributes. Muted autoplay is
|
// autoplay is universally allowed without a gesture (Chrome / Firefox /
|
||||||
// universally allowed (no gesture-activation needed) across
|
// WebKit / Android Capacitor / iOS Safari); unmuted would need a fresh
|
||||||
// Chrome / Firefox / WebKit / Android Capacitor / iOS Safari —
|
// gesture per video and fails on iOS Safari after the first step. The user
|
||||||
// see the MDN Autoplay guide. Unmuted autoplay would require a
|
// unmutes via native controls; that doesn't persist across steps because a
|
||||||
// fresh user-gesture per video, which fails on iOS Safari
|
// neighbour video isn't mounted until it becomes the active slide.
|
||||||
// after the first step in this viewer. Element-Web takes the
|
// Element-Web takes the same muted stance.
|
||||||
// same `muted` stance for the same reason. The user can unmute
|
|
||||||
// via the native `<video>` controls; subsequent videos in the
|
|
||||||
// same viewer session keep their unmuted state inherited
|
|
||||||
// through the element remount (key={entry.eventId} forces a
|
|
||||||
// fresh element, so unmute does NOT persist — accepted UX
|
|
||||||
// trade for "playing always works").
|
|
||||||
|
|
||||||
const transform = isReady
|
// The active image's transform composes the paging offset with zoom + pan;
|
||||||
? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
|
// neighbour slides use the plain slot offset (computed per-slide below).
|
||||||
: undefined;
|
const activeTransform = `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`;
|
||||||
// Transition ON while a commit slide settles (swipeAnimating) and at
|
// Transition ON while a commit slide settles (swipeAnimating) and at rest
|
||||||
// rest (offset 0 — smooth zoom + snap-back); OFF during an active drag
|
// (smooth zoom / snap-back); OFF during an active finger drag (1:1 tracking).
|
||||||
// so the image tracks the finger 1:1.
|
|
||||||
let imageTransition = 'none';
|
let imageTransition = 'none';
|
||||||
if (swipeAnimating) {
|
if (instantStep) {
|
||||||
|
// Instant step: no transition this commit so the persisted incoming slide
|
||||||
|
// appears at centre at once — no slide-in from the neighbour slot, and no
|
||||||
|
// wobble from a stale zoom/pan (also honours prefers-reduced-motion steps).
|
||||||
|
imageTransition = 'none';
|
||||||
|
} else if (swipeAnimating) {
|
||||||
imageTransition = `transform ${SWIPE_ANIM_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`;
|
imageTransition = `transform ${SWIPE_ANIM_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`;
|
||||||
} else if (swipeOffset === 0) {
|
} else if (swipeOffset === 0) {
|
||||||
imageTransition = 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)';
|
imageTransition = 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swipe filmstrip neighbours — only at rest zoom and for images (swipe
|
// Persistent filmstrip — the ±1 window around the current entry, always
|
||||||
// is image-only). They slide WITH the current image (same offset, one
|
// mounted and keyed by eventId. Paging shifts `currentIndex` (via the atom)
|
||||||
// stage-width to either side) so the prev/next picture is visible as
|
// and resets `swipeOffset`; each surviving slide's `slot*pitch + swipeOffset`
|
||||||
// the user drags and glides to centre on commit.
|
// is unchanged across that shift, so nothing remounts or jumps — the old
|
||||||
const prevEntry = currentIndex > 0 ? mediaSet[currentIndex - 1] : undefined;
|
// neighbour just becomes the active slide. Neighbours sit one pitch to either
|
||||||
const nextEntry =
|
// side (off-screen) and slide in on a swipe.
|
||||||
currentIndex >= 0 && currentIndex < mediaSet.length - 1
|
const pitch = stageWidth + SWIPE_GAP_PX;
|
||||||
? mediaSet[currentIndex + 1]
|
const slides: Array<{ entry: MediaViewerEntry; slot: number }> = [];
|
||||||
: undefined;
|
if (currentIndex >= 0) {
|
||||||
const showSwipePreviews = entry.kind === 'image' && zoom === 1 && stageWidth > 0;
|
// Neighbours need the measured `pitch` to sit off-screen; until the stage
|
||||||
|
// is measured (first frame) render only the active slide so they don't
|
||||||
|
// briefly flash near centre with a stage-less `pitch`.
|
||||||
|
const neighboursReady = stageWidth > 0;
|
||||||
|
for (let slot = -1; slot <= 1; slot += 1) {
|
||||||
|
if (slot === 0 || neighboursReady) {
|
||||||
|
const slideEntry = mediaSet[currentIndex + slot];
|
||||||
|
if (slideEntry) slides.push({ entry: slideEntry, slot });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Entry isn't in the loaded timeline window (out-of-scrollback media): show
|
||||||
|
// it alone as the active slide — there are no neighbours to page to, and
|
||||||
|
// `canPrev`/`canNext` already gate stepping off `currentIndex >= 0`.
|
||||||
|
slides.push({ entry, slot: 0 });
|
||||||
|
}
|
||||||
|
// On native the prev/next chevrons are redundant — a horizontal swipe pages
|
||||||
|
// between entries (photos glide; videos switch instantly above their control
|
||||||
|
// band). Web keeps the chevrons on every entry: swipe is touch-only, so a
|
||||||
|
// desktop mouse still needs the buttons (web-mobile touch gets both).
|
||||||
|
const showChevrons = mediaSet.length > 1 && !isNativePlatform();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
// `PageHeader` (= folds `Header size="600"`) so the header's
|
// `PageHeader` (= folds `Header size="600"`) for a consistent
|
||||||
// bottom rule aligns to the pixel with the chat header to the
|
// strip height. `outlined={false}` drops the default bottom rule:
|
||||||
// left across the 12px void gap — same trick the profile
|
// the only consumer that renders this header is the mobile sheet
|
||||||
// side pane uses. A plain `<div height: 48px>` was visibly
|
// (the desktop window passes `hideHeader`), and there it's a
|
||||||
// shorter and broke the cross-pane seam.
|
// free-floating viewer over the image — a divider under the title
|
||||||
|
// just reads as a stray line, not a cross-pane seam.
|
||||||
|
outlined={false}
|
||||||
className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
|
className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
|
@ -1089,68 +1329,23 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={css.stage} ref={stageRef}>
|
<div className={css.stage} ref={stageRef}>
|
||||||
{showSwipePreviews && prevEntry?.kind === 'image' && (
|
{slides.map(({ entry: slideEntry, slot }) => (
|
||||||
<SwipePreview
|
<Slide
|
||||||
key={prevEntry.eventId}
|
key={slideEntry.eventId}
|
||||||
entry={prevEntry}
|
entry={slideEntry}
|
||||||
resolveSrc={resolveMediaSrc}
|
resolveSrc={resolveMediaSrc}
|
||||||
offsetPx={swipeOffset - stageWidth - SWIPE_GAP_PX}
|
isActive={slot === 0}
|
||||||
|
activeSrc={effectiveSrc}
|
||||||
|
slideTransform={`translate3d(${slot * pitch + swipeOffset}px, 0, 0)`}
|
||||||
|
activeTransform={activeTransform}
|
||||||
transition={imageTransition}
|
transition={imageTransition}
|
||||||
/>
|
panCursor={panCursor}
|
||||||
)}
|
imgRefCb={setActiveImgRef}
|
||||||
{showSwipePreviews && nextEntry?.kind === 'image' && (
|
videoRefCb={setActiveVideoRef}
|
||||||
<SwipePreview
|
|
||||||
key={nextEntry.eventId}
|
|
||||||
entry={nextEntry}
|
|
||||||
resolveSrc={resolveMediaSrc}
|
|
||||||
offsetPx={swipeOffset + stageWidth + SWIPE_GAP_PX}
|
|
||||||
transition={imageTransition}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isReady && effectiveSrc && entry.kind === 'image' && (
|
|
||||||
// `onMouseDown` drives drag-pan when the image is zoomed in.
|
|
||||||
// Keyboard pan is wired separately at the stage level. Keyed by
|
|
||||||
// eventId (like the video) so a commit mounts a fresh element
|
|
||||||
// centred at offset 0 — no slide-back from the committed edge.
|
|
||||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
||||||
<img
|
|
||||||
ref={imgRef}
|
|
||||||
key={entry.eventId}
|
|
||||||
src={effectiveSrc}
|
|
||||||
alt={entry.body}
|
|
||||||
className={css.image}
|
|
||||||
draggable={false}
|
|
||||||
style={{
|
|
||||||
transform,
|
|
||||||
cursor: panCursor,
|
|
||||||
transition: imageTransition,
|
|
||||||
}}
|
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onLoad={onImageLoad}
|
onImageLoad={onImageLoad}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
{isReady && effectiveSrc && entry.kind === 'video' && (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
key={entry.eventId}
|
|
||||||
src={effectiveSrc}
|
|
||||||
title={entry.body}
|
|
||||||
className={css.image}
|
|
||||||
controls
|
|
||||||
// Muted + autoPlay: see the rationale comment above
|
|
||||||
// — muted autoplay is the only policy-compliant way
|
|
||||||
// to start every video automatically including on iOS
|
|
||||||
// Safari after a swipe / chevron step. User unmutes
|
|
||||||
// via native controls.
|
|
||||||
muted
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
style={{
|
|
||||||
transform: `translate3d(${swipeOffset}px, 0, 0)`,
|
|
||||||
transition: imageTransition,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className={css.stateOverlay}>
|
<div className={css.stateOverlay}>
|
||||||
|
|
@ -1172,7 +1367,7 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mediaSet.length > 1 && (
|
{showChevrons && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,18 @@ import { color, toRem } from 'folds';
|
||||||
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||||
|
|
||||||
// Outer container — anchor for the two absolutely-positioned panes
|
// Outer container — anchor for the two absolutely-positioned panes
|
||||||
// (`appBody` and `silhouette`). Same shape as the settings-sheet
|
// (`appBody` and `silhouette`). `overflow: hidden` clips the full-bleed
|
||||||
// container, see that file for the full rationale.
|
// children at the SCREEN edges; the rounded top corners are carved by the
|
||||||
|
// silhouette's own `overflow: hidden` + border-radius (its top edge sits
|
||||||
|
// flush under the status bar, nowhere near the container's edges).
|
||||||
|
//
|
||||||
|
// Deliberately NO background color. The silhouette's carved corners must
|
||||||
|
// stay TRANSPARENT so they reveal the live chat behind (`appBody`), exactly
|
||||||
|
// like the chat-list curtain / profile horseshoe render «only the rounded
|
||||||
|
// shape» over real content. Painting the container would put a flat square
|
||||||
|
// behind the curve — a rounded silhouette sitting on a coloured block,
|
||||||
|
// which is the «rounding without rounding» look we're avoiding (whether the
|
||||||
|
// block is black or light-blue).
|
||||||
export const container = style({
|
export const container = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -15,11 +25,21 @@ export const container = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Holds the wrapped chat column. Stays put — clip-path carves the
|
// Holds the wrapped chat column, full-bleed behind the silhouette. NOT
|
||||||
// bottom edge so virtualized timeline rows aren't re-measured
|
// clipped: the live chat must paint behind the silhouette's rounded top
|
||||||
// mid-gesture. `backgroundColor` opaque so the void colour painted
|
// corners so the carved corners reveal real content (room header /
|
||||||
// on the container doesn't bleed through any transparent slivers
|
// timeline) — that's what makes the rounding read, with no synthetic
|
||||||
// in the wrapped tree (e.g. between bubble rows).
|
// backing square. The opaque silhouette covers everything except the two
|
||||||
|
// corner triangles + the status-bar strip above its top edge.
|
||||||
|
//
|
||||||
|
// `isolation: isolate` is load-bearing now that the chat is full-bleed
|
||||||
|
// (it used to be clip-path-masked away below the sheet). It makes appBody
|
||||||
|
// its OWN stacking context so the chat's positively z-indexed overlays —
|
||||||
|
// the composer / input form, scroll-to-bottom pill, typing strip — stay
|
||||||
|
// confined below the silhouette sibling. Without it they out-stack the
|
||||||
|
// (z-index: auto) sheet and render on top of the open viewer.
|
||||||
|
// `backgroundColor` opaque so nothing behind the wrapper bleeds through any
|
||||||
|
// transparent slivers in the wrapped tree (e.g. between bubble rows).
|
||||||
export const appBody = style({
|
export const appBody = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -31,16 +51,18 @@ export const appBody = style({
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
backgroundColor: color.Background.Container,
|
backgroundColor: color.Background.Container,
|
||||||
willChange: 'clip-path',
|
isolation: 'isolate',
|
||||||
});
|
});
|
||||||
|
|
||||||
// The viewer sheet's surface. `Background.Container` (deepest Dawn
|
// The viewer sheet's surface. `Background.Container` (deepest Dawn tone)
|
||||||
// tone) so the image floats on a near-black backdrop — closest to
|
// so the image floats on a near-black backdrop — closest to the legacy
|
||||||
// the legacy `<Overlay>` viewer's full-screen dark scrim. Rounded
|
// `<Overlay>` viewer's full-screen dark scrim. Rounded top corners (same
|
||||||
// top corners (same radius as the tab curtains) with `overflow:
|
// radius as the tab curtains) with `overflow: hidden` clip the header /
|
||||||
// hidden` clip the header / image to the curve; the carved corners
|
// image to the curve. `overflow: hidden` also means the two corner
|
||||||
// expose the app's dark background behind, so the rounding reads
|
// triangles paint NOTHING — they stay transparent and reveal the live chat
|
||||||
// without re-introducing a black void gap.
|
// behind (`appBody` is full-bleed, not clipped), so the rounding reads
|
||||||
|
// against real content the way the chat-list curtain / profile horseshoe
|
||||||
|
// do, with no backing square behind the curve.
|
||||||
export const silhouette = style({
|
export const silhouette = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|
@ -71,10 +93,11 @@ export const panelContent = style({
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 20px drag-to-close band. The ONLY drag-to-close origin — touches
|
// 20px drag-to-close band — ONE of two close origins. This dedicated strip
|
||||||
// on the viewer body below this strip drive zoom / pan / swipe and
|
// drives close via the handle gesture below; the viewer body is the other,
|
||||||
// MUST NOT initiate a close gesture. `touchAction: none` blocks
|
// driving close via MediaViewerBody's `onCloseDrag` (a vertical-down drag at
|
||||||
// browser-native scroll on this strip.
|
// zoom=1 anywhere on the stage). Touches on the body otherwise drive zoom /
|
||||||
|
// pan / swipe. `touchAction: none` blocks browser-native scroll on this strip.
|
||||||
export const panelHandle = style({
|
export const panelHandle = style({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
height: toRem(20),
|
height: toRem(20),
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,62 @@
|
||||||
// Bottom-up sheet that wraps the mobile chat column for the media
|
// Bottom-up sheet that wraps the mobile chat column for the media
|
||||||
// viewer. Built on the same scaffolding as `MobileSettingsHorseshoe`
|
// viewer. Built on the same scaffolding as `MobileSettingsHorseshoe`
|
||||||
// (clip-path bottom-carve, VAUL easing, Strict-Mode-safe entry
|
// (Strict-Mode-safe entry animation, `keepMounted` delayed unmount), but
|
||||||
// animation, `keepMounted` delayed unmount) but — being a FULLSCREEN
|
// this sheet is full-bleed — no clip-path bottom-carve — and rides the
|
||||||
// viewer — it drops the «horseshoe» look: no rounded top corners and
|
// pager / swipe-back easing (`PAGER_EASING`), not vaul's. Being a
|
||||||
// no black void gap above the sheet (see the geometry block below).
|
// near-fullscreen viewer it keeps the «horseshoe» rounded top corners
|
||||||
|
// (same radius as the tab curtains) but stays tall — the rounded top
|
||||||
|
// lands flush under the system status bar (see the geometry block below).
|
||||||
//
|
//
|
||||||
// Differences from the settings sheet:
|
// Differences from the settings sheet:
|
||||||
// • Tap-to-open only. No drag-up origin — opening is driven from
|
// • Tap-to-open only. No drag-up origin — opening is driven from
|
||||||
// `useOpenMediaViewer` called by `ImageContent.onClick`. The
|
// `useOpenMediaViewer` called by `ImageContent.onClick`. Drag-to-
|
||||||
// 20px handle band is the ONLY drag-sensitive area; touches on
|
// CLOSE works from BOTH the 20px handle band AND anywhere on the
|
||||||
// the viewer body below run zoom / pan / horizontal swipe
|
// viewer body (a vertical-down drag at zoom=1, fed by
|
||||||
// instead.
|
// MediaViewerBody's `onCloseDrag`); horizontal swipe pages, pinch
|
||||||
// • Fullscreen, flush top: the sheet covers the chat (incl. its
|
// zooms, and a one-finger drag at zoom>1 pans.
|
||||||
// header) and its flat top lands flush under the system status
|
// • Near-fullscreen, flush top: the sheet covers the chat (incl. its
|
||||||
// bar — no rounded carve, no void seam — vs settings's rounded 2/3.
|
// header) and its rounded top lands flush under the system status
|
||||||
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for
|
// bar — vs settings's shorter rounded 2/3.
|
||||||
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
|
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for a
|
||||||
|
// dark image backdrop. The rounded top corners are transparent and
|
||||||
|
// reveal the live chat behind (appBody is full-bleed, not clipped) —
|
||||||
|
// no backing square — unlike settings's `SurfaceVariant.Container`
|
||||||
|
// silhouette over a void seam. See the css file.
|
||||||
// • Wrapped content is the chat column, not the DM list.
|
// • Wrapped content is the chat column, not the DM list.
|
||||||
// • No `--vojo-safe-top` reset — there's no PageNav inside the
|
// • The silhouette resets `--vojo-safe-top` to 0 (mirror of the
|
||||||
// viewer body that would inherit it; the viewer body has its own
|
// settings sheet) so a status-bar-padded descendant added here later
|
||||||
// padding policy.
|
// isn't pushed down inside a sheet that already sits below the bar.
|
||||||
|
|
||||||
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { mediaViewerAtom } from '../../state/mediaViewer';
|
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||||
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
import { PAGER_EASING, PAGER_TRANSITION_MS } from '../../components/mobile-tabs-pager/geometry';
|
||||||
import { MediaViewerBody } from './MediaViewerBody';
|
import { MediaViewerBody } from './MediaViewerBody';
|
||||||
import * as css from './MobileMediaViewerHorseshoe.css';
|
import * as css from './MobileMediaViewerHorseshoe.css';
|
||||||
|
|
||||||
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
// Open / close timing borrowed from the tab pager — the SAME constants the
|
||||||
const ANIMATION_MS = 250;
|
// swipe-back-from-chat gesture re-uses (`swipe-back/geometry.ts`). Reusing
|
||||||
|
// them (not re-literalling 250ms / a vaul curve) keeps the sheet's slide
|
||||||
|
// reading at the same speed + decelerating curve as returning from a chat,
|
||||||
|
// and means the two can never drift in feel. `cubic-bezier(0.22, 1, 0.36, 1)`
|
||||||
|
// is the long-tail easeOut that makes the motion settle rather than snap.
|
||||||
|
const SHEET_EASING = PAGER_EASING;
|
||||||
|
const ANIMATION_MS = PAGER_TRANSITION_MS;
|
||||||
// Drag distance past which release commits the close. Matches the
|
// Drag distance past which release commits the close. Matches the
|
||||||
// settings sheet's 80px (the only commit gesture there too).
|
// settings sheet's 80px (the only commit gesture there too). Measured
|
||||||
|
// against the RUBBER-BANDED sheet displacement below, not the raw finger
|
||||||
|
// pull — the same «displacement, not raw» rule the chat-list curtain uses.
|
||||||
const COMMIT_THRESHOLD_PX = 80;
|
const COMMIT_THRESHOLD_PX = 80;
|
||||||
|
// Finger→sheet damping for the drag-to-close, mirroring the chat-list
|
||||||
|
// curtain's body gesture (`stream-header/geometry.ts::RUBBER_BAND`): the
|
||||||
|
// sheet follows the finger at 0.65× so the pull reads as physically
|
||||||
|
// «heavier». With the threshold above on the damped displacement, a commit
|
||||||
|
// needs ≈80 / 0.65 ≈ 123px of actual finger travel.
|
||||||
|
const DRAG_RUBBER_BAND = 0.65;
|
||||||
// Sheet height as a fraction of the viewport — near-fullscreen by
|
// Sheet height as a fraction of the viewport — near-fullscreen by
|
||||||
// design: the viewer should cover the chat (incl. its header) and stop
|
// design: the viewer should cover the chat (incl. its header) and stop
|
||||||
// just under the system status bar / tray. Capped at runtime by the
|
// just under the system status bar / tray. Capped at runtime by the
|
||||||
|
|
@ -51,7 +72,9 @@ const RAIL_VIEWPORT_FRACTION = 1;
|
||||||
const RAIL_MIN_PX = 200;
|
const RAIL_MIN_PX = 200;
|
||||||
|
|
||||||
type DragState = {
|
type DragState = {
|
||||||
inputType: 'touch' | 'pointer';
|
// 'touch' / 'pointer' originate on the top handle band; 'body' is fed by
|
||||||
|
// MediaViewerBody's drag-to-close (a downward drag anywhere on the stage).
|
||||||
|
inputType: 'touch' | 'pointer' | 'body';
|
||||||
startY: number;
|
startY: number;
|
||||||
deltaY: number;
|
deltaY: number;
|
||||||
};
|
};
|
||||||
|
|
@ -120,7 +143,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
|
|
||||||
// Final rail = viewport × fraction (full screen), then floor-then-
|
// Final rail = viewport × fraction (full screen), then floor-then-
|
||||||
// ceiling clamped. Container minus the status-bar inset is the HARD
|
// ceiling clamped. Container minus the status-bar inset is the HARD
|
||||||
// ceiling: the sheet's flat top lands flush at the bottom of the
|
// ceiling: the sheet's rounded top lands flush at the bottom of the
|
||||||
// status bar (no void gap / breathing), covering the whole chat column
|
// status bar (no void gap / breathing), covering the whole chat column
|
||||||
// down to just under the tray; when the wrapper shrinks (call rail /
|
// down to just under the tray; when the wrapper shrinks (call rail /
|
||||||
// split-screen) it shrinks below `RAIL_MIN_PX` rather than spilling
|
// split-screen) it shrinks below `RAIL_MIN_PX` rather than spilling
|
||||||
|
|
@ -145,9 +168,9 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep the viewer body mounted through the 250ms exit slide so
|
// Keep the viewer body mounted through the exit slide (`ANIMATION_MS`)
|
||||||
// the user sees the image slide down with the sheet instead of
|
// so the user sees the image slide down with the sheet instead of an
|
||||||
// an empty panel collapsing.
|
// empty panel collapsing.
|
||||||
const [keepMounted, setKeepMounted] = useState(open);
|
const [keepMounted, setKeepMounted] = useState(open);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -164,12 +187,13 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
if (entry) lastEntryRef.current = entry;
|
if (entry) lastEntryRef.current = entry;
|
||||||
|
|
||||||
const baseExpanded = open && hasEntered ? railHeightPx : 0;
|
const baseExpanded = open && hasEntered ? railHeightPx : 0;
|
||||||
// Drag is close-only (positive deltaY); clamp negative deltas to
|
// Drag is close-only (positive deltaY) and rubber-banded: the sheet
|
||||||
// 0 so an upward reversal cancels rather than expanding past the
|
// shrinks by `deltaY × DRAG_RUBBER_BAND`, so it lags the finger and the
|
||||||
// open height.
|
// pull feels «heavier» (same as the chat-list curtain body gesture).
|
||||||
const expandedPx = drag
|
// `Math.max(0, deltaY)` ignores any upward reversal past the start so it
|
||||||
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
// can't expand the sheet past its open height.
|
||||||
: baseExpanded;
|
const dragDisplacementPx = drag ? Math.max(0, drag.deltaY) * DRAG_RUBBER_BAND : 0;
|
||||||
|
const expandedPx = Math.max(0, baseExpanded - dragDisplacementPx);
|
||||||
const isDragging = drag !== null;
|
const isDragging = drag !== null;
|
||||||
|
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -181,6 +205,22 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
const closeSheetRef = useRef(closeSheet);
|
const closeSheetRef = useRef(closeSheet);
|
||||||
closeSheetRef.current = closeSheet;
|
closeSheetRef.current = closeSheet;
|
||||||
|
|
||||||
|
// Drag-to-close fed from MediaViewerBody — a downward drag anywhere on
|
||||||
|
// the stage (zoom=1). Mirrors the handle's rubber-band + commit threshold
|
||||||
|
// so the two surfaces feel identical. `dragging:false` is the release:
|
||||||
|
// commit if the damped displacement cleared the threshold, else spring
|
||||||
|
// back. Stable identity — MediaViewerBody mirrors it into a ref anyway.
|
||||||
|
const onBodyCloseDrag = useCallback((rawDeltaY: number, dragging: boolean) => {
|
||||||
|
if (dragging) {
|
||||||
|
setDrag({ inputType: 'body', startY: 0, deltaY: Math.max(0, rawDeltaY) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.max(0, rawDeltaY) * DRAG_RUBBER_BAND > COMMIT_THRESHOLD_PX) {
|
||||||
|
closeSheetRef.current();
|
||||||
|
}
|
||||||
|
setDrag(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Clear the atom if the wrapper unmounts while the sheet is open
|
// Clear the atom if the wrapper unmounts while the sheet is open
|
||||||
// (route change). Strict-mode rehearsal guard via `hasEnteredRef`
|
// (route change). Strict-mode rehearsal guard via `hasEnteredRef`
|
||||||
// — without it, the cleanup fires before the entry rAF and a
|
// — without it, the cleanup fires before the entry rAF and a
|
||||||
|
|
@ -237,7 +277,9 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
const applyEnd = () => {
|
const applyEnd = () => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
if (d.deltaY > COMMIT_THRESHOLD_PX) {
|
// Threshold is on the rubber-banded sheet displacement, not the raw
|
||||||
|
// finger pull — matches the live `dragDisplacementPx` the user sees.
|
||||||
|
if (Math.max(0, d.deltaY) * DRAG_RUBBER_BAND > COMMIT_THRESHOLD_PX) {
|
||||||
closeSheetRef.current();
|
closeSheetRef.current();
|
||||||
}
|
}
|
||||||
setDrag(null);
|
setDrag(null);
|
||||||
|
|
@ -301,18 +343,13 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Geometry — the media viewer is fullscreen, so (unlike the settings /
|
// Geometry — the media viewer is near-fullscreen: its rounded top lands
|
||||||
// profile horseshoes) it carves NO rounded top corners and NO void gap
|
// flush under the status-bar strip. The opaque silhouette grows from the
|
||||||
// above the sheet: the sheet's flat top butts directly against the chat
|
// bottom over the live chat (`appBody`, full-bleed behind it); its rounded
|
||||||
// it clips (which now fills only the status-bar strip above it). Chat bg
|
// TL/TR corners stay transparent and reveal that chat, so the rounding
|
||||||
// and sheet bg are the same Dawn tone, so there's no black void seam at
|
// reads against real content — no clip-path carve, no backing square (see
|
||||||
// the top — the viewer reads as a flush fullscreen panel under the tray.
|
// the css file). Only the silhouette's height animates.
|
||||||
const appBodyMaskBottomPx = expandedPx;
|
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${SHEET_EASING}`;
|
||||||
|
|
||||||
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px)`;
|
|
||||||
|
|
||||||
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
|
||||||
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
|
||||||
|
|
||||||
const renderEntry = entry ?? lastEntryRef.current;
|
const renderEntry = entry ?? lastEntryRef.current;
|
||||||
const renderBody = !!renderEntry && (keepMounted || isDragging);
|
const renderBody = !!renderEntry && (keepMounted || isDragging);
|
||||||
|
|
@ -353,14 +390,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
portalTarget
|
portalTarget
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
<div
|
<div className={css.appBody} style={{ overscrollBehaviorY: 'contain' }}>
|
||||||
className={css.appBody}
|
|
||||||
style={{
|
|
||||||
clipPath: appBodyClipPath,
|
|
||||||
transition: appBodyTransition,
|
|
||||||
overscrollBehaviorY: 'contain',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -369,7 +399,15 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
style={{
|
style={{
|
||||||
height: `${expandedPx}px`,
|
height: `${expandedPx}px`,
|
||||||
transition: silhouetteTransition,
|
transition: silhouetteTransition,
|
||||||
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
// Gate on `renderBody` (open / dragging / exiting), NOT on
|
||||||
|
// `expandedPx > 0`. On close `expandedPx` snaps to 0 immediately
|
||||||
|
// while the height transition animates X→0 over `ANIMATION_MS`;
|
||||||
|
// gating on `expandedPx` would flip to `hidden` on that first
|
||||||
|
// frame and the slide-down would never be seen (the sheet would
|
||||||
|
// just pop out). `renderBody` stays true through the exit window
|
||||||
|
// (`keepMounted`), so the retract animation is visible, then it
|
||||||
|
// goes false and hides the spent 0-height sheet.
|
||||||
|
visibility: renderBody ? 'visible' : 'hidden',
|
||||||
// Reset `--vojo-safe-top` so any descendant that paints a
|
// Reset `--vojo-safe-top` so any descendant that paints a
|
||||||
// `padding-top: var(--vojo-safe-top)` for status-bar safe-
|
// `padding-top: var(--vojo-safe-top)` for status-bar safe-
|
||||||
// area (e.g. PageNav-style headers, if anyone adds one
|
// area (e.g. PageNav-style headers, if anyone adds one
|
||||||
|
|
@ -391,7 +429,11 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
</div>
|
</div>
|
||||||
<div className={css.panelBody}>
|
<div className={css.panelBody}>
|
||||||
{renderBody && renderEntry && (
|
{renderBody && renderEntry && (
|
||||||
<MediaViewerBody entry={renderEntry} requestClose={() => closeSheetRef.current()} />
|
<MediaViewerBody
|
||||||
|
entry={renderEntry}
|
||||||
|
requestClose={() => closeSheetRef.current()}
|
||||||
|
onCloseDrag={onBodyCloseDrag}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue