From 770609b96457c06af6494f6b32a49e7dcb5361a3 Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 16 May 2026 20:45:54 +0300 Subject: [PATCH] feat(media): make the desktop right-side media pane resizable with a smart max that subtracts chat-list width and a chat-column reservation --- .../room/RoomViewMediaSidePanel.css.ts | 83 ++++++++- .../features/room/RoomViewMediaSidePanel.tsx | 164 +++++++++++++++++- src/app/state/mediaSidePanelWidth.ts | 68 ++++++++ 3 files changed, 304 insertions(+), 11 deletions(-) create mode 100644 src/app/state/mediaSidePanelWidth.ts diff --git a/src/app/features/room/RoomViewMediaSidePanel.css.ts b/src/app/features/room/RoomViewMediaSidePanel.css.ts index a7d80426..f559e7a1 100644 --- a/src/app/features/room/RoomViewMediaSidePanel.css.ts +++ b/src/app/features/room/RoomViewMediaSidePanel.css.ts @@ -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, + }, + }, +}); diff --git a/src/app/features/room/RoomViewMediaSidePanel.tsx b/src/app/features/room/RoomViewMediaSidePanel.tsx index 9c6aa246..61161dd9 100644 --- a/src/app/features/room/RoomViewMediaSidePanel.tsx +++ b/src/app/features/room/RoomViewMediaSidePanel.tsx @@ -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(typeof window !== 'undefined' ? window.innerWidth : 1280); + const [dragging, setDragging] = useState(false); + const [liveWidth, setLiveWidth] = useState(null); + + const resizableRef = useRef(null); + const resizeHandleRef = useRef(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) => { + if (e.button !== 0 || !canResize) return; + e.preventDefault(); + beginDrag(e.pointerId); + }, + [beginDrag, canResize] + ); + + const onResizePointerMove = useCallback( + (e: React.PointerEvent) => { + 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) => { + try { + resizeHandleRef.current?.releasePointerCapture(e.pointerId); + } catch { + /* releasePointerCapture is best-effort */ + } + endDrag(); + }, + [endDrag] + ); + + const onResizeKeyDown = useCallback( + (e: React.KeyboardEvent) => { + 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} >
-
- +
+
+ +
+ {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 +
+ )}
); diff --git a/src/app/state/mediaSidePanelWidth.ts b/src/app/state/mediaSidePanelWidth.ts new file mode 100644 index 00000000..6324827a --- /dev/null +++ b/src/app/state/mediaSidePanelWidth.ts @@ -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(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( + 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 };