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:
parent
11c46d9250
commit
3dee9f099f
4 changed files with 314 additions and 15 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
33
src/app/state/threadDrawerWidth.ts
Normal file
33
src/app/state/threadDrawerWidth.ts
Normal 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)));
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue