feat(media): make the desktop right-side media pane resizable with a smart max that subtracts chat-list width and a chat-column reservation

This commit is contained in:
heaven 2026-05-16 20:45:54 +03:00
parent ebf2cfe07b
commit 770609b964
3 changed files with 304 additions and 11 deletions

View file

@ -1,11 +1,13 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Right-side media pane. Much wider than the profile pane —
// images need room to read comfortably. `clamp(480px, 50vw, 880px)`
// gives a generous minimum on narrow desktops while capping width
// on ultra-wide displays.
// 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
@ -14,12 +16,25 @@ import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// `Background.Container` (#0d0e11) chosen for the dark image
// backdrop — same logic as the mobile silhouette bg.
export const panel = style({
position: 'relative',
flexShrink: 0,
width: `clamp(${toRem(480)}, 50vw, ${toRem(880)})`,
flexGrow: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
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),
@ -36,3 +51,57 @@ export const body = style({
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,
},
},
});

View file

@ -2,17 +2,31 @@
// `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, { useRef } from 'react';
import { useAtomValue } from 'jotai';
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() {
@ -25,6 +39,117 @@ export function RoomViewMediaSidePanel() {
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 (
@ -52,14 +177,45 @@ export function RoomViewMediaSidePanel() {
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.body}>
<MediaViewerBody entry={entry} requestClose={close} />
<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>
);

View file

@ -0,0 +1,68 @@
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 };