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
cab6b788e3
commit
3f873a5041
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
|
// a time; both feed into `showAnyHorseshoe` for the chat-column
|
||||||
// bg + the void-gap render gate.
|
// bg + the void-gap render gate.
|
||||||
const showMediaHorseshoe = mediaOpen && !isMobile && !showThreadDrawer;
|
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(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
|
|
@ -278,13 +282,24 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showThreadDrawer && decodedRootId && parentRoomPath && (
|
{showThreadDrawer && decodedRootId && parentRoomPath && (
|
||||||
<ThreadDrawer
|
<>
|
||||||
key={`${room.roomId}/${decodedRootId}`}
|
{showThreadHorseshoe && (
|
||||||
room={room}
|
<Box
|
||||||
rootId={decodedRootId}
|
shrink="No"
|
||||||
parentRoomPath={parentRoomPath}
|
style={{
|
||||||
variant={isMobile ? 'mobile' : 'desktop'}
|
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>
|
</Box>
|
||||||
</MediaViewerHostContext.Provider>
|
</MediaViewerHostContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,80 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
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`:
|
// Layout copies element-web `_ThreadPanel.pcss:73-84`:
|
||||||
// `max-height: 100%` clamps the column to the parent's viewport-bound
|
// `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
|
// under content (otherwise flex-children grow with content and push
|
||||||
// the composer off-screen — bug observed on cold-load with many
|
// the composer off-screen — bug observed on cold-load with many
|
||||||
// replies). Comment in element-web: «don't displace the composer».
|
// 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({
|
export const ThreadDrawer = style({
|
||||||
flexShrink: 0,
|
flexGrow: 1,
|
||||||
width: `clamp(${toRem(320)}, 28%, ${toRem(420)})`,
|
width: '100%',
|
||||||
maxHeight: '100%',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
|
||||||
minHeight: 0,
|
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.
|
// 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 React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
config,
|
config,
|
||||||
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import {
|
import {
|
||||||
Direction,
|
Direction,
|
||||||
|
|
@ -82,6 +83,11 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import {
|
||||||
|
THREAD_DRAWER_WIDTH_MIN,
|
||||||
|
clampThreadDrawerWidth,
|
||||||
|
threadDrawerWidthAtom,
|
||||||
|
} from '../../state/threadDrawerWidth';
|
||||||
import {
|
import {
|
||||||
factoryRenderLinkifyWithMention,
|
factoryRenderLinkifyWithMention,
|
||||||
getReactCustomHtmlParser,
|
getReactCustomHtmlParser,
|
||||||
|
|
@ -154,6 +160,130 @@ export function ThreadDrawer({
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const fileDropContainerRef = useRef<HTMLDivElement>(null);
|
const fileDropContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const closeBtnRef = useRef<HTMLButtonElement>(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
|
// Sentinel at the bottom of the replies list. The autoscroll effect
|
||||||
// below scrolls into view on (a) initial reveal, (b) reply count
|
// below scrolls into view on (a) initial reveal, (b) reply count
|
||||||
// growth while at-bottom, (c) own send. Whether we're at-bottom is
|
// growth while at-bottom, (c) own send. Whether we're at-bottom is
|
||||||
|
|
@ -1136,10 +1266,10 @@ export function ThreadDrawer({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const asideContent = (
|
||||||
<aside
|
<aside
|
||||||
ref={fileDropContainerRef}
|
ref={fileDropContainerRef}
|
||||||
className={variant === 'desktop' ? css.ThreadDrawer : css.ThreadDrawerMobile}
|
className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile}
|
||||||
role="region"
|
role="region"
|
||||||
aria-labelledby={headerId}
|
aria-labelledby={headerId}
|
||||||
>
|
>
|
||||||
|
|
@ -1198,4 +1328,40 @@ export function ThreadDrawer({
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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