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
8123df0103
commit
03fb01cc7b
3 changed files with 304 additions and 11 deletions
|
|
@ -1,11 +1,13 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, toRem } from 'folds';
|
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 —
|
// Right-side media pane. Width is owned by the component (resizable,
|
||||||
// images need room to read comfortably. `clamp(480px, 50vw, 880px)`
|
// localStorage-backed via `mediaSidePanelWidthAtom`); no `width` here
|
||||||
// gives a generous minimum on narrow desktops while capping width
|
// so the inline `style={{ width }}` wins without specificity fights.
|
||||||
// on ultra-wide displays.
|
// `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
|
// Left edge rounded (TL + BL) to carve across the 12px horseshoe
|
||||||
// void gap rendered by `Room.tsx` — same design language as the
|
// 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
|
// `Background.Container` (#0d0e11) chosen for the dark image
|
||||||
// backdrop — same logic as the mobile silhouette bg.
|
// backdrop — same logic as the mobile silhouette bg.
|
||||||
export const panel = style({
|
export const panel = style({
|
||||||
|
position: 'relative',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: `clamp(${toRem(480)}, 50vw, ${toRem(880)})`,
|
flexGrow: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: color.Background.Container,
|
|
||||||
minHeight: 0,
|
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',
|
overflow: 'hidden',
|
||||||
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||||
borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||||
|
|
@ -36,3 +51,57 @@ export const body = style({
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
minWidth: 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
|
// `MediaViewerBody` the mobile bottom-up horseshoe shows, but as a
|
||||||
// flex sibling next to the chat column instead of a slide-up rail.
|
// 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
|
// Mounted in `Room.tsx` only on non-mobile screens; mobile uses
|
||||||
// `MobileMediaViewerHorseshoe` instead.
|
// `MobileMediaViewerHorseshoe` instead.
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { mediaViewerAtom } from '../../state/mediaViewer';
|
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||||
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { MediaViewerBody } from './MediaViewerBody';
|
import { MediaViewerBody } from './MediaViewerBody';
|
||||||
|
import {
|
||||||
|
MEDIA_SIDE_PANEL_WIDTH_MIN,
|
||||||
|
clampMediaSidePanelWidth,
|
||||||
|
computeMediaSidePanelMax,
|
||||||
|
mediaSidePanelWidthAtom,
|
||||||
|
sidebarWidthAtom,
|
||||||
|
} from '../../state/mediaSidePanelWidth';
|
||||||
import * as css from './RoomViewMediaSidePanel.css';
|
import * as css from './RoomViewMediaSidePanel.css';
|
||||||
|
|
||||||
export function RoomViewMediaSidePanel() {
|
export function RoomViewMediaSidePanel() {
|
||||||
|
|
@ -25,6 +39,117 @@ export function RoomViewMediaSidePanel() {
|
||||||
const entryRef = useRef(entry);
|
const entryRef = useRef(entry);
|
||||||
entryRef.current = 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;
|
if (!open || !entry) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -52,14 +177,45 @@ export function RoomViewMediaSidePanel() {
|
||||||
active={open}
|
active={open}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={resizableRef}
|
||||||
className={css.panel}
|
className={css.panel}
|
||||||
|
style={{ width: panelWidth }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={entry.body || t('MediaViewer.title', 'Media viewer')}
|
aria-label={entry.body || t('MediaViewer.title', 'Media viewer')}
|
||||||
>
|
>
|
||||||
<div className={css.body}>
|
<div className={css.inner}>
|
||||||
<MediaViewerBody entry={entry} requestClose={close} />
|
<div className={css.body}>
|
||||||
|
<MediaViewerBody entry={entry} requestClose={close} />
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</FocusTrap>
|
</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