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:
parent
ebf2cfe07b
commit
770609b964
3 changed files with 304 additions and 11 deletions
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
68
src/app/state/mediaSidePanelWidth.ts
Normal file
68
src/app/state/mediaSidePanelWidth.ts
Normal 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 };
|
||||
Loading…
Add table
Reference in a new issue