feat(thread-drawer): wrap in horseshoe seam with rounded TL/BL and add pointer/keyboard resize clamped to viewport/3

This commit is contained in:
v.lagerev 2026-05-13 14:42:15 +03:00
parent cab6b788e3
commit 3f873a5041
4 changed files with 314 additions and 15 deletions

View file

@ -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 && (
<ThreadDrawer
key={`${room.roomId}/${decodedRootId}`}
room={room}
rootId={decodedRootId}
parentRoomPath={parentRoomPath}
variant={isMobile ? 'mobile' : 'desktop'}
/>
<>
{showThreadHorseshoe && (
<Box
shrink="No"
style={{
width: toRem(VOJO_HORSESHOE_GAP_PX),
backgroundColor: VOJO_HORSESHOE_VOID_COLOR,
}}
/>
)}
<ThreadDrawer
key={`${room.roomId}/${decodedRootId}`}
room={room}
rootId={decodedRootId}
parentRoomPath={parentRoomPath}
variant={isMobile ? 'mobile' : 'desktop'}
/>
</>
)}
</Box>
</MediaViewerHostContext.Provider>

View file

@ -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.

View file

@ -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<HTMLDivElement>(null);
const closeBtnRef = useRef<HTMLButtonElement>(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<HTMLDivElement>(null);
const resizeHandleRef = useRef<HTMLDivElement>(null);
const [savedWidth, setSavedWidth] = useAtom(threadDrawerWidthAtom);
const [vw, setVw] = useState<number>(
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<number | null>(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<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
// (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<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;
// 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 = (
<aside
ref={fileDropContainerRef}
className={variant === 'desktop' ? css.ThreadDrawer : css.ThreadDrawerMobile}
className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile}
role="region"
aria-labelledby={headerId}
>
@ -1198,4 +1328,40 @@ export function ThreadDrawer({
</div>
</aside>
);
if (!isDesktopVariant) {
return asideContent;
}
return (
<div
ref={resizableRef}
className={css.ThreadDrawerResizable}
style={{ width: drawerWidth }}
>
{asideContent}
{canResize && (
<div
ref={resizeHandleRef}
role="separator"
aria-orientation="vertical"
aria-valuenow={drawerWidth}
aria-valuemin={THREAD_DRAWER_WIDTH_MIN}
aria-valuemax={drawerMaxW}
aria-label="Resize thread drawer"
tabIndex={0}
className={css.ThreadDrawerResizeHandle}
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>
);
}

View file

@ -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<unknown>(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<number>(
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)));
};