From 3dee9f099fcba3afc58148295c94fb1f672c7a68 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 13 May 2026 14:42:15 +0300 Subject: [PATCH] feat(thread-drawer): wrap in horseshoe seam with rounded TL/BL and add pointer/keyboard resize clamped to viewport/3 --- src/app/features/room/Room.tsx | 31 +++- src/app/features/room/ThreadDrawer.css.ts | 93 +++++++++++- src/app/features/room/ThreadDrawer.tsx | 172 +++++++++++++++++++++- src/app/state/threadDrawerWidth.ts | 33 +++++ 4 files changed, 314 insertions(+), 15 deletions(-) create mode 100644 src/app/state/threadDrawerWidth.ts diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 39956cd0..32edb635 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -122,7 +122,11 @@ export function Room({ renderRoomView }: RoomProps) { // a time; both feed into `showAnyHorseshoe` for the chat-column // bg + the void-gap render gate. const showMediaHorseshoe = mediaOpen && !isMobile && !showThreadDrawer; - const showAnyHorseshoe = showProfileHorseshoe || showMediaHorseshoe; + // Thread drawer side pane on desktop. Mobile hides the chat column + // entirely (`drawerHidesChat`) so the seam doesn't apply there. + const showThreadHorseshoe = showThreadDrawer && !isMobile; + const showAnyHorseshoe = + showProfileHorseshoe || showMediaHorseshoe || showThreadHorseshoe; useKeyDown( window, @@ -278,13 +282,24 @@ export function Room({ renderRoomView }: RoomProps) { )} {showThreadDrawer && decodedRootId && parentRoomPath && ( - + <> + {showThreadHorseshoe && ( + + )} + + )} diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index 9674ef64..dae8adf4 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -1,5 +1,80 @@ import { style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; +import { + VOJO_HORSESHOE_GAP_PX, + VOJO_HORSESHOE_RADIUS_PX, +} from '../../styles/horseshoe'; + +// Desktop wrapper for the resizable thread drawer. Sizing and the +// absolutely-positioned resize handle live here; the inner aside +// (`ThreadDrawer` below) owns the rounded TL/BL carves + bg. +// `overflow: visible` on the wrapper is critical — the handle sits +// at `left: -GAP_PX` inside the horseshoe void, and the aside's +// `overflow: hidden` would otherwise clip it. +export const ThreadDrawerResizable = style({ + position: 'relative', + flexShrink: 0, + flexGrow: 0, + maxHeight: '100%', + minHeight: 0, + display: 'flex', + flexDirection: 'column', +}); + +// Resize handle mirror of `PageNavResizeHandle` — sits in the 12px +// horseshoe void to the LEFT of the drawer (page-nav's handle sits in +// the gap to the right of the nav). Same cosmetics: thin indicator bar +// that fades in on hover/focus and stretches/shrinks at the clamps. +export const ThreadDrawerResizeHandle = 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, + }, + }, +}); // Layout copies element-web `_ThreadPanel.pcss:73-84`: // `max-height: 100%` clamps the column to the parent's viewport-bound @@ -7,15 +82,25 @@ import { color, config, toRem } from 'folds'; // under content (otherwise flex-children grow with content and push // the composer off-screen — bug observed on cold-load with many // replies). Comment in element-web: «don't displace the composer». +// +// Horseshoe: rounded TL/BL mirrors the page-nav <-> chat and chat <-> +// profile/media seams. Room.tsx paints the 12px void gap to the left +// of the drawer (`showThreadHorseshoe`) and the chat-column gets an +// explicit Background bg so the void can't bleed through. +// +// Sizing (`width`, `flexShrink`) was moved to `ThreadDrawerResizable` +// so the aside fills its resizable wrapper and the wrapper owns the +// flex-row contract with Room.tsx. export const ThreadDrawer = style({ - flexShrink: 0, - width: `clamp(${toRem(320)}, 28%, ${toRem(420)})`, - maxHeight: '100%', + flexGrow: 1, + width: '100%', display: 'flex', flexDirection: 'column', backgroundColor: color.Surface.Container, - borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, minHeight: 0, + overflow: 'hidden', + borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), + borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), }); // Mobile push: drawer occupies the whole content column, not a side pane. diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 7e38050f..776b1308 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Box, Button, @@ -13,6 +13,7 @@ import { Spinner, Text, config, + toRem, } from 'folds'; import { Direction, @@ -82,6 +83,11 @@ import { roomToParentsAtom } from '../../state/room/roomToParents'; import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; +import { + THREAD_DRAWER_WIDTH_MIN, + clampThreadDrawerWidth, + threadDrawerWidthAtom, +} from '../../state/threadDrawerWidth'; import { factoryRenderLinkifyWithMention, getReactCustomHtmlParser, @@ -154,6 +160,130 @@ export function ThreadDrawer({ const editor = useEditor(); const fileDropContainerRef = useRef(null); const closeBtnRef = useRef(null); + + // Desktop-only resizable side pane. Mirrors `ResizablePageNav` in + // `components/page/Page.tsx`: width persisted to localStorage via + // `threadDrawerWidthAtom`, clamped [MIN, viewport/3]. Mobile variant + // doesn't render the wrapper at all so these hooks are effectively + // no-ops there — they always run (rules-of-hooks) but the ref / DOM + // is never wired up. + const isDesktopVariant = variant === 'desktop'; + const resizableRef = useRef(null); + const resizeHandleRef = useRef(null); + const [savedWidth, setSavedWidth] = useAtom(threadDrawerWidthAtom); + const [vw, setVw] = useState( + typeof window !== 'undefined' ? window.innerWidth : 1280 + ); + const [dragging, setDragging] = useState(false); + // Live width during a drag — kept in component state so we don't + // flush localStorage on every pointermove (hundreds of sync disk + // writes per drag). Atom is committed once on pointerup. + const [liveWidth, setLiveWidth] = useState(null); + + useEffect(() => { + if (!isDesktopVariant) return undefined; + const onResize = () => setVw(window.innerWidth); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [isDesktopVariant]); + + const drawerMaxW = Math.max(THREAD_DRAWER_WIDTH_MIN, Math.floor(vw / 3)); + const drawerBaseWidth = dragging && liveWidth !== null ? liveWidth : savedWidth; + const drawerWidth = clampThreadDrawerWidth(drawerBaseWidth, vw); + // Viewports too narrow for any meaningful range (max == min) hide the + // handle entirely — leaving a non-draggable handle reads as broken UI. + const canResize = isDesktopVariant && drawerMaxW > THREAD_DRAWER_WIDTH_MIN; + const atMin = + dragging && liveWidth !== null && liveWidth <= THREAD_DRAWER_WIDTH_MIN; + const atMax = dragging && liveWidth !== null && liveWidth >= drawerMaxW; + + // Body-style cleanup if the drag terminates without `pointerup` + // (component unmount mid-drag — route change, mobile breakpoint flip, + // Alt-Tab). Without this the page can get stuck with col-resize cursor + // + userSelect: none. + 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(clampThreadDrawerWidth(current, window.innerWidth)); + } + return null; + }); + }, [setSavedWidth]); + + 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 + // (drawer 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(clampThreadDrawerWidth(rect.right - e.clientX, window.innerWidth)); + }, + [dragging] + ); + + 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; + // Arrow direction is inverted vs the page-nav handle: ArrowLeft + // grows the drawer (its left edge moves left → width increases). + if (e.key === 'ArrowLeft') next = savedWidth + step; + else if (e.key === 'ArrowRight') next = savedWidth - step; + else if (e.key === 'Home') next = drawerMaxW; + else if (e.key === 'End') next = THREAD_DRAWER_WIDTH_MIN; + if (next === null) return; + e.preventDefault(); + setSavedWidth(clampThreadDrawerWidth(next, vw)); + }, + [savedWidth, vw, drawerMaxW, canResize, setSavedWidth] + ); // Sentinel at the bottom of the replies list. The autoscroll effect // below scrolls into view on (a) initial reveal, (b) reply count // growth while at-bottom, (c) own send. Whether we're at-bottom is @@ -1136,10 +1266,10 @@ export function ThreadDrawer({ ); }; - return ( + const asideContent = ( ); + + if (!isDesktopVariant) { + return asideContent; + } + + return ( +
+ {asideContent} + {canResize && ( +
+ )} +
+ ); } diff --git a/src/app/state/threadDrawerWidth.ts b/src/app/state/threadDrawerWidth.ts new file mode 100644 index 00000000..1f340450 --- /dev/null +++ b/src/app/state/threadDrawerWidth.ts @@ -0,0 +1,33 @@ +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from './utils/atomWithLocalStorage'; + +export const THREAD_DRAWER_WIDTH_KEY = 'vojo_thread_drawer_width'; +// Match the prior static `clamp(320, 28%, 420)` lower bound so users on +// narrow viewports keep the same minimum they had pre-resize. +export const THREAD_DRAWER_WIDTH_MIN = 320; +// Upper end of the prior clamp — pleasant default for typical desktops. +export const THREAD_DRAWER_WIDTH_DEFAULT = 420; + +const readThreadDrawerWidth = (key: string): number => { + const raw = getLocalStorageItem(key, THREAD_DRAWER_WIDTH_DEFAULT); + const value = + typeof raw === 'number' && Number.isFinite(raw) ? raw : THREAD_DRAWER_WIDTH_DEFAULT; + return Math.max(THREAD_DRAWER_WIDTH_MIN, Math.round(value)); +}; + +export const threadDrawerWidthAtom = atomWithLocalStorage( + THREAD_DRAWER_WIDTH_KEY, + readThreadDrawerWidth, + setLocalStorageItem +); + +// Symmetric with `clampSidebarWidth` — max = viewport/3 with a MIN floor +// so single-window-fraction viewports don't collapse the drawer below +// what's readable. +export const clampThreadDrawerWidth = (px: number, viewportWidth: number): number => { + const max = Math.max(THREAD_DRAWER_WIDTH_MIN, Math.floor(viewportWidth / 3)); + return Math.max(THREAD_DRAWER_WIDTH_MIN, Math.min(max, Math.round(px))); +};