feat(media): rework the viewer — smaller rounded chat previews, a draggable desktop window, a fullscreen mobile sheet, and swipe animation
This commit is contained in:
parent
901bec2e9a
commit
b6ffa03891
17 changed files with 952 additions and 696 deletions
|
|
@ -1,26 +1,28 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
const StreamMediaCornerNotch = toRem(4);
|
||||
// Small uniform rounding on ALL four corners (R400 = 8px) — the old
|
||||
// asymmetric "notch toward sender" corner is gone; previews now read as
|
||||
// plain rounded thumbnails on both own and peer sides.
|
||||
const StreamMediaCornerSmall = config.radii.R400;
|
||||
const StreamMediaCornerRound = config.radii.R500;
|
||||
|
||||
export const StreamMediaBubble = recipe({
|
||||
base: {
|
||||
export const StreamMediaBubble = style({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
maxWidth: '100%',
|
||||
boxSizing: 'border-box',
|
||||
flexShrink: 0,
|
||||
borderRadius: StreamMediaCornerSmall,
|
||||
// 1px frame drawn ABOVE the image via a pseudo-element. `inset
|
||||
// box-shadow` would sit on the bg layer and get hidden by the
|
||||
// `<img>` filling 100% × 100%; a real `border` would push the
|
||||
// image's coordinate space and re-introduce the 1px chip-alignment
|
||||
// offset we just removed. The pseudo sits at z-index 1 (above
|
||||
// image's auto-stacked 0, below the username overlay at z 2),
|
||||
// inherits the bubble's asymmetric border-radius, and is
|
||||
// pointer-events:none so clicks pass through to the image.
|
||||
// inherits the bubble's border-radius, and is pointer-events:none so
|
||||
// clicks pass through to the image.
|
||||
selectors: {
|
||||
'&::after': {
|
||||
content: '""',
|
||||
|
|
@ -32,22 +34,10 @@ export const StreamMediaBubble = recipe({
|
|||
zIndex: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
own: {
|
||||
true: {
|
||||
borderRadius: `${StreamMediaCornerNotch} ${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerRound}`,
|
||||
},
|
||||
false: {
|
||||
borderRadius: `${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerNotch}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Caption mini-bubble under the image. Uniform R500 corners — the asymmetric
|
||||
// notch lives on the image-bubble itself; stacking two notch'd rectangles
|
||||
// reads worse than one notched + one rounded.
|
||||
// Caption mini-bubble under the image. Keeps a slightly larger R500 radius,
|
||||
// intentionally distinct from the preview bubble's uniform R400.
|
||||
export const StreamMediaCaption = style({
|
||||
marginTop: toRem(4),
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import { toRem } from 'folds';
|
|||
import { StreamMediaBubble } from './StreamMedia.css';
|
||||
import { logMedia, useMediaMeasureDebug } from './streamMediaDebug';
|
||||
|
||||
const STREAM_MEDIA_MAX_DIM = 320;
|
||||
// Timeline media preview box — shrunk ~1.9× from the original 320 per
|
||||
// design so photos/videos read as compact inline previews; the full
|
||||
// image opens in the media viewer on tap.
|
||||
const STREAM_MEDIA_MAX_DIM = 168;
|
||||
const STREAM_MEDIA_MIN_ASPECT = 3 / 4;
|
||||
const STREAM_MEDIA_MAX_ASPECT = 4 / 3;
|
||||
|
||||
|
|
@ -40,10 +43,13 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
|
|||
return { width: toRem(h * naturalAspect), height: toRem(h) };
|
||||
}
|
||||
|
||||
// Shared chrome for image / video timeline bubbles: square-ish bubble with
|
||||
// asymmetric notch + 1px pseudo-frame. Caller plugs in the actual media
|
||||
// renderer as `children`. The sender nick is rendered ABOVE the media by the
|
||||
// Stream layout's name header (like text messages), not overlaid on the image.
|
||||
// Shared chrome for image / video timeline bubbles: an aspect-preserving
|
||||
// (up to 168px) preview box with uniformly-rounded R400 corners and a 1px
|
||||
// pseudo-frame. Caller plugs in the actual media renderer as `children`.
|
||||
// The sender nick is rendered ABOVE the media by the Stream layout's name
|
||||
// header (like text messages), not overlaid on the image. `own` no longer
|
||||
// affects rendering after the uniform-corner change — it survives only as
|
||||
// a debug-log field (sender vs peer) for the media-measure diagnostics.
|
||||
export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMediaShellProps) {
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const computedStyle = computeBoxStyle(naturalW, naturalH);
|
||||
|
|
@ -58,7 +64,7 @@ export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMe
|
|||
useMediaMeasureDebug(bubbleRef, [computedStyle.width, computedStyle.height]);
|
||||
|
||||
return (
|
||||
<div ref={bubbleRef} className={StreamMediaBubble({ own })} style={computedStyle}>
|
||||
<div ref={bubbleRef} className={StreamMediaBubble} style={computedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ export type ImageContentProps = {
|
|||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
// When provided AND the `MediaViewerHostContext` is non-null,
|
||||
// clicking the thumbnail opens the atom-driven horseshoe viewer
|
||||
// (mobile bottom-up sheet / desktop right pane) instead of the
|
||||
// clicking the thumbnail opens the atom-driven viewer (mobile
|
||||
// bottom-up sheet / desktop+tablet floating window) instead of the
|
||||
// legacy full-screen `<Overlay>` viewer. Non-Room surfaces
|
||||
// (pin-menu, message search) leave the host context as `null` and
|
||||
// therefore keep the legacy modal even if they pass `eventId`.
|
||||
|
|
|
|||
|
|
@ -71,6 +71,20 @@ export const image = style({
|
|||
imageRendering: 'auto',
|
||||
});
|
||||
|
||||
// Swipe-filmstrip neighbour layer — fills the stage, centres its image
|
||||
// like the current one, and ignores pointers (the current image owns the
|
||||
// gesture surface). Translated by the parent so prev/next slide with the
|
||||
// drag.
|
||||
export const previewLayer = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
// Side chevrons — visibility logic:
|
||||
// • Coarse pointer (touch devices, mobile): always visible at
|
||||
// 0.7 opacity so users have a tappable nav affordance without
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Body for the room media viewer — mounted inside both the mobile
|
||||
// `MobileMediaViewerHorseshoe` panel-body and the desktop
|
||||
// `RoomViewMediaSidePanel`. Owns:
|
||||
// `MobileMediaViewerHorseshoe` panel-body and the desktop floating
|
||||
// `RoomViewMediaWindow`. Owns:
|
||||
// • Resolving + decrypting the media URL for the current entry,
|
||||
// including revoke-on-unmount for blob URLs we create.
|
||||
// • Zoom for images (button row + `+`/`-`/`0` keys).
|
||||
|
|
@ -46,6 +46,13 @@ const SWIPE_COMMIT_PX = 80;
|
|||
// the gesture is interpreted as a swipe — keeps accidental diagonal
|
||||
// drags from committing.
|
||||
const SWIPE_DOMINANCE = 1.5;
|
||||
// Duration of the slide animation when a prev/next navigation commits
|
||||
// (swipe release past threshold, chevron tap, arrow key).
|
||||
const SWIPE_ANIM_MS = 280;
|
||||
// Gutter between adjacent images in the swipe filmstrip, so consecutive
|
||||
// pictures don't read as glued edge-to-edge while sliding. The panel
|
||||
// «pitch» (centre-to-centre travel) is therefore stageWidth + this.
|
||||
const SWIPE_GAP_PX = 24;
|
||||
|
||||
// Walk the room's loaded timeline events and build the ordered set
|
||||
// of viewable image + video events. Out-of-window media (older than
|
||||
|
|
@ -94,12 +101,63 @@ function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null
|
|||
};
|
||||
}
|
||||
|
||||
// A non-interactive neighbour image for the swipe «filmstrip». It resolves
|
||||
// (and shares, via the parent's `resolveSrc` cache) its own decrypted src so
|
||||
// the prev / next image is already on-screen as the user drags, then slides
|
||||
// to centre on commit. Image-only — videos keep instant navigation. Sits
|
||||
// BEHIND the interactive current image (`pointer-events: none`).
|
||||
function SwipePreview({
|
||||
entry,
|
||||
resolveSrc,
|
||||
offsetPx,
|
||||
transition,
|
||||
}: {
|
||||
entry: MediaViewerEntry;
|
||||
resolveSrc: (e: MediaViewerEntry) => Promise<string>;
|
||||
offsetPx: number;
|
||||
transition: string;
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
resolveSrc(entry)
|
||||
.then((s) => {
|
||||
if (alive) setSrc(s);
|
||||
})
|
||||
.catch(() => {
|
||||
/* neighbour preview is best-effort */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [entry, resolveSrc]);
|
||||
return (
|
||||
<div className={css.previewLayer} aria-hidden="true">
|
||||
{src && (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={css.image}
|
||||
draggable={false}
|
||||
style={{ transform: `translate3d(${offsetPx}px, 0, 0)`, transition }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MediaViewerBodyProps = {
|
||||
entry: MediaViewerEntry;
|
||||
requestClose: () => void;
|
||||
// Drop the title/zoom/download/close toolbar entirely. The desktop
|
||||
// floating window owns its own minimal chrome (drag bar + close), so
|
||||
// it passes this; zoom is wheel-only and nav is keyboard/chevron
|
||||
// there. The mobile horseshoe + (legacy) side pane leave it false to
|
||||
// keep the full toolbar.
|
||||
hideHeader?: boolean;
|
||||
};
|
||||
|
||||
export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||
export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewerBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
|
@ -127,6 +185,24 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');
|
||||
|
||||
// Horizontal swipe / slide state (declared here, before the
|
||||
// navigation + keyboard handlers that read it). `swipeOffset` is the
|
||||
// live drag translate; `swipeAnimating` is true while a commit slide
|
||||
// is settling (so the transition is on for the ±stageWidth glide);
|
||||
// `stageWidth` feeds the neighbour-panel positions and the commit
|
||||
// target; `commitTimerRef` defers the entry swap to the end of the
|
||||
// slide. Gesture detail is in the big stage-listener effect below.
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [swipeAnimating, setSwipeAnimating] = useState(false);
|
||||
const [stageWidth, setStageWidth] = useState(0);
|
||||
const commitTimerRef = useRef<number | null>(null);
|
||||
const prefersReducedMotion = useMemo(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
[]
|
||||
);
|
||||
|
||||
// Mirror `zoom` and `pan` into refs so callbacks installed in
|
||||
// long-lived listeners can read the latest values without
|
||||
// re-binding, and so the inlined anchor-aware zoom math in
|
||||
|
|
@ -203,12 +279,31 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
// `onImageLoad` set a small ceiling) to a high-res one would
|
||||
// briefly use the stale ceiling until the new image's `onLoad`
|
||||
// fires. Gesture state is reset in a separate effect below.
|
||||
// Also cancel any in-flight commit slide (e.g. an external open
|
||||
// mid-animation) so its deferred entry swap can't fire stale.
|
||||
useEffect(() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
setMaxZoom(ZOOM_CEILING_FALLBACK);
|
||||
if (commitTimerRef.current !== null) {
|
||||
window.clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = null;
|
||||
}
|
||||
setSwipeAnimating(false);
|
||||
}, [entry.eventId, setZoom]);
|
||||
|
||||
// Track the stage width so the swipe filmstrip can position the
|
||||
// prev/next panels exactly one stage-width to either side and the
|
||||
// commit slide knows how far to glide.
|
||||
useEffect(() => {
|
||||
const el = stageRef.current;
|
||||
if (!el) return undefined;
|
||||
setStageWidth(el.clientWidth);
|
||||
const ro = new ResizeObserver(() => setStageWidth(el.clientWidth));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Re-clamp the existing pan against the new bounds when zoom
|
||||
// changes (e.g. zooming out from 3× to 1× should snap a panned
|
||||
// image back to centre since there's nothing to pan into).
|
||||
|
|
@ -254,55 +349,62 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
[clampPan]
|
||||
);
|
||||
|
||||
// Resolve + decrypt the media URL. Mirrors `ImageContent`'s
|
||||
// pattern. Blob-URL lifecycle is managed via `lastBlobUrlRef`:
|
||||
// every encrypted entry allocates a fresh blob INSIDE the async
|
||||
// callback, and we revoke the previous one before storing the
|
||||
// new one. This pattern handles two leak paths that a naive
|
||||
// `useEffect(srcState)` cleanup misses:
|
||||
// 1. `useAsyncCallback`'s "Request replaced!" reject path —
|
||||
// a rapidly-replaced loadSrc may have created a blob via
|
||||
// `createObjectURL` before the throw, which never reaches
|
||||
// `srcState` and so is never cleaned up. Here we revoke
|
||||
// whatever was last stamped into the ref before stamping
|
||||
// the new one.
|
||||
// 2. Unmount mid-load — `useEffect` cleanup with a stale
|
||||
// `srcState.data` could revoke a blob the new mount is
|
||||
// still using. With a ref-tracked invariant of "we own
|
||||
// `lastBlobUrlRef.current`", the unmount cleanup is
|
||||
// unambiguous.
|
||||
const lastBlobUrlRef = useRef<string | null>(null);
|
||||
const { url: entryUrl, encInfo: entryEncInfo, mimeType: entryMimeType } = entry;
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, entryUrl, useAuthentication);
|
||||
// Resolve + decrypt the media URL into a displayable src, CACHED by
|
||||
// eventId so the current image and its swipe neighbours share one
|
||||
// decrypted blob — that's what lets a swipe slide between two
|
||||
// already-decrypted images with no reload gap. Blob URLs (encrypted
|
||||
// media) are tracked in `blobUrlsRef` so we revoke exactly those on
|
||||
// eviction / unmount; plain http(s) srcs need no revoke. The
|
||||
// concurrent-resolve re-check below means a rapidly-replaced load (or
|
||||
// a preview + main racing on the same event) never leaks a duplicate
|
||||
// blob.
|
||||
const srcCacheRef = useRef<Map<string, string>>(new Map());
|
||||
const blobUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const resolveMediaSrc = useCallback(
|
||||
async (e: MediaViewerEntry): Promise<string> => {
|
||||
const cached = srcCacheRef.current.get(e.eventId);
|
||||
if (cached) return cached;
|
||||
const mediaUrl = mxcUrlToHttp(mx, e.url, useAuthentication);
|
||||
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||
if (entryEncInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, entryMimeType ?? FALLBACK_MIMETYPE, entryEncInfo)
|
||||
);
|
||||
const blob = URL.createObjectURL(fileContent);
|
||||
if (lastBlobUrlRef.current && lastBlobUrlRef.current !== blob) {
|
||||
URL.revokeObjectURL(lastBlobUrlRef.current);
|
||||
}
|
||||
lastBlobUrlRef.current = blob;
|
||||
return blob;
|
||||
}
|
||||
const enc = e.encInfo;
|
||||
if (!enc) {
|
||||
srcCacheRef.current.set(e.eventId, mediaUrl);
|
||||
return mediaUrl;
|
||||
}, [mx, entryUrl, entryEncInfo, entryMimeType, useAuthentication])
|
||||
}
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, e.mimeType ?? FALLBACK_MIMETYPE, enc)
|
||||
);
|
||||
// A concurrent resolve for the same event may have won the cache
|
||||
// while we decrypted — drop our duplicate blob and use the winner.
|
||||
const winner = srcCacheRef.current.get(e.eventId);
|
||||
if (winner) return winner;
|
||||
const blob = URL.createObjectURL(fileContent);
|
||||
blobUrlsRef.current.add(blob);
|
||||
srcCacheRef.current.set(e.eventId, blob);
|
||||
return blob;
|
||||
},
|
||||
[mx, useAuthentication]
|
||||
);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(() => resolveMediaSrc(entry), [resolveMediaSrc, entry])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSrc();
|
||||
}, [loadSrc]);
|
||||
|
||||
// Revoke any blob URL we still own when the viewer body unmounts
|
||||
// (sheet closed entirely, room change, etc).
|
||||
// Revoke every blob URL we created (and cancel a pending commit
|
||||
// slide) when the viewer body unmounts — sheet closed, room change.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (lastBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(lastBlobUrlRef.current);
|
||||
lastBlobUrlRef.current = null;
|
||||
blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u));
|
||||
blobUrlsRef.current.clear();
|
||||
srcCacheRef.current.clear();
|
||||
if (commitTimerRef.current !== null) {
|
||||
window.clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[]
|
||||
|
|
@ -377,24 +479,84 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
[mediaSet, entry.eventId]
|
||||
);
|
||||
|
||||
const step = useCallback(
|
||||
// Navigate prev/next with a slide. For image→image (and motion
|
||||
// allowed, stage measured) we glide the current image out and the
|
||||
// neighbour panel in over `SWIPE_ANIM_MS`, THEN swap the atom entry —
|
||||
// because the neighbour was prefetched into the shared src cache the
|
||||
// new current paints instantly at centre, so the swap is seamless.
|
||||
// Anything else (video involved, reduced motion, stage not measured)
|
||||
// falls back to an instant switch. Guarded against re-entry while a
|
||||
// commit slide is already in flight.
|
||||
const animatedStep = useCallback(
|
||||
(delta: -1 | 1) => {
|
||||
if (commitTimerRef.current !== null) return;
|
||||
if (currentIndex < 0) return;
|
||||
const target = currentIndex + delta;
|
||||
if (target < 0 || target >= mediaSet.length) return;
|
||||
const next = mediaSet[target];
|
||||
if (!next) return;
|
||||
openMediaViewer(next);
|
||||
const targetIndex = currentIndex + delta;
|
||||
if (targetIndex < 0 || targetIndex >= mediaSet.length) return;
|
||||
const target = mediaSet[targetIndex];
|
||||
if (!target) return;
|
||||
const stageW = stageRef.current?.clientWidth ?? 0;
|
||||
// Slide only at rest zoom (a zoomed image has no neighbour panels
|
||||
// and shouldn't glide while scaled — chevron/arrow nav while zoomed
|
||||
// switches instantly). `zoomRef` keeps this out of the deps.
|
||||
const canSlide =
|
||||
!prefersReducedMotion &&
|
||||
stageW > 0 &&
|
||||
zoomRef.current === 1 &&
|
||||
entry.kind === 'image' &&
|
||||
target.kind === 'image';
|
||||
if (!canSlide) {
|
||||
setSwipeOffset(0);
|
||||
openMediaViewer(target);
|
||||
return;
|
||||
}
|
||||
setSwipeAnimating(true);
|
||||
// delta +1 (next) slides content LEFT by one panel pitch (stage +
|
||||
// gutter) → current glides off-screen while the prefetched next
|
||||
// panel (sitting one pitch to the right) glides to centre.
|
||||
const pitch = stageW + SWIPE_GAP_PX;
|
||||
setSwipeOffset(delta === 1 ? -pitch : pitch);
|
||||
commitTimerRef.current = window.setTimeout(() => {
|
||||
commitTimerRef.current = null;
|
||||
setSwipeAnimating(false);
|
||||
setSwipeOffset(0);
|
||||
openMediaViewer(target);
|
||||
}, SWIPE_ANIM_MS);
|
||||
},
|
||||
[currentIndex, mediaSet, openMediaViewer]
|
||||
[currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion]
|
||||
);
|
||||
|
||||
const canPrev = currentIndex > 0;
|
||||
const canNext = currentIndex >= 0 && currentIndex < mediaSet.length - 1;
|
||||
|
||||
// Prefetch the immediate image neighbours so a swipe slides into an
|
||||
// already-decrypted picture, and prune cached srcs outside a small
|
||||
// window around the current index (revoking their blobs) to bound
|
||||
// memory. The current entry and its ±1 neighbours are always kept, so
|
||||
// we never revoke a src a live <img> is using.
|
||||
useEffect(() => {
|
||||
if (currentIndex < 0) return;
|
||||
[mediaSet[currentIndex - 1], mediaSet[currentIndex + 1]].forEach((e) => {
|
||||
if (e && e.kind === 'image') resolveMediaSrc(e).catch(() => undefined);
|
||||
});
|
||||
const keep = new Set<string>([entry.eventId]);
|
||||
for (let d = -2; d <= 2; d += 1) {
|
||||
const e = mediaSet[currentIndex + d];
|
||||
if (e) keep.add(e.eventId);
|
||||
}
|
||||
srcCacheRef.current.forEach((src, eid) => {
|
||||
if (keep.has(eid)) return;
|
||||
if (blobUrlsRef.current.has(src)) {
|
||||
URL.revokeObjectURL(src);
|
||||
blobUrlsRef.current.delete(src);
|
||||
}
|
||||
srcCacheRef.current.delete(eid);
|
||||
});
|
||||
}, [currentIndex, mediaSet, entry.eventId, resolveMediaSrc]);
|
||||
|
||||
// Keyboard — ArrowLeft / Right step, +/- / 0 manage zoom. Esc is
|
||||
// handled by the parent shell (focus-trap on desktop, explicit
|
||||
// keydown listener on mobile).
|
||||
// handled by the parent shell via an explicit window keydown listener
|
||||
// (both the mobile horseshoe and the desktop floating window).
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
|
|
@ -407,10 +569,10 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
step(-1);
|
||||
animatedStep(-1);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
step(1);
|
||||
animatedStep(1);
|
||||
} else if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault();
|
||||
zoomIn();
|
||||
|
|
@ -424,7 +586,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [step, zoomIn, zoomOut, setZoom]);
|
||||
}, [animatedStep, zoomIn, zoomOut, setZoom]);
|
||||
|
||||
// Multi-touch gesture state on the image stage. Three modes:
|
||||
// • zoom=1, 1 finger → horizontal-swipe to prev/next image.
|
||||
|
|
@ -438,7 +600,8 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
// Chromium both fire for touch, which double-fires every handler.
|
||||
// The MDN reference pattern that drove this implementation:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
// (`swipeOffset` / `swipeAnimating` are declared up top with the rest
|
||||
// of the slide state.)
|
||||
|
||||
const swipeStateRef = useRef({
|
||||
active: false,
|
||||
|
|
@ -487,8 +650,19 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
canPrevRef.current = canPrev;
|
||||
const canNextRef = useRef(canNext);
|
||||
canNextRef.current = canNext;
|
||||
const stepRef = useRef(step);
|
||||
stepRef.current = step;
|
||||
// Whether the prev/next neighbour is an IMAGE (i.e. slides). A video
|
||||
// neighbour (or a true edge) instead rubber-bands the drag — there's no
|
||||
// slide panel to follow the finger into, it switches instantly on
|
||||
// release. Mirrored for the once-installed gesture listeners.
|
||||
const prevIsImageRef = useRef(false);
|
||||
prevIsImageRef.current = currentIndex > 0 && mediaSet[currentIndex - 1]?.kind === 'image';
|
||||
const nextIsImageRef = useRef(false);
|
||||
nextIsImageRef.current =
|
||||
currentIndex >= 0 &&
|
||||
currentIndex < mediaSet.length - 1 &&
|
||||
mediaSet[currentIndex + 1]?.kind === 'image';
|
||||
const animatedStepRef = useRef(animatedStep);
|
||||
animatedStepRef.current = animatedStep;
|
||||
const entryKindRef = useRef(entry.kind);
|
||||
entryKindRef.current = entry.kind;
|
||||
// `zoomRef` + `panRef` are already mirrored in the pan section
|
||||
|
|
@ -531,6 +705,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
// mouse here for everything.
|
||||
if (e.pointerType === 'mouse') return;
|
||||
if (entryKindRef.current !== 'image') return;
|
||||
// Ignore new touches while a commit slide is gliding (the entry is
|
||||
// about to swap). Without this, a fresh pointerdown mid-glide would
|
||||
// run `setSwipeOffset(0)` while `swipeAnimating` is still on and
|
||||
// animate the outgoing image BACK toward centre — a reverse/stutter
|
||||
// on fast double-swipes. Mirrors the same guard in `animatedStep`.
|
||||
if (commitTimerRef.current !== null) return;
|
||||
|
||||
// Capture the pointer so subsequent `pointermove`/`pointerup`
|
||||
// events are dispatched to the stage even when the finger
|
||||
|
|
@ -651,8 +831,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
}
|
||||
if (s.claimed !== 'swipe') return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
const blocked = (dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current);
|
||||
setSwipeOffset(blocked ? dx / 3 : dx);
|
||||
// Rubber-band (1/3 travel) when the swipe direction has nothing to
|
||||
// slide into — a true list edge OR a video neighbour (videos switch
|
||||
// instantly on release rather than gliding). A real image neighbour
|
||||
// follows the finger 1:1.
|
||||
const resisted = (dx > 0 && !prevIsImageRef.current) || (dx < 0 && !nextIsImageRef.current);
|
||||
setSwipeOffset(resisted ? dx / 3 : dx);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -699,11 +883,19 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
const s = swipeStateRef.current;
|
||||
if (s.active) {
|
||||
s.active = false;
|
||||
let committed = false;
|
||||
if (s.claimed === 'swipe') {
|
||||
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) stepRef.current(-1);
|
||||
else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) stepRef.current(1);
|
||||
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) {
|
||||
animatedStepRef.current(-1);
|
||||
committed = true;
|
||||
} else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) {
|
||||
animatedStepRef.current(1);
|
||||
committed = true;
|
||||
}
|
||||
setSwipeOffset(0);
|
||||
}
|
||||
// A commit lets `animatedStep` own the offset (glide to
|
||||
// ±stageWidth); a non-committing release snaps back to centre.
|
||||
if (!committed) setSwipeOffset(0);
|
||||
}
|
||||
touchPanStateRef.current = null;
|
||||
}
|
||||
|
|
@ -766,7 +958,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const effectiveSrc = srcState.status === AsyncStatus.Success ? srcState.data : undefined;
|
||||
// Prefer the shared cache so a just-committed (prefetched) entry paints
|
||||
// instantly with no `useAsyncCallback` Loading flash; fall back to the
|
||||
// loader's result for the first, uncached open.
|
||||
const cachedSrc = srcCacheRef.current.get(entry.eventId);
|
||||
const effectiveSrc =
|
||||
cachedSrc ?? (srcState.status === AsyncStatus.Success ? srcState.data : undefined);
|
||||
const isLoading =
|
||||
!effectiveSrc &&
|
||||
(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Idle);
|
||||
|
|
@ -795,11 +992,30 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
const transform = isReady
|
||||
? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
|
||||
: undefined;
|
||||
const imageTransition =
|
||||
swipeOffset === 0 ? 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)' : 'none';
|
||||
// Transition ON while a commit slide settles (swipeAnimating) and at
|
||||
// rest (offset 0 — smooth zoom + snap-back); OFF during an active drag
|
||||
// so the image tracks the finger 1:1.
|
||||
let imageTransition = 'none';
|
||||
if (swipeAnimating) {
|
||||
imageTransition = `transform ${SWIPE_ANIM_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`;
|
||||
} else if (swipeOffset === 0) {
|
||||
imageTransition = 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
}
|
||||
|
||||
// Swipe filmstrip neighbours — only at rest zoom and for images (swipe
|
||||
// is image-only). They slide WITH the current image (same offset, one
|
||||
// stage-width to either side) so the prev/next picture is visible as
|
||||
// the user drags and glides to centre on commit.
|
||||
const prevEntry = currentIndex > 0 ? mediaSet[currentIndex - 1] : undefined;
|
||||
const nextEntry =
|
||||
currentIndex >= 0 && currentIndex < mediaSet.length - 1
|
||||
? mediaSet[currentIndex + 1]
|
||||
: undefined;
|
||||
const showSwipePreviews = entry.kind === 'image' && zoom === 1 && stageWidth > 0;
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{!hideHeader && (
|
||||
<PageHeader
|
||||
// `PageHeader` (= folds `Header size="600"`) so the header's
|
||||
// bottom rule aligns to the pixel with the chat header to the
|
||||
|
|
@ -870,14 +1086,36 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
</IconButton>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
)}
|
||||
|
||||
<div className={css.stage} ref={stageRef}>
|
||||
{showSwipePreviews && prevEntry?.kind === 'image' && (
|
||||
<SwipePreview
|
||||
key={prevEntry.eventId}
|
||||
entry={prevEntry}
|
||||
resolveSrc={resolveMediaSrc}
|
||||
offsetPx={swipeOffset - stageWidth - SWIPE_GAP_PX}
|
||||
transition={imageTransition}
|
||||
/>
|
||||
)}
|
||||
{showSwipePreviews && nextEntry?.kind === 'image' && (
|
||||
<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.
|
||||
// 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}
|
||||
|
|
@ -942,9 +1180,9 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
aria-label={t('MediaViewer.previous', 'Previous')}
|
||||
// `aria-disabled` (not `disabled`) so the CSS dimmed-
|
||||
// state selector still matches; the onClick already
|
||||
// bails internally via `step`'s bounds check.
|
||||
// bails internally via `animatedStep`'s bounds check.
|
||||
aria-disabled={!canPrev}
|
||||
onClick={() => step(-1)}
|
||||
onClick={() => animatedStep(-1)}
|
||||
>
|
||||
<Icon size="200" src={Icons.ChevronLeft} />
|
||||
</button>
|
||||
|
|
@ -953,7 +1191,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
className={classNames(css.chevron, css.chevronRight)}
|
||||
aria-label={t('MediaViewer.next', 'Next')}
|
||||
aria-disabled={!canNext}
|
||||
onClick={() => step(1)}
|
||||
onClick={() => animatedStep(1)}
|
||||
>
|
||||
<Icon size="200" src={Icons.ChevronRight} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, toRem } from 'folds';
|
||||
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||
|
||||
// Re-exported so the TSX can pick up the constants without crossing
|
||||
// the vanilla-extract / runtime boundary twice.
|
||||
export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX;
|
||||
export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
|
||||
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||
|
||||
// Outer container — anchor for the two absolutely-positioned panes
|
||||
// (`appBody` and `silhouette`). Same shape as the settings-sheet
|
||||
|
|
@ -41,7 +36,11 @@ export const appBody = style({
|
|||
|
||||
// The viewer sheet's surface. `Background.Container` (deepest Dawn
|
||||
// tone) so the image floats on a near-black backdrop — closest to
|
||||
// the legacy `<Overlay>` viewer's full-screen dark scrim.
|
||||
// the legacy `<Overlay>` viewer's full-screen dark scrim. Rounded
|
||||
// top corners (same radius as the tab curtains) with `overflow:
|
||||
// hidden` clip the header / image to the curve; the carved corners
|
||||
// expose the app's dark background behind, so the rounding reads
|
||||
// without re-introducing a black void gap.
|
||||
export const silhouette = style({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
|
|
@ -51,7 +50,9 @@ export const silhouette = style({
|
|||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Background.Container,
|
||||
willChange: 'height, border-top-left-radius, border-top-right-radius',
|
||||
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||
borderTopRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||
willChange: 'height',
|
||||
});
|
||||
|
||||
// Top-anchored — handle + viewer body reveal top-down as silhouette
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Bottom-up «horseshoe» sheet that wraps the mobile chat column for
|
||||
// the media viewer. Sister of `MobileSettingsHorseshoe` (which wraps
|
||||
// the DM list with the same idiom for the Settings tree) — same
|
||||
// clip-path carve, same VAUL easing, same Strict-Mode-safe entry
|
||||
// animation, same `keepMounted` delayed unmount.
|
||||
// Bottom-up sheet that wraps the mobile chat column for the media
|
||||
// viewer. Built on the same scaffolding as `MobileSettingsHorseshoe`
|
||||
// (clip-path bottom-carve, VAUL easing, Strict-Mode-safe entry
|
||||
// animation, `keepMounted` delayed unmount) but — being a FULLSCREEN
|
||||
// viewer — it drops the «horseshoe» look: no rounded top corners and
|
||||
// no black void gap above the sheet (see the geometry block below).
|
||||
//
|
||||
// Differences from the settings sheet:
|
||||
// • Tap-to-open only. No drag-up origin — opening is driven from
|
||||
|
|
@ -10,7 +11,9 @@
|
|||
// 20px handle band is the ONLY drag-sensitive area; touches on
|
||||
// the viewer body below run zoom / pan / horizontal swipe
|
||||
// instead.
|
||||
// • `RAIL_FRACTION = 3 / 4` (75% of viewport) vs settings's 2/3.
|
||||
// • Fullscreen, flush top: the sheet covers the chat (incl. its
|
||||
// header) and its flat top lands flush under the system status
|
||||
// bar — no rounded carve, no void seam — vs settings's rounded 2/3.
|
||||
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for
|
||||
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
|
||||
// • Wrapped content is the chat column, not the DM list.
|
||||
|
|
@ -25,7 +28,6 @@ import { useTranslation } from 'react-i18next';
|
|||
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
||||
import { MediaViewerBody } from './MediaViewerBody';
|
||||
import * as css from './MobileMediaViewerHorseshoe.css';
|
||||
|
||||
|
|
@ -34,39 +36,19 @@ const ANIMATION_MS = 250;
|
|||
// Drag distance past which release commits the close. Matches the
|
||||
// settings sheet's 80px (the only commit gesture there too).
|
||||
const COMMIT_THRESHOLD_PX = 80;
|
||||
// Sheet height as a fraction of the viewport — product spec.
|
||||
// Capped at runtime by the wrapper container's actual height
|
||||
// (see `railHeightPx` calc below) so it can't spill past the
|
||||
// chat header when chrome outside the chat column (e.g.
|
||||
// `HorseshoeContainer.bottomRail` during a call) shrinks the
|
||||
// wrapper.
|
||||
const RAIL_VIEWPORT_FRACTION = 0.8;
|
||||
// Sheet height as a fraction of the viewport — near-fullscreen by
|
||||
// design: the viewer should cover the chat (incl. its header) and stop
|
||||
// just under the system status bar / tray. Capped at runtime by the
|
||||
// wrapper container's actual height minus the status-bar inset (see
|
||||
// `railHeightPx` calc below) so the sheet's rounded top edge lands at
|
||||
// the bottom of the status bar, never over the tray icons.
|
||||
const RAIL_VIEWPORT_FRACTION = 1;
|
||||
// Absolute floor for tiny / split-screen viewports — the sheet
|
||||
// becomes useless below this. NB: floor cannot exceed the
|
||||
// container's ceiling (`Math.min` is applied last), so in extreme
|
||||
// squeeze cases (split-screen, oversized call rail) the sheet
|
||||
// silently shrinks below the floor rather than clipping the chat
|
||||
// header.
|
||||
// silently shrinks below the floor rather than clipping the status bar.
|
||||
const RAIL_MIN_PX = 200;
|
||||
// Headroom reserved at the TOP of the container for the chat
|
||||
// header (`PageHeader` = folds `Header size="600"`, ~64px) plus
|
||||
// the 12px horseshoe void gap above the silhouette's rounded
|
||||
// TL/TR carves. With this in place the sheet's top edge stops
|
||||
// just below the chat header even when the wrapper container
|
||||
// shrinks (e.g. `HorseshoeContainer.bottomRail` is on during a
|
||||
// call). Previously the headroom was 8px which let the sheet
|
||||
// climb over the header in tight viewports.
|
||||
const CHAT_HEADER_RESERVED_PX = 76;
|
||||
// Same emerge window as the settings sheet so the silhouette's
|
||||
// rounding + void gap finish exactly when the gesture qualifies to
|
||||
// commit.
|
||||
const HORSESHOE_EMERGE_PX = 80;
|
||||
|
||||
// Symmetric cubic in-out — slow start, fast middle, slow finish.
|
||||
// Same curve the settings / profile horseshoes use for their emerge
|
||||
// (rationale at the top of `MobileSettingsHorseshoe.tsx`).
|
||||
const easeInOutCubic = (t: number): number =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
|
||||
|
||||
type DragState = {
|
||||
inputType: 'touch' | 'pointer';
|
||||
|
|
@ -97,13 +79,12 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
|||
// Measure the wrapper container directly via `ResizeObserver` so
|
||||
// the rail height tracks whatever vertical space is actually
|
||||
// available to the chat column. The viewport-based fraction is
|
||||
// the *intent* (90% of the screen — product spec), but
|
||||
// the *intent* (full screen — product spec), but
|
||||
// `HorseshoeContainer.bottomRail` (the call rail) sits OUTSIDE
|
||||
// the chat column inside the same viewport and shrinks our
|
||||
// wrapper when active. Without the container clamp, 90% of the
|
||||
// viewport could exceed the wrapper's height, and the sheet
|
||||
// would render up to / past the chat header which sits inside
|
||||
// the wrapper's appBody.
|
||||
// wrapper when active. Without the container clamp, a full-viewport
|
||||
// sheet could exceed the wrapper's height and spill past the bottom
|
||||
// chrome; the clamp keeps the top edge at the status bar instead.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(() =>
|
||||
typeof window === 'undefined' ? 800 : window.innerHeight
|
||||
|
|
@ -121,15 +102,32 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
|||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Final rail = viewport × fraction, then floor-then-ceiling
|
||||
// clamped. Container (minus chat-header reserve) is the HARD
|
||||
// ceiling: when the wrapper shrinks (call rail / split-screen)
|
||||
// the sheet shrinks below `RAIL_MIN_PX` rather than spilling
|
||||
// over the chat header. Order matters — `Math.min` last makes
|
||||
// container win against floor.
|
||||
// Live status-bar inset. `env(safe-area-inset-top)` can't be read
|
||||
// reliably off a custom property in JS, so we measure a zero-width
|
||||
// probe whose height is `var(--vojo-safe-top)` (= the env value).
|
||||
// This is the only thing the near-fullscreen sheet must stay below
|
||||
// — it covers the chat header but must not climb over the tray.
|
||||
const safeTopRef = useRef<HTMLDivElement>(null);
|
||||
const [safeTopPx, setSafeTopPx] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
const el = safeTopRef.current;
|
||||
if (!el) return undefined;
|
||||
setSafeTopPx(el.offsetHeight);
|
||||
const ro = new ResizeObserver(() => setSafeTopPx(el.offsetHeight));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Final rail = viewport × fraction (full screen), then floor-then-
|
||||
// ceiling clamped. Container minus the status-bar inset is the HARD
|
||||
// ceiling: the sheet's flat top lands flush at the bottom of the
|
||||
// status bar (no void gap / breathing), covering the whole chat column
|
||||
// down to just under the tray; when the wrapper shrinks (call rail /
|
||||
// split-screen) it shrinks below `RAIL_MIN_PX` rather than spilling
|
||||
// over the tray. Order matters — `Math.min` last makes container win.
|
||||
const idealRailPx = Math.round(viewportHeight * RAIL_VIEWPORT_FRACTION);
|
||||
const railHeightPx = Math.min(
|
||||
Math.max(0, containerHeight - CHAT_HEADER_RESERVED_PX),
|
||||
Math.max(0, containerHeight - safeTopPx),
|
||||
Math.max(RAIL_MIN_PX, idealRailPx)
|
||||
);
|
||||
const open = !!entry;
|
||||
|
|
@ -172,9 +170,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
|||
const expandedPx = drag
|
||||
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
||||
: baseExpanded;
|
||||
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
||||
const isDragging = drag !== null;
|
||||
const horseshoeActive = expandedPx > 0;
|
||||
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -208,9 +204,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
|||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable)
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -307,30 +301,19 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Geometry — same emerge ramp as the settings / profile horseshoes
|
||||
// (rationale at the top of `MobileSettingsHorseshoe`).
|
||||
let horseshoeRamp: number;
|
||||
if (isDragging) {
|
||||
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
|
||||
} else {
|
||||
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
|
||||
}
|
||||
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||
const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||
const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
|
||||
const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
|
||||
// Geometry — the media viewer is fullscreen, so (unlike the settings /
|
||||
// profile horseshoes) it carves NO rounded top corners and NO void gap
|
||||
// above the sheet: the sheet's flat top butts directly against the chat
|
||||
// it clips (which now fills only the status-bar strip above it). Chat bg
|
||||
// and sheet bg are the same Dawn tone, so there's no black void seam at
|
||||
// the top — the viewer reads as a flush fullscreen panel under the tray.
|
||||
const appBodyMaskBottomPx = expandedPx;
|
||||
|
||||
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
|
||||
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px)`;
|
||||
|
||||
const silhouetteTransition = isDragging
|
||||
? 'none'
|
||||
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
|
||||
};
|
||||
|
||||
const renderEntry = entry ?? lastEntryRef.current;
|
||||
const renderBody = !!renderEntry && (keepMounted || isDragging);
|
||||
|
||||
|
|
@ -344,7 +327,22 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
|||
: null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={css.container} style={containerStyle}>
|
||||
<div ref={containerRef} className={css.container}>
|
||||
{/* Zero-width probe — its height resolves `env(safe-area-inset-top)`
|
||||
so the JS rail-height calc can keep the sheet below the tray. */}
|
||||
<div
|
||||
ref={safeTopRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 'var(--vojo-safe-top, 0px)',
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
}}
|
||||
/>
|
||||
{open && portalTarget
|
||||
? createPortal(
|
||||
<div
|
||||
|
|
@ -370,8 +368,6 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
|||
className={css.silhouette}
|
||||
style={{
|
||||
height: `${expandedPx}px`,
|
||||
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
|
||||
borderTopRightRadius: `${silhouetteRadiusPx}px`,
|
||||
transition: silhouetteTransition,
|
||||
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
||||
// Reset `--vojo-safe-top` so any descendant that paints a
|
||||
|
|
|
|||
|
|
@ -25,9 +25,8 @@ import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
|
|||
import { RoomViewMembersPanel } from './RoomViewMembersPanel';
|
||||
import { RoomViewMembersSidePanel } from './RoomViewMembersSidePanel';
|
||||
import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
|
||||
import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel';
|
||||
import { RoomViewMediaWindow } from './RoomViewMediaWindow';
|
||||
import { MediaViewerHostContext } from './mediaViewerHostContext';
|
||||
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
|
||||
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
|
||||
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
|
||||
|
|
@ -133,16 +132,12 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
// PageRoot. The chat column applies an explicit Background bg so the
|
||||
// parent void can't bleed through any transparent slivers.
|
||||
const profileOpen = !!useAtomValue(userRoomProfileAtom);
|
||||
const mediaOpen = !!useAtomValue(mediaViewerAtom);
|
||||
const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom);
|
||||
const callView = room.isCallRoom();
|
||||
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
||||
// Media viewer side pane on desktop — same horseshoe seam idiom
|
||||
// as the profile pane. The two are mutually exclusive in practice
|
||||
// (the open hooks clear the other atom), so at most one shows at
|
||||
// a time; both feed into `paintParentVoid` for the chat-column bg
|
||||
// and locally gate the shared `<VoidGap />` seam.
|
||||
const showMediaHorseshoe = mediaOpen && !isMobile && !showThreadDrawer;
|
||||
// The desktop/tablet media viewer is no longer a right-side pane — it's
|
||||
// a free-floating draggable window (`RoomViewMediaWindow`) portaled over
|
||||
// the app, so it doesn't carve a horseshoe seam or paint the parent void.
|
||||
// Thread drawer side pane on desktop. Mobile hides the chat column
|
||||
// entirely (`drawerHidesChat`) so the seam doesn't apply there.
|
||||
const showThreadHorseshoe = showThreadDrawer && !isMobile;
|
||||
|
|
@ -168,8 +163,7 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
// remain local to each render site (a single `paintParentVoid` gate
|
||||
// would over-render when only members is open — there is no
|
||||
// profile/media slot to anchor it to).
|
||||
const paintParentVoid =
|
||||
showProfileHorseshoe || showMediaHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
|
||||
const paintParentVoid = showProfileHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
|
|
@ -193,12 +187,13 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
);
|
||||
|
||||
// Disable the atom-driven media viewer when the desktop thread
|
||||
// drawer is open — the side-pane mount block below is gated on
|
||||
// `!showThreadDrawer`, so the new viewer's right pane wouldn't
|
||||
// render, and an atom-only open path would silently no-op on the
|
||||
// user's tap. With the host context cleared, `ImageContent` /
|
||||
// `VideoContent` fall back to the legacy full-screen `<Overlay>`
|
||||
// modal, which sits on top of every pane and still works.
|
||||
// drawer is open — with the host context cleared, a timeline
|
||||
// image/video tap falls back to the legacy full-screen `<Overlay>`
|
||||
// modal instead of opening a floating window on top of the drawer
|
||||
// (the `<Overlay>` sits above every pane and still works). NB: an
|
||||
// already-open floating window is NOT torn down by this — the window
|
||||
// mounts for all non-mobile screens and stays visible over the
|
||||
// drawer; only NEW taps are routed to the legacy modal.
|
||||
// Mobile is unaffected: `drawerHidesChat` hides the chat column
|
||||
// entirely on mobile + thread, so there's no media to tap anyway.
|
||||
//
|
||||
|
|
@ -283,11 +278,18 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
exclusive via the open hooks. */}
|
||||
{!isMobile && !showThreadDrawer && (
|
||||
<>
|
||||
{(showProfileHorseshoe || showMediaHorseshoe) && <VoidGap />}
|
||||
{showProfileHorseshoe && <VoidGap />}
|
||||
<RoomViewProfileSidePanel />
|
||||
<RoomViewMediaSidePanel />
|
||||
</>
|
||||
)}
|
||||
{/* Desktop / tablet media viewer — a free-floating draggable
|
||||
window portaled over the app (replaces the old right-side
|
||||
pane). It self-gates on `mediaViewerAtom`, so mount it for
|
||||
every non-mobile screen — unconditionally on the thread
|
||||
drawer too, so a window opened before the drawer stays
|
||||
visible over it (opening NEW media during the drawer is
|
||||
still routed to the legacy modal — see `mediaHostValue`). */}
|
||||
{!isMobile && <RoomViewMediaWindow />}
|
||||
|
||||
{callView && chat && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, toRem } from 'folds';
|
||||
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||
|
||||
// Right-side media pane. Width is owned by the component (resizable,
|
||||
// localStorage-backed via `mediaSidePanelWidthAtom`); no `width` here
|
||||
// so the inline `style={{ width }}` wins without specificity fights.
|
||||
// `overflow: visible` so the absolutely-positioned resize handle in
|
||||
// the void gap to the left isn't clipped — symmetric with
|
||||
// `ThreadDrawerResizable`.
|
||||
//
|
||||
// Left edge rounded (TL + BL) to carve across the 12px horseshoe
|
||||
// void gap rendered by `Room.tsx` — same design language as the
|
||||
// profile pane and the page-nav <-> chat split.
|
||||
//
|
||||
// `Background.Container` (#0d0e11) chosen for the dark image
|
||||
// backdrop — same logic as the mobile silhouette bg.
|
||||
export const panel = style({
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
});
|
||||
|
||||
// Inner content surface that actually paints the panel bg + rounded
|
||||
// carves. Separate from the outer `panel` so the resize handle (which
|
||||
// sits OUTSIDE the panel's left edge, inside the horseshoe void) isn't
|
||||
// clipped by `overflow: hidden`.
|
||||
export const inner = style({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
backgroundColor: color.Background.Container,
|
||||
overflow: 'hidden',
|
||||
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||
borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||
});
|
||||
|
||||
// Body fills the remaining vertical space below `MediaViewerBody`'s
|
||||
// own header. The pane has no separate `PageHeader` strip — the
|
||||
// viewer body owns its chrome (close button, title, action row) so
|
||||
// the header sits flush with the rounded TL corner.
|
||||
export const body = style({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
// Resize handle mirror of `ThreadDrawerResizeHandle` — sits in the
|
||||
// 12px horseshoe void to the LEFT of the panel. Same indicator-bar
|
||||
// cosmetics so the two resizable right panes feel identical.
|
||||
export const resizeHandle = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `-${toRem(VOJO_HORSESHOE_GAP_PX)}`,
|
||||
width: toRem(VOJO_HORSESHOE_GAP_PX),
|
||||
cursor: 'col-resize',
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
touchAction: 'none',
|
||||
outline: 'none',
|
||||
selectors: {
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: 2,
|
||||
height: toRem(36),
|
||||
transform: 'translate(-50%, -50%)',
|
||||
borderRadius: 1,
|
||||
backgroundColor: color.Surface.OnContainer,
|
||||
opacity: 0,
|
||||
transition:
|
||||
'opacity 140ms ease, height 140ms ease, width 140ms ease, background-color 140ms ease',
|
||||
},
|
||||
'&:hover::before, &:focus-visible::before': {
|
||||
opacity: 0.25,
|
||||
},
|
||||
'&:focus-visible::before': {
|
||||
backgroundColor: color.Primary.Main,
|
||||
opacity: 0.45,
|
||||
},
|
||||
'&[data-dragging="true"]::before': {
|
||||
opacity: 0.55,
|
||||
height: toRem(48),
|
||||
backgroundColor: color.Primary.Main,
|
||||
},
|
||||
'&[data-dragging="true"][data-at-min="true"]::before': {
|
||||
height: toRem(28),
|
||||
width: 3,
|
||||
opacity: 0.85,
|
||||
},
|
||||
'&[data-dragging="true"][data-at-max="true"]::before': {
|
||||
height: toRem(76),
|
||||
width: 2,
|
||||
opacity: 0.9,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
// Desktop / tablet right-side media pane. Renders the same
|
||||
// `MediaViewerBody` the mobile bottom-up horseshoe shows, but as a
|
||||
// flex sibling next to the chat column instead of a slide-up rail.
|
||||
//
|
||||
// Width is resizable on the left edge — mirror of `ThreadDrawer`'s
|
||||
// desktop variant. Saved width lives in `mediaSidePanelWidthAtom`
|
||||
// (localStorage); the max is computed «smart»: viewport minus the
|
||||
// left rail + chat-list column (its own saved width) + two horseshoe
|
||||
// void gaps + a chat-column reservation. So dragging the media pane
|
||||
// wider only eats slack, never squeezes the chat below readability.
|
||||
//
|
||||
// Mounted in `Room.tsx` only on non-mobile screens; mobile uses
|
||||
// `MobileMediaViewerHorseshoe` instead.
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { MediaViewerBody } from './MediaViewerBody';
|
||||
import {
|
||||
MEDIA_SIDE_PANEL_WIDTH_MIN,
|
||||
clampMediaSidePanelWidth,
|
||||
computeMediaSidePanelMax,
|
||||
mediaSidePanelWidthAtom,
|
||||
sidebarWidthAtom,
|
||||
} from '../../state/mediaSidePanelWidth';
|
||||
import * as css from './RoomViewMediaSidePanel.css';
|
||||
|
||||
export function RoomViewMediaSidePanel() {
|
||||
const { t } = useTranslation();
|
||||
const entry = useAtomValue(mediaViewerAtom);
|
||||
const close = useCloseMediaViewer();
|
||||
|
||||
const open = !!entry;
|
||||
|
||||
const entryRef = useRef(entry);
|
||||
entryRef.current = entry;
|
||||
|
||||
const [savedWidth, setSavedWidth] = useAtom(mediaSidePanelWidthAtom);
|
||||
const pageNavWidth = useAtomValue(sidebarWidthAtom);
|
||||
const [vw, setVw] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1280);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [liveWidth, setLiveWidth] = useState<number | null>(null);
|
||||
|
||||
const resizableRef = useRef<HTMLDivElement>(null);
|
||||
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setVw(window.innerWidth);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const maxW = computeMediaSidePanelMax(vw, pageNavWidth);
|
||||
const baseWidth = dragging && liveWidth !== null ? liveWidth : savedWidth;
|
||||
const panelWidth = clampMediaSidePanelWidth(baseWidth, vw, pageNavWidth);
|
||||
const canResize = maxW > MEDIA_SIDE_PANEL_WIDTH_MIN;
|
||||
const atMin = dragging && liveWidth !== null && liveWidth <= MEDIA_SIDE_PANEL_WIDTH_MIN;
|
||||
const atMax = dragging && liveWidth !== null && liveWidth >= maxW;
|
||||
|
||||
// Body-style cleanup if the drag terminates without `pointerup`
|
||||
// (unmount mid-drag — entry cleared, route change, mobile flip).
|
||||
// Without this the page can get stuck with col-resize cursor.
|
||||
useEffect(() => {
|
||||
if (!dragging) return undefined;
|
||||
return () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
const beginDrag = useCallback((pointerId: number) => {
|
||||
setDragging(true);
|
||||
setLiveWidth(null);
|
||||
try {
|
||||
resizeHandleRef.current?.setPointerCapture(pointerId);
|
||||
} catch {
|
||||
/* setPointerCapture is best-effort */
|
||||
}
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragging(false);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setLiveWidth((current) => {
|
||||
if (current !== null) {
|
||||
setSavedWidth(clampMediaSidePanelWidth(current, window.innerWidth, pageNavWidth));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, [setSavedWidth, pageNavWidth]);
|
||||
|
||||
const onResizePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 || !canResize) return;
|
||||
e.preventDefault();
|
||||
beginDrag(e.pointerId);
|
||||
},
|
||||
[beginDrag, canResize]
|
||||
);
|
||||
|
||||
const onResizePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging || !resizableRef.current) return;
|
||||
// Right edge of the wrapper is anchored at the row's right edge
|
||||
// (panel is the last item in Room.tsx's flex row). Width grows
|
||||
// as the pointer moves LEFT, shrinks as it moves right — inverse
|
||||
// of the page-nav handle.
|
||||
const rect = resizableRef.current.getBoundingClientRect();
|
||||
setLiveWidth(
|
||||
clampMediaSidePanelWidth(rect.right - e.clientX, window.innerWidth, pageNavWidth)
|
||||
);
|
||||
},
|
||||
[dragging, pageNavWidth]
|
||||
);
|
||||
|
||||
const onResizeStopPointer = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
try {
|
||||
resizeHandleRef.current?.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* releasePointerCapture is best-effort */
|
||||
}
|
||||
endDrag();
|
||||
},
|
||||
[endDrag]
|
||||
);
|
||||
|
||||
const onResizeKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!canResize) return;
|
||||
const step = e.shiftKey ? 64 : 16;
|
||||
let next: number | null = null;
|
||||
// ArrowLeft grows the panel (its left edge moves left → width
|
||||
// increases), ArrowRight shrinks. Home → max, End → min.
|
||||
if (e.key === 'ArrowLeft') next = savedWidth + step;
|
||||
else if (e.key === 'ArrowRight') next = savedWidth - step;
|
||||
else if (e.key === 'Home') next = maxW;
|
||||
else if (e.key === 'End') next = MEDIA_SIDE_PANEL_WIDTH_MIN;
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
setSavedWidth(clampMediaSidePanelWidth(next, vw, pageNavWidth));
|
||||
},
|
||||
[savedWidth, vw, pageNavWidth, maxW, canResize, setSavedWidth]
|
||||
);
|
||||
|
||||
if (!open || !entry) return null;
|
||||
|
||||
return (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
// Move focus into the trap on open so screen readers
|
||||
// announce the dialog and keyboard arrow keys / Esc reach
|
||||
// the viewer body. Falls back to first focusable element
|
||||
// (the close button in the body header) without us pinning
|
||||
// a specific ref — simpler than threading one down.
|
||||
initialFocus: undefined,
|
||||
// Outside clicks pass through to chat (`allowOutsideClick`)
|
||||
// but don't close — clicking around in chat to scroll /
|
||||
// compose shouldn't dismiss the viewer. Explicit close via
|
||||
// the body's × button or `Esc`. Mirror of the profile side
|
||||
// pane's stance.
|
||||
clickOutsideDeactivates: false,
|
||||
allowOutsideClick: () => true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
onDeactivate: () => {
|
||||
if (entryRef.current) close();
|
||||
},
|
||||
checkCanFocusTrap: () => Promise.resolve(),
|
||||
}}
|
||||
active={open}
|
||||
>
|
||||
<div
|
||||
ref={resizableRef}
|
||||
className={css.panel}
|
||||
style={{ width: panelWidth }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={entry.body || t('MediaViewer.title', 'Media viewer')}
|
||||
>
|
||||
<div className={css.inner}>
|
||||
<div className={css.body}>
|
||||
<MediaViewerBody entry={entry} requestClose={close} />
|
||||
</div>
|
||||
</div>
|
||||
{canResize && (
|
||||
// Canonical WAI-ARIA window-splitter pattern (focusable
|
||||
// separator with current/min/max). See ThreadDrawer for the
|
||||
// same justification.
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/role-supports-aria-props, jsx-a11y/no-noninteractive-tabindex
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={panelWidth}
|
||||
aria-valuemin={MEDIA_SIDE_PANEL_WIDTH_MIN}
|
||||
aria-valuemax={maxW}
|
||||
aria-label={t('MediaViewer.resize', 'Resize media viewer')}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={css.resizeHandle}
|
||||
data-dragging={dragging || undefined}
|
||||
data-at-min={atMin || undefined}
|
||||
data-at-max={atMax || undefined}
|
||||
onPointerDown={onResizePointerDown}
|
||||
onPointerMove={onResizePointerMove}
|
||||
onPointerUp={onResizeStopPointer}
|
||||
onPointerCancel={onResizeStopPointer}
|
||||
onLostPointerCapture={onResizeStopPointer}
|
||||
onKeyDown={onResizeKeyDown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
}
|
||||
94
src/app/features/room/RoomViewMediaWindow.css.ts
Normal file
94
src/app/features/room/RoomViewMediaWindow.css.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
// Desktop / tablet floating media window — a draggable overlay that
|
||||
// sits ON TOP of the app (portaled to #portalContainer) instead of the
|
||||
// old right-side flex pane. Position + size are owned by the component
|
||||
// (inline `style`); chrome is deliberately minimal: a thin drag bar +
|
||||
// close, plus a subtle resize grip. Zoom is wheel-only, navigation is
|
||||
// keyboard / hover-chevrons (both live in `MediaViewerBody`).
|
||||
export const floating = style({
|
||||
position: 'fixed',
|
||||
// Above normal app chrome (call rail, headers); folds modals/overlays
|
||||
// still portal above this (zIndex.Max), which is correct for dialogs
|
||||
// over the window.
|
||||
zIndex: config.zIndex.Z400,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
borderRadius: toRem(12),
|
||||
overflow: 'hidden',
|
||||
boxShadow: config.shadow.E400,
|
||||
border: `1px solid ${color.SurfaceVariant.Container}`,
|
||||
});
|
||||
|
||||
// The only drag-to-move origin. `touch-action: none` so a touch-drag on
|
||||
// a tablet moves the window instead of scrolling the page behind.
|
||||
export const dragBar = style({
|
||||
flexShrink: 0,
|
||||
height: toRem(32),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
paddingLeft: config.space.S300,
|
||||
paddingRight: config.space.S100,
|
||||
cursor: 'move',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderBottom: `1px solid ${color.SurfaceVariant.Container}`,
|
||||
});
|
||||
|
||||
// Faint filename — present but unobtrusive; doubles as part of the
|
||||
// grab surface.
|
||||
export const title = style({
|
||||
flex: '1 1 auto',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: toRem(12),
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
export const body = style({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
// Subtle bottom-right corner resize affordance — no button, just a
|
||||
// draggable corner that brightens on hover.
|
||||
export const resizeGrip = style({
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: toRem(20),
|
||||
height: toRem(20),
|
||||
cursor: 'nwse-resize',
|
||||
touchAction: 'none',
|
||||
zIndex: 3,
|
||||
selectors: {
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
right: toRem(3),
|
||||
bottom: toRem(3),
|
||||
width: toRem(9),
|
||||
height: toRem(9),
|
||||
borderRight: `2px solid ${color.Surface.OnContainer}`,
|
||||
borderBottom: `2px solid ${color.Surface.OnContainer}`,
|
||||
borderBottomRightRadius: toRem(3),
|
||||
opacity: 0.25,
|
||||
transition: 'opacity 140ms ease',
|
||||
},
|
||||
'&:hover::after': { opacity: 0.6 },
|
||||
},
|
||||
});
|
||||
243
src/app/features/room/RoomViewMediaWindow.tsx
Normal file
243
src/app/features/room/RoomViewMediaWindow.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Desktop / tablet media viewer — a free-floating, draggable window that
|
||||
// renders ON TOP of the app instead of the old right-side flex pane. It
|
||||
// reuses `MediaViewerBody` (resolve/decrypt, zoom, pan, swipe, prev/next)
|
||||
// with `hideHeader` so the only chrome is a thin drag bar + close button,
|
||||
// plus a subtle corner resize grip. Portaled to #portalContainer so no
|
||||
// ancestor transform/overflow can clip it.
|
||||
//
|
||||
// Mounted in `Room.tsx` for non-mobile screens; mobile uses
|
||||
// `MobileMediaViewerHorseshoe`.
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, IconButton, Icons } from 'folds';
|
||||
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import {
|
||||
MediaWindowRect,
|
||||
clampMediaWindowRect,
|
||||
defaultMediaWindowRect,
|
||||
mediaWindowRectAtom,
|
||||
} from '../../state/mediaWindowRect';
|
||||
import { MediaViewerBody } from './MediaViewerBody';
|
||||
import * as css from './RoomViewMediaWindow.css';
|
||||
|
||||
type GestureMode = 'move' | 'resize';
|
||||
|
||||
export function RoomViewMediaWindow() {
|
||||
const { t } = useTranslation();
|
||||
const entry = useAtomValue(mediaViewerAtom);
|
||||
const close = useCloseMediaViewer();
|
||||
const room = useRoom();
|
||||
const [savedRect, setSavedRect] = useAtom(mediaWindowRectAtom);
|
||||
|
||||
const open = !!entry;
|
||||
|
||||
const [viewport, setViewport] = useState(() => ({
|
||||
w: typeof window === 'undefined' ? 1280 : window.innerWidth,
|
||||
h: typeof window === 'undefined' ? 800 : window.innerHeight,
|
||||
}));
|
||||
useEffect(() => {
|
||||
const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight });
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// Live window geometry. Seeded from the saved rect (clamped) or a
|
||||
// centred default the first time the window opens, then mutated by
|
||||
// drag / resize and re-clamped on viewport resize.
|
||||
const [rect, setRect] = useState<MediaWindowRect | null>(null);
|
||||
|
||||
const wasOpenRef = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (open && !wasOpenRef.current) {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
// Route BOTH branches through the clamp so the centred default is
|
||||
// floored to the min size too (defaultMediaWindowRect alone can dip
|
||||
// below MEDIA_WINDOW_MIN_H on a very short viewport).
|
||||
setRect(clampMediaWindowRect(savedRect ?? defaultMediaWindowRect(vw, vh), vw, vh));
|
||||
}
|
||||
wasOpenRef.current = open;
|
||||
}, [open, savedRect]);
|
||||
|
||||
// Re-clamp into the viewport when it shrinks while the window is open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setRect((prev) => (prev ? clampMediaWindowRect(prev, viewport.w, viewport.h) : prev));
|
||||
}, [open, viewport.w, viewport.h]);
|
||||
|
||||
// Drag (move) + resize share one gesture, distinguished by `mode`.
|
||||
// We listen on `document` (added on pointerdown, removed on pointerup)
|
||||
// so the gesture keeps tracking even when the cursor leaves the small
|
||||
// drag bar / grip. `startRect` is snapshotted so each move computes an
|
||||
// absolute delta from the gesture origin (no drift accumulation).
|
||||
const gestureRef = useRef<{
|
||||
mode: GestureMode;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startRect: MediaWindowRect;
|
||||
} | null>(null);
|
||||
|
||||
const persist = useCallback(
|
||||
(r: MediaWindowRect) => {
|
||||
setSavedRect(clampMediaWindowRect(r, window.innerWidth, window.innerHeight));
|
||||
},
|
||||
[setSavedRect]
|
||||
);
|
||||
|
||||
const onGesturePointerMove = useCallback((e: PointerEvent) => {
|
||||
const g = gestureRef.current;
|
||||
if (!g) return;
|
||||
e.preventDefault();
|
||||
const dx = e.clientX - g.startX;
|
||||
const dy = e.clientY - g.startY;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
if (g.mode === 'move') {
|
||||
setRect(
|
||||
clampMediaWindowRect(
|
||||
{ ...g.startRect, x: g.startRect.x + dx, y: g.startRect.y + dy },
|
||||
vw,
|
||||
vh
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Resize from the bottom-right grip (x/y fixed): cap growth to the
|
||||
// space available to the right / below the window's origin so the
|
||||
// grip can't be pushed off-screen and become unreachable.
|
||||
setRect(
|
||||
clampMediaWindowRect(
|
||||
{
|
||||
...g.startRect,
|
||||
w: Math.min(g.startRect.w + dx, vw - g.startRect.x),
|
||||
h: Math.min(g.startRect.h + dy, vh - g.startRect.y),
|
||||
},
|
||||
vw,
|
||||
vh
|
||||
)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onGesturePointerUp = useCallback(() => {
|
||||
gestureRef.current = null;
|
||||
document.removeEventListener('pointermove', onGesturePointerMove);
|
||||
document.removeEventListener('pointerup', onGesturePointerUp);
|
||||
document.removeEventListener('pointercancel', onGesturePointerUp);
|
||||
setRect((prev) => {
|
||||
if (prev) persist(prev);
|
||||
return prev;
|
||||
});
|
||||
}, [onGesturePointerMove, persist]);
|
||||
|
||||
const beginGesture = useCallback(
|
||||
(mode: GestureMode, e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!rect || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
gestureRef.current = { mode, startX: e.clientX, startY: e.clientY, startRect: rect };
|
||||
document.addEventListener('pointermove', onGesturePointerMove);
|
||||
document.addEventListener('pointerup', onGesturePointerUp);
|
||||
// A cancelled pointer (tablet edge-gesture, palm rejection, OS
|
||||
// scroll takeover) fires pointercancel instead of pointerup — end
|
||||
// the gesture the same way so it can't strand + leak listeners.
|
||||
document.addEventListener('pointercancel', onGesturePointerUp);
|
||||
},
|
||||
[rect, onGesturePointerMove, onGesturePointerUp]
|
||||
);
|
||||
|
||||
// Belt-and-suspenders: drop any in-flight document listeners if the
|
||||
// window unmounts mid-drag (route change, atom cleared).
|
||||
useEffect(
|
||||
() => () => {
|
||||
document.removeEventListener('pointermove', onGesturePointerMove);
|
||||
document.removeEventListener('pointerup', onGesturePointerUp);
|
||||
document.removeEventListener('pointercancel', onGesturePointerUp);
|
||||
},
|
||||
[onGesturePointerMove, onGesturePointerUp]
|
||||
);
|
||||
|
||||
// Esc closes (the body owns arrows / +/- / 0; it deliberately leaves
|
||||
// Esc to the host so each shell can decide its own dismiss policy).
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
const entryRef = useRef(entry);
|
||||
entryRef.current = entry;
|
||||
|
||||
// Clear the global media atom when the room changes (this window lives
|
||||
// under a `key={room.roomId}` RoomProvider, so a room switch unmounts
|
||||
// it). Without this the next room would re-seed the window and float
|
||||
// the previous room's media over it with dead prev/next (currentIndex
|
||||
// = -1). Mirrors RoomViewProfileSidePanel + the mobile horseshoe. Refs
|
||||
// so the cleanup doesn't re-fire on every prev/next entry step.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (entryRef.current) closeRef.current();
|
||||
},
|
||||
[room.roomId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
closeRef.current();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open]);
|
||||
|
||||
if (!open || !entry || !rect) return null;
|
||||
|
||||
const portalTarget =
|
||||
typeof document !== 'undefined'
|
||||
? document.getElementById('portalContainer') ?? document.body
|
||||
: null;
|
||||
if (!portalTarget) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={css.floating}
|
||||
style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
|
||||
role="dialog"
|
||||
aria-label={entry.body || t('MediaViewer.title', 'Media viewer')}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div className={css.dragBar} onPointerDown={(e) => beginGesture('move', e)}>
|
||||
<span className={css.title}>{entry.body}</span>
|
||||
<IconButton
|
||||
variant="Background"
|
||||
fill="None"
|
||||
radii="300"
|
||||
size="300"
|
||||
// Don't let a click on the close button start a window drag.
|
||||
onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
|
||||
onClick={close}
|
||||
aria-label={t('Common.close', 'Close')}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
<MediaViewerBody entry={entry} requestClose={close} hideHeader />
|
||||
</div>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={css.resizeGrip}
|
||||
aria-hidden="true"
|
||||
onPointerDown={(e) => beginGesture('resize', e)}
|
||||
/>
|
||||
</div>,
|
||||
portalTarget
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
// Non-null only when an atom-driven media-viewer shell (the mobile
|
||||
// bottom-up horseshoe or the desktop right side pane) is mounted in
|
||||
// bottom-up horseshoe or the desktop/tablet floating window
|
||||
// `RoomViewMediaWindow`) is mounted in
|
||||
// the React tree above this consumer. `ImageContent` reads this to
|
||||
// decide between the new atom-open path (Room timeline) and the
|
||||
// legacy local-state `<Overlay>` viewer (pin-menu, message search
|
||||
|
|
@ -16,5 +17,4 @@ export type MediaViewerHostValue = { roomId: string } | null;
|
|||
|
||||
export const MediaViewerHostContext = createContext<MediaViewerHostValue>(null);
|
||||
|
||||
export const useMediaViewerHost = (): MediaViewerHostValue =>
|
||||
useContext(MediaViewerHostContext);
|
||||
export const useMediaViewerHost = (): MediaViewerHostValue => useContext(MediaViewerHostContext);
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ type OpenCallback = (
|
|||
export const useOpenUserRoomProfile = (): OpenCallback => {
|
||||
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
|
||||
// Bidirectional mutual exclusion with the media viewer — opening
|
||||
// profile closes any open media viewer pane / sheet. Mirrors the
|
||||
// clear-the-other-atom behaviour in `useOpenMediaViewer`. Without
|
||||
// this, on desktop both `RoomViewProfileSidePanel` and
|
||||
// `RoomViewMediaSidePanel` would mount as siblings and fight for
|
||||
// the right-pane slot.
|
||||
// profile closes any open media viewer window / sheet. Mirrors the
|
||||
// clear-the-other-atom behaviour in `useOpenMediaViewer`. The media
|
||||
// viewer is a floating window (`RoomViewMediaWindow`) on desktop now,
|
||||
// but keeping them mutually exclusive avoids a profile pane fighting
|
||||
// the window for attention.
|
||||
const setMediaViewer = useSetAtom(mediaViewerAtom);
|
||||
// Symmetric mutual exclusion with the group-room members sheet — when
|
||||
// the user taps a row inside the members list, the per-user profile
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
import {
|
||||
atomWithLocalStorage,
|
||||
getLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from './utils/atomWithLocalStorage';
|
||||
import { sidebarWidthAtom } from './sidebarWidth';
|
||||
|
||||
export const MEDIA_SIDE_PANEL_WIDTH_KEY = 'vojo_media_side_panel_width';
|
||||
// Lowest readable width for `MediaViewerBody`'s media card + action row.
|
||||
// Below ~360 the file-name truncates aggressively and the action row
|
||||
// starts wrapping.
|
||||
export const MEDIA_SIDE_PANEL_WIDTH_MIN = 360;
|
||||
// Pleasant starting width on a typical 1440-wide desktop after the
|
||||
// chat-list column at its default 416 and the chat column reserve.
|
||||
export const MEDIA_SIDE_PANEL_WIDTH_DEFAULT = 520;
|
||||
// Hard ceiling: even on ultra-wide displays the media pane shouldn't
|
||||
// dwarf the chat — matches the prior CSS `clamp(..., ..., 880px)` cap.
|
||||
export const MEDIA_SIDE_PANEL_WIDTH_HARD_MAX = 880;
|
||||
|
||||
// Layout constants the smart-max math depends on. Kept here (not
|
||||
// imported from horseshoe.ts / Sidebar.css.ts) so the clamp helper
|
||||
// stays a pure function safe to call from anywhere — including a
|
||||
// re-mount race where the CSS-module hasn't bound yet.
|
||||
const SIDEBAR_RAIL_PX = 66; // global icon rail, fixed
|
||||
const VOID_GAP_PX = 12; // VOJO_HORSESHOE_GAP_PX (page-nav<->chat and chat<->media)
|
||||
// Minimum width to reserve for the chat column when media is open.
|
||||
// Below ~360 the timeline text gets line-broken to single words and
|
||||
// reading falls apart — the whole point of this clamp is to protect
|
||||
// that reading area, not the media pane.
|
||||
const CHAT_COLUMN_RESERVED_PX = 360;
|
||||
|
||||
const readMediaSidePanelWidth = (key: string): number => {
|
||||
const raw = getLocalStorageItem<unknown>(key, MEDIA_SIDE_PANEL_WIDTH_DEFAULT);
|
||||
const value =
|
||||
typeof raw === 'number' && Number.isFinite(raw) ? raw : MEDIA_SIDE_PANEL_WIDTH_DEFAULT;
|
||||
return Math.max(MEDIA_SIDE_PANEL_WIDTH_MIN, Math.round(value));
|
||||
};
|
||||
|
||||
export const mediaSidePanelWidthAtom = atomWithLocalStorage<number>(
|
||||
MEDIA_SIDE_PANEL_WIDTH_KEY,
|
||||
readMediaSidePanelWidth,
|
||||
setLocalStorageItem
|
||||
);
|
||||
|
||||
// Smart maximum: viewport minus (left rail + chat-list column + two
|
||||
// horseshoe void gaps + chat-column reservation). Both the chat-list
|
||||
// and the chat-column are protected — dragging media wider can only
|
||||
// eat the slack between them and the hard cap. If even the reservation
|
||||
// can't fit (tiny laptop / virtual viewport), the max collapses to
|
||||
// MIN so the panel still mounts at a sane size.
|
||||
export const computeMediaSidePanelMax = (viewportWidth: number, pageNavWidth: number): number => {
|
||||
const reserved = SIDEBAR_RAIL_PX + pageNavWidth + VOID_GAP_PX * 2 + CHAT_COLUMN_RESERVED_PX;
|
||||
const available = viewportWidth - reserved;
|
||||
return Math.max(MEDIA_SIDE_PANEL_WIDTH_MIN, Math.min(MEDIA_SIDE_PANEL_WIDTH_HARD_MAX, available));
|
||||
};
|
||||
|
||||
export const clampMediaSidePanelWidth = (
|
||||
px: number,
|
||||
viewportWidth: number,
|
||||
pageNavWidth: number
|
||||
): number => {
|
||||
const max = computeMediaSidePanelMax(viewportWidth, pageNavWidth);
|
||||
return Math.max(MEDIA_SIDE_PANEL_WIDTH_MIN, Math.min(max, Math.round(px)));
|
||||
};
|
||||
|
||||
// Re-export so callers don't have to import `sidebarWidthAtom` from a
|
||||
// separate state file just to feed the clamp helper.
|
||||
export { sidebarWidthAtom };
|
||||
|
|
@ -3,7 +3,8 @@ import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|||
import { IImageInfo, IVideoInfo } from '../../types/matrix/common';
|
||||
|
||||
// Open state for the room media viewer — bottom-up sheet on mobile,
|
||||
// right-side pane on desktop. Mirror of `settingsSheetAtom`. The
|
||||
// floating draggable window on desktop/tablet (RoomViewMediaWindow).
|
||||
// Mirror of `settingsSheetAtom`. The
|
||||
// atom-driven shell is mounted in `Room.tsx`; `ImageContent` /
|
||||
// `VideoContent` open the atom when the host context from
|
||||
// `mediaViewerHostContext` is non-null (Room timeline path);
|
||||
|
|
|
|||
68
src/app/state/mediaWindowRect.ts
Normal file
68
src/app/state/mediaWindowRect.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
atomWithLocalStorage,
|
||||
getLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from './utils/atomWithLocalStorage';
|
||||
|
||||
// Persisted geometry of the desktop/tablet floating media window
|
||||
// (`RoomViewMediaWindow`). Position + size survive reloads; both are
|
||||
// re-clamped into the live viewport on open so a window saved on a big
|
||||
// monitor can't open off-screen on a small one. `null` until the user
|
||||
// has opened the viewer once — the component then centres a default.
|
||||
export const MEDIA_WINDOW_RECT_KEY = 'vojo_media_window_rect';
|
||||
|
||||
export type MediaWindowRect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
// Floors so the window never collapses below a usable media card.
|
||||
export const MEDIA_WINDOW_MIN_W = 320;
|
||||
export const MEDIA_WINDOW_MIN_H = 240;
|
||||
// Always keep at least this much of the window on-screen so the drag
|
||||
// bar stays grabbable no matter where it was dropped.
|
||||
const ON_SCREEN_MARGIN_PX = 80;
|
||||
|
||||
const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
||||
|
||||
const readMediaWindowRect = (key: string): MediaWindowRect | null => {
|
||||
const raw = getLocalStorageItem<unknown>(key, null);
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Partial<MediaWindowRect>;
|
||||
if (
|
||||
!isFiniteNumber(r.x) ||
|
||||
!isFiniteNumber(r.y) ||
|
||||
!isFiniteNumber(r.w) ||
|
||||
!isFiniteNumber(r.h)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { x: r.x, y: r.y, w: r.w, h: r.h };
|
||||
};
|
||||
|
||||
export const mediaWindowRectAtom = atomWithLocalStorage<MediaWindowRect | null>(
|
||||
MEDIA_WINDOW_RECT_KEY,
|
||||
readMediaWindowRect,
|
||||
setLocalStorageItem
|
||||
);
|
||||
|
||||
// Pleasant centred default — roughly half the width, ~70% of the height
|
||||
// of a typical desktop, clamped to the floors.
|
||||
export const defaultMediaWindowRect = (vw: number, vh: number): MediaWindowRect => {
|
||||
const w = Math.round(Math.min(640, Math.max(MEDIA_WINDOW_MIN_W, vw * 0.45)));
|
||||
const h = Math.round(Math.min(Math.round(vh * 0.85), Math.max(MEDIA_WINDOW_MIN_H, vh * 0.7)));
|
||||
return { x: Math.round((vw - w) / 2), y: Math.round((vh - h) / 2), w, h };
|
||||
};
|
||||
|
||||
// Clamp a rect into the viewport: size capped to the viewport and floored,
|
||||
// position kept so a margin always stays on-screen (drag bar grabbable).
|
||||
export const clampMediaWindowRect = (
|
||||
rect: MediaWindowRect,
|
||||
vw: number,
|
||||
vh: number
|
||||
): MediaWindowRect => {
|
||||
const w = Math.round(Math.max(MEDIA_WINDOW_MIN_W, Math.min(rect.w, vw)));
|
||||
const h = Math.round(Math.max(MEDIA_WINDOW_MIN_H, Math.min(rect.h, vh)));
|
||||
const x = Math.round(
|
||||
Math.max(ON_SCREEN_MARGIN_PX - w, Math.min(rect.x, vw - ON_SCREEN_MARGIN_PX))
|
||||
);
|
||||
const y = Math.round(Math.max(0, Math.min(rect.y, Math.max(0, vh - 40))));
|
||||
return { x, y, w, h };
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue