このコミットが含まれているのは:
@@ -34,7 +34,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
||||
const cardRef = useRef<HTMLDivElement> (null)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-6 p-4">
|
||||
<div className="post-list flex flex-wrap gap-6 p-4">
|
||||
{posts.map ((post, i) => {
|
||||
const sharedId = `page-${ post.id }`
|
||||
const layoutId = sharedId
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { forwardRef, useMemo } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createPath } from 'react-router-dom'
|
||||
import { createPath, useLocation } from 'react-router-dom'
|
||||
|
||||
import { localiseTo } from '@/lib/locales'
|
||||
import { isVerticalPreviewPath, localeFromPath,
|
||||
localiseTo, unlocalisedPath } from '@/lib/locales'
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||
import { prefetchForURL } from '@/lib/prefetchers'
|
||||
@@ -38,15 +39,18 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
|
||||
const navigate = useLocaleNavigate ()
|
||||
const locale = useLocale ()
|
||||
const { pathname } = useLocation ()
|
||||
const qc = useQueryClient ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
||||
const path = useMemo (
|
||||
() => {
|
||||
const localised = localiseTo (to, locale)
|
||||
return typeof localised === 'string' ? localised : createPath (localised)
|
||||
const localised = localiseTo (to, locale, pathname)
|
||||
const destination = typeof localised === 'string' ? localised : createPath (localised)
|
||||
return (isVerticalPreviewPath (pathname) && localeFromPath (destination) === locale)
|
||||
? `/mn-Mong${ unlocalisedPath (destination) }` : destination
|
||||
},
|
||||
[to, locale],
|
||||
[to, locale, pathname],
|
||||
)
|
||||
const url = useMemo (() => {
|
||||
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Globe } from 'lucide-react'
|
||||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import { useLocale, useLocaleNavigate, useVerticalPreview } from '@/lib/useLocale'
|
||||
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
|
||||
import Separator from '@/components/MenuSeparator'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
@@ -137,6 +137,7 @@ export const menuOutline = (
|
||||
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
const { message, label } = useUITranslation ('shared')
|
||||
const locale = useLocale ()
|
||||
const verticalPreview = useVerticalPreview ()
|
||||
const localeName = locales.find (entry => entry.code === locale)?.name
|
||||
const navigate = useLocaleNavigate ()
|
||||
const location = useLocation ()
|
||||
@@ -180,11 +181,12 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
let node: HTMLElement | null = el
|
||||
while (node != null && node !== nav)
|
||||
{
|
||||
left += node.offsetLeft
|
||||
left += verticalPreview ? node.offsetTop : node.offsetLeft
|
||||
const parent: Element | null = node.offsetParent
|
||||
node = parent instanceof HTMLElement ? parent : null
|
||||
}
|
||||
const width = node === nav ? el.offsetWidth : 0
|
||||
const width = node === nav
|
||||
? (verticalPreview ? el.offsetHeight : el.offsetWidth) : 0
|
||||
const visible = width > 0
|
||||
setHL (current =>
|
||||
(current.left === left && current.width === width && current.visible === visible)
|
||||
@@ -202,6 +204,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||
const [submenuHeight, setSubmenuHeight] = useState (0)
|
||||
const [submenuWidth, setSubmenuWidth] = useState (0)
|
||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||
|
||||
const wikiIdStr = String (wikiId ?? '')
|
||||
@@ -255,8 +258,22 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
animationsOff
|
||||
? undefined
|
||||
: (reducedAnimations
|
||||
? 'transition-[transform,width] duration-75 ease-linear'
|
||||
: 'transition-[transform,width] duration-200 ease-out')
|
||||
? 'transition-[transform,width,height] duration-75 ease-linear'
|
||||
: 'transition-[transform,width,height] duration-200 ease-out')
|
||||
const submenuItemMotion = animationsOff ? { } : {
|
||||
initial: { x: verticalPreview ? -40 : 0,
|
||||
y: verticalPreview ? 0 : -40, opacity: 0 },
|
||||
animate: { x: 0, y: 0, opacity: 1 },
|
||||
exit: { x: verticalPreview ? -40 : 0,
|
||||
y: verticalPreview ? 0 : -40, opacity: 0 } }
|
||||
const submenuVariants = {
|
||||
enter: (direction: -1 | 1) => ({
|
||||
x: verticalPreview ? direction * 24 : 0,
|
||||
y: verticalPreview ? 0 : direction * 24, opacity: 0 }),
|
||||
centre: { x: 0, y: 0, opacity: 1 },
|
||||
exit: (direction: -1 | 1) => ({
|
||||
x: verticalPreview ? (-direction) * 24 : 0,
|
||||
y: verticalPreview ? 0 : (-direction) * 24, opacity: 0 }) }
|
||||
|
||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||
|
||||
@@ -287,6 +304,14 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (verticalPreview)
|
||||
{
|
||||
setDesktop (true)
|
||||
setDensity ('auto')
|
||||
measure (moreVsbl ? -1 : activeIdx)
|
||||
return
|
||||
}
|
||||
|
||||
// Measure the same desktop elements while the mobile layout is active.
|
||||
layout.setAttribute ('data-measuring', '')
|
||||
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
|
||||
@@ -351,7 +376,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
removeEventListener ('resize', schedule)
|
||||
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||
}
|
||||
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
|
||||
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales,
|
||||
menuLabels, verticalPreview])
|
||||
|
||||
useEffect (() => {
|
||||
if (desktop == null || layoutReady)
|
||||
@@ -378,7 +404,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
measure (moreVsbl ? -1 : activeIdx)
|
||||
if (!(desktop))
|
||||
setMoreVsbl (false)
|
||||
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
|
||||
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode,
|
||||
menuLabels, verticalPreview])
|
||||
|
||||
useEffect (() => {
|
||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||
@@ -392,6 +419,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
|
||||
const update = () => {
|
||||
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
|
||||
setSubmenuWidth (Math.ceil (content.getBoundingClientRect ().width))
|
||||
}
|
||||
const observer =
|
||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
|
||||
@@ -403,7 +431,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
observer?.disconnect ()
|
||||
removeEventListener ('resize', update)
|
||||
}
|
||||
}, [activeIdx, moreVsbl, desktop, density, locale])
|
||||
}, [activeIdx, moreVsbl, desktop, density, locale, verticalPreview])
|
||||
|
||||
useEffect (() => {
|
||||
setMenuOpen (false)
|
||||
@@ -479,8 +507,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
className={cn ('absolute inset-y-0 h-12',
|
||||
'top-nav-highlight',
|
||||
highlightTransitionClass)}
|
||||
style={{ width: hl.width,
|
||||
transform: `translateX(${ hl.left }px)`,
|
||||
style={{ width: verticalPreview ? '100%' : hl.width,
|
||||
height: verticalPreview ? hl.width : undefined,
|
||||
transform: verticalPreview
|
||||
? `translateY(${ hl.left }px)`
|
||||
: `translateX(${ hl.left }px)`,
|
||||
opacity: hl.visible ? 1 : 0 }}/>
|
||||
|
||||
{visibleMenu.map ((item, i) => (
|
||||
@@ -529,10 +560,18 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
|
||||
<TopNavUser user={user}/>
|
||||
{localeSelector}
|
||||
{verticalPreview && (
|
||||
<span className="vertical-preview-sample" lang="mn-Mong">
|
||||
ᠮᠣᠩᠭᠣᠯ
|
||||
</span>)}
|
||||
</div>
|
||||
|
||||
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
|
||||
{menuOpen && localeSelector}
|
||||
{verticalPreview && (
|
||||
<span className="vertical-preview-sample" lang="mn-Mong">
|
||||
ᠮᠣᠩᠭᠣᠯ
|
||||
</span>)}
|
||||
<button
|
||||
type="button"
|
||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||
@@ -554,7 +593,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
layout
|
||||
className="top-nav-desktop-submenu relative z-20 shrink-0
|
||||
overflow-hidden top-nav-submenu"
|
||||
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
|
||||
animate={verticalPreview && desktop
|
||||
? { width: submenuWidth, height: '100%' }
|
||||
: { width: 'auto', height: desktop == null ? 'auto' : submenuHeight }}
|
||||
onMouseLeave={() => {
|
||||
if (moreVsbl)
|
||||
setMoreVsbl (false)
|
||||
@@ -566,7 +607,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
onLayoutAnimationComplete={() => {
|
||||
measure (highlightTargetRef.current)
|
||||
}}>
|
||||
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
|
||||
<div ref={submenuContentRef}
|
||||
className={cn ('top-nav-submenu-content', moreVsbl && 'top-nav-more-menu')}>
|
||||
{moreVsbl
|
||||
? (
|
||||
moreMenu.map ((item, i) => (
|
||||
@@ -576,9 +618,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
{...((item.visible ?? true)
|
||||
? { layoutId: `menu-${ menu.indexOf (item) }` }
|
||||
: (animationsOff ? { }
|
||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||
: { initial: {
|
||||
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 },
|
||||
animate: { x: 0, y: 0, opacity: 1 },
|
||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||
exit: {
|
||||
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 } }))}
|
||||
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
||||
className="z-10 min-w-0 px-3 py-2 font-bold">
|
||||
<h2>
|
||||
@@ -599,10 +643,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
transition={topNavTransition}
|
||||
{...(visibleMenu[activeIdx] === item
|
||||
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||
: (animationsOff ? { }
|
||||
: { initial: { y: -40, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -40, opacity: 0 } }))}>
|
||||
: submenuItemMotion)}>
|
||||
{subItem.component}
|
||||
</motion.div>)
|
||||
: (
|
||||
@@ -612,10 +653,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
transition={topNavTransition}
|
||||
{...(visibleMenu[activeIdx] === item
|
||||
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||
: (animationsOff ? { }
|
||||
: { initial: { y: -40, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -40, opacity: 0 } }))}>
|
||||
: submenuItemMotion)}>
|
||||
<PrefetchLink
|
||||
to={subItem.to}
|
||||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||
@@ -633,7 +671,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
<div className="relative min-h-[40px]">
|
||||
{animationsOff
|
||||
? (
|
||||
<div className="flex min-h-[40px] flex-wrap items-center px-3">
|
||||
<div className="top-nav-submenu-items flex min-h-[40px]
|
||||
flex-wrap items-center px-3">
|
||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => (
|
||||
@@ -662,10 +701,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
<motion.div
|
||||
key={activeIdx}
|
||||
custom={dir}
|
||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||
centre: { y: 0, opacity: 1 },
|
||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||
className="relative flex min-h-[40px] flex-wrap items-center px-3"
|
||||
variants={submenuVariants}
|
||||
className="top-nav-submenu-items relative flex min-h-[40px]
|
||||
flex-wrap items-center px-3"
|
||||
initial="enter"
|
||||
animate="centre"
|
||||
exit="exit"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { useVerticalPreview } from '@/lib/useLocale'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
@@ -9,21 +12,71 @@ import type { FC, ReactNode } from 'react'
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
animateLayout?: boolean }
|
||||
animateLayout?: boolean
|
||||
verticalContent?: boolean }
|
||||
|
||||
|
||||
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
|
||||
const MainArea: FC<Props> = (
|
||||
{ children, className, animateLayout = true, verticalContent = false },
|
||||
) => {
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const verticalPreview = useVerticalPreview ()
|
||||
const horizontal = verticalPreview && verticalContent
|
||||
const { pathname, search } = useLocation ()
|
||||
const rootRef = useRef<HTMLElement | null> (null)
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const layoutTransition = clientAnimationTransition (
|
||||
animationMode,
|
||||
{ normal: { duration: .2, ease: 'easeOut' as const } },
|
||||
)
|
||||
{ normal: { duration: .2, ease: 'easeOut' as const } })
|
||||
|
||||
useEffect (() => {
|
||||
if (horizontal && rootRef.current != null)
|
||||
rootRef.current.scrollLeft = 0
|
||||
}, [horizontal, pathname, search])
|
||||
|
||||
useEffect (() => {
|
||||
const root = rootRef.current
|
||||
if (!horizontal || root == null)
|
||||
return
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey
|
||||
|| event.deltaX !== 0 || event.deltaY === 0 || event.defaultPrevented)
|
||||
return
|
||||
|
||||
// Nested tables, forms and code blocks retain their own scrolling.
|
||||
let node = event.target instanceof HTMLElement ? event.target : null
|
||||
while (node != null && node !== root)
|
||||
{
|
||||
if (node.matches ('input, select, textarea, [contenteditable="true"]'))
|
||||
return
|
||||
const style = getComputedStyle (node)
|
||||
if ((/auto|scroll/.test (style.overflowY) && node.scrollHeight > node.clientHeight)
|
||||
|| (/auto|scroll/.test (style.overflowX) && node.scrollWidth > node.clientWidth))
|
||||
return
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
const unit = event.deltaMode === 1 ? 16 : (event.deltaMode === 2 ? root.clientWidth : 1)
|
||||
const delta = event.deltaY * unit
|
||||
if ((delta < 0 && root.scrollLeft <= 0)
|
||||
|| (delta > 0 && root.scrollLeft + root.clientWidth >= root.scrollWidth))
|
||||
return
|
||||
event.preventDefault ()
|
||||
root.scrollLeft += delta
|
||||
}
|
||||
|
||||
root.addEventListener ('wheel', handleWheel, { passive: false })
|
||||
return () => root.removeEventListener ('wheel', handleWheel)
|
||||
}, [horizontal])
|
||||
|
||||
return (
|
||||
<motion.main
|
||||
ref={rootRef}
|
||||
data-vertical-content={horizontal || undefined}
|
||||
tabIndex={horizontal ? 0 : undefined}
|
||||
transition={{ layout: layoutTransition }}
|
||||
className={cn ('flex-1 overflow-y-auto p-4', className)}
|
||||
className={cn ('main-area flex-1 overflow-y-auto p-4', className)}
|
||||
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
|
||||
{children}
|
||||
</motion.main>)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { useVerticalPreview } from '@/lib/useLocale'
|
||||
|
||||
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type SidebarSide = 'left' | 'right'
|
||||
|
||||
@@ -23,12 +24,12 @@ type Props = {
|
||||
|
||||
type ResizeState = {
|
||||
pointerId: number
|
||||
startWidth: number
|
||||
startSize: number
|
||||
startX: number
|
||||
startY: number
|
||||
verticalPreview: boolean
|
||||
side: SidebarSide }
|
||||
|
||||
type PendingPressState = ResizeState & { startY: number }
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||
const MIN_SIDEBAR_WIDTH = 192
|
||||
const MIN_MAIN_WIDTH = 360
|
||||
@@ -37,84 +38,97 @@ const LONG_PRESS_DELAY_MS = 350
|
||||
const TOUCH_MOVE_THRESHOLD_PX = 8
|
||||
|
||||
|
||||
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string =>
|
||||
`sidebar:${ sidebarKey }:${ side }:width`
|
||||
const storageKeyForSidebar = (
|
||||
sidebarKey: string,
|
||||
side: SidebarSide,
|
||||
verticalPreview = false,
|
||||
): string =>
|
||||
`sidebar:${ sidebarKey }:${ side }:${ verticalPreview ? 'height' : 'width' }`
|
||||
|
||||
|
||||
const getSidebarMaxWidth = (containerWidth: number): number => {
|
||||
const getSidebarMaxWidth = (containerSize: number): number => {
|
||||
const viewportWidth =
|
||||
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
||||
typeof window === 'undefined' ? containerSize : window.innerWidth
|
||||
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
||||
const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH)
|
||||
const containerMaxWidth = Math.floor (containerSize - MIN_MAIN_WIDTH)
|
||||
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
|
||||
|
||||
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
|
||||
}
|
||||
|
||||
|
||||
const readStoredSidebarWidth = (
|
||||
const readStoredSidebarSize = (
|
||||
sidebarKey: string,
|
||||
side: SidebarSide): number => {
|
||||
side: SidebarSide,
|
||||
verticalPreview: boolean,
|
||||
): number => {
|
||||
if (typeof window === 'undefined')
|
||||
return DEFAULT_SIDEBAR_WIDTH
|
||||
|
||||
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
|
||||
const width = value == null ? Number.NaN : Number (value)
|
||||
|
||||
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
|
||||
try
|
||||
{
|
||||
const value = window.localStorage.getItem (
|
||||
storageKeyForSidebar (sidebarKey, side, verticalPreview))
|
||||
const size = value == null ? Number.NaN : Number (value)
|
||||
return Number.isFinite (size) ? size : DEFAULT_SIDEBAR_WIDTH
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DEFAULT_SIDEBAR_WIDTH
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const SidebarComponent: FC<Props> = ({
|
||||
children,
|
||||
className,
|
||||
maxWidth,
|
||||
maxWidthRatio,
|
||||
animateLayout = true,
|
||||
onWidthChange,
|
||||
sidebarKey,
|
||||
side = 'left',
|
||||
}) => {
|
||||
const SidebarComponent: FC<Props> = (
|
||||
{ children, className, maxWidth, maxWidthRatio, animateLayout = true,
|
||||
onWidthChange, sidebarKey, side = 'left' },
|
||||
) => {
|
||||
const { message } = useUITranslation ('shared')
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const verticalPreview = useVerticalPreview ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const layoutTransition = clientAnimationTransition (
|
||||
animationMode,
|
||||
{ normal: { duration: .2, ease: 'easeOut' as const } },
|
||||
)
|
||||
{ normal: { duration: .2, ease: 'easeOut' as const } })
|
||||
const rootRef = useRef<HTMLDivElement | null> (null)
|
||||
const maxWidthRef = useRef (maxWidth)
|
||||
const maxWidthRatioRef = useRef (maxWidthRatio)
|
||||
const getContainerWidth = useCallback (() => {
|
||||
const containerWidth = rootRef.current
|
||||
?.closest ('[data-sidebar-container]')
|
||||
?.clientWidth
|
||||
?? window.innerWidth
|
||||
return containerWidth
|
||||
}, [])
|
||||
const getClampedWidth = useCallback ((
|
||||
nextWidth: number,
|
||||
containerWidth: number): number => {
|
||||
const getContainerSize = useCallback (() => {
|
||||
const container = rootRef.current?.closest ('[data-sidebar-container]')
|
||||
return verticalPreview
|
||||
? container?.clientHeight ?? window.innerHeight
|
||||
: container?.clientWidth ?? window.innerWidth
|
||||
}, [verticalPreview])
|
||||
const getClampedSize = useCallback ((
|
||||
nextSize: number,
|
||||
containerSize: number,
|
||||
): number => {
|
||||
if (verticalPreview)
|
||||
{
|
||||
const maximum = Math.max (64, Math.min (
|
||||
window.innerHeight * .6, containerSize - 160))
|
||||
return Math.min (maximum, Math.max (64, nextSize))
|
||||
}
|
||||
const viewportWidth =
|
||||
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
||||
typeof window === 'undefined' ? containerSize : window.innerWidth
|
||||
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
||||
const ratioMaxWidth = maxWidthRatioRef.current == null
|
||||
? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
|
||||
? Infinity : Math.floor (containerSize * maxWidthRatioRef.current)
|
||||
const configuredMaxWidth = maxWidthRef.current == null
|
||||
? getSidebarMaxWidth (containerWidth)
|
||||
? getSidebarMaxWidth (containerSize)
|
||||
: Math.max (MIN_SIDEBAR_WIDTH,
|
||||
Math.min (viewportMaxWidth, maxWidthRef.current))
|
||||
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
|
||||
Math.min (ratioMaxWidth, configuredMaxWidth))
|
||||
|
||||
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
|
||||
}, [])
|
||||
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextSize))
|
||||
}, [verticalPreview])
|
||||
|
||||
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
|
||||
const [size, setSize] = useState (DEFAULT_SIDEBAR_WIDTH)
|
||||
const [resizing, setResizing] = useState (false)
|
||||
const widthRef = useRef (width)
|
||||
const sizeRef = useRef (size)
|
||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
|
||||
const pendingPressRef = useRef<PendingPressState | null> (null)
|
||||
const pendingPressRef = useRef<ResizeState | null> (null)
|
||||
const resizeRef = useRef<ResizeState | null> (null)
|
||||
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
|
||||
|
||||
@@ -148,7 +162,7 @@ const SidebarComponent: FC<Props> = ({
|
||||
userSelect: document.body.style.userSelect }
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.cursor = state.verticalPreview ? 'row-resize' : 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [clearLongPressTimer])
|
||||
|
||||
@@ -159,43 +173,50 @@ const SidebarComponent: FC<Props> = ({
|
||||
const state = resizeRef.current
|
||||
resizeRef.current = null
|
||||
|
||||
if (state != null)
|
||||
window.localStorage.setItem (
|
||||
storageKeyForSidebar (sidebarKey, state.side),
|
||||
String (widthRef.current))
|
||||
try
|
||||
{
|
||||
if (state != null)
|
||||
window.localStorage.setItem (
|
||||
storageKeyForSidebar (sidebarKey, state.side, state.verticalPreview),
|
||||
String (sizeRef.current))
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Resizing remains usable when browser storage is unavailable.
|
||||
}
|
||||
|
||||
setResizing (false)
|
||||
restoreBodyStyle ()
|
||||
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
|
||||
|
||||
const applyWidth = useCallback ((nextWidth: number) => {
|
||||
if (nextWidth === widthRef.current)
|
||||
const applySize = useCallback ((nextSize: number) => {
|
||||
if (nextSize === sizeRef.current)
|
||||
return
|
||||
|
||||
widthRef.current = nextWidth
|
||||
setWidth (nextWidth)
|
||||
sizeRef.current = nextSize
|
||||
setSize (nextSize)
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
maxWidthRef.current = maxWidth
|
||||
maxWidthRatioRef.current = maxWidthRatio
|
||||
const containerWidth = getContainerWidth ()
|
||||
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
||||
applyWidth (nextWidth)
|
||||
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
|
||||
const containerSize = getContainerSize ()
|
||||
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
||||
applySize (nextSize)
|
||||
}, [applySize, getClampedSize, getContainerSize, maxWidth, maxWidthRatio])
|
||||
|
||||
const updateWidth = useCallback ((clientX: number) => {
|
||||
const updateSize = useCallback ((clientX: number, clientY: number) => {
|
||||
const state = resizeRef.current
|
||||
if (state == null)
|
||||
return
|
||||
|
||||
const containerWidth = getContainerWidth ()
|
||||
const delta = state.side === 'left'
|
||||
? clientX - state.startX
|
||||
: state.startX - clientX
|
||||
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
|
||||
applyWidth (nextWidth)
|
||||
}, [applyWidth, getClampedWidth, getContainerWidth])
|
||||
const containerSize = getContainerSize ()
|
||||
const delta = state.verticalPreview
|
||||
? clientY - state.startY
|
||||
: (state.side === 'left' ? clientX - state.startX : state.startX - clientX)
|
||||
const nextSize = getClampedSize (state.startSize + delta, containerSize)
|
||||
applySize (nextSize)
|
||||
}, [applySize, getClampedSize, getContainerSize])
|
||||
|
||||
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
|
||||
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
|
||||
@@ -211,8 +232,10 @@ const SidebarComponent: FC<Props> = ({
|
||||
const state = {
|
||||
pointerId: ev.pointerId,
|
||||
side,
|
||||
startWidth: widthRef.current,
|
||||
startX: ev.clientX }
|
||||
startSize: sizeRef.current,
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
verticalPreview }
|
||||
|
||||
if (ev.pointerType === 'mouse')
|
||||
{
|
||||
@@ -220,7 +243,7 @@ const SidebarComponent: FC<Props> = ({
|
||||
return
|
||||
}
|
||||
|
||||
pendingPressRef.current = { ...state, startY: ev.clientY }
|
||||
pendingPressRef.current = state
|
||||
clearLongPressTimer ()
|
||||
longPressTimerRef.current = setTimeout (() => {
|
||||
if (pendingPressRef.current != null)
|
||||
@@ -234,7 +257,7 @@ const SidebarComponent: FC<Props> = ({
|
||||
if (resizeRef.current != null)
|
||||
{
|
||||
ev.preventDefault ()
|
||||
updateWidth (ev.clientX)
|
||||
updateSize (ev.clientX, ev.clientY)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -262,58 +285,58 @@ const SidebarComponent: FC<Props> = ({
|
||||
finishResize ()
|
||||
}
|
||||
|
||||
const resetWidth = useCallback (() => {
|
||||
const containerWidth = getContainerWidth ()
|
||||
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
|
||||
applyWidth (nextWidth)
|
||||
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
|
||||
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
|
||||
const resetSize = useCallback (() => {
|
||||
const containerSize = getContainerSize ()
|
||||
const nextSize = getClampedSize (DEFAULT_SIDEBAR_WIDTH, containerSize)
|
||||
applySize (nextSize)
|
||||
window.localStorage.removeItem (
|
||||
storageKeyForSidebar (sidebarKey, side, verticalPreview))
|
||||
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
|
||||
|
||||
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
|
||||
ev.preventDefault ()
|
||||
ev.stopPropagation ()
|
||||
resetWidth ()
|
||||
resetSize ()
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
const containerWidth = getContainerWidth ()
|
||||
const nextWidth = getClampedWidth (
|
||||
readStoredSidebarWidth (sidebarKey, side),
|
||||
containerWidth)
|
||||
applyWidth (nextWidth)
|
||||
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
|
||||
const containerSize = getContainerSize ()
|
||||
const nextSize = getClampedSize (
|
||||
readStoredSidebarSize (sidebarKey, side, verticalPreview),
|
||||
containerSize)
|
||||
applySize (nextSize)
|
||||
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
|
||||
|
||||
useEffect (() => {
|
||||
const containerWidth = getContainerWidth ()
|
||||
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
||||
applyWidth (nextWidth)
|
||||
}, [applyWidth, getClampedWidth, getContainerWidth])
|
||||
const containerSize = getContainerSize ()
|
||||
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
||||
applySize (nextSize)
|
||||
}, [applySize, getClampedSize, getContainerSize])
|
||||
|
||||
useEffect (() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
|
||||
const handleResize = () => {
|
||||
const containerWidth = getContainerWidth ()
|
||||
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
||||
applyWidth (nextWidth)
|
||||
const containerSize = getContainerSize ()
|
||||
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
||||
applySize (nextSize)
|
||||
}
|
||||
|
||||
window.addEventListener ('resize', handleResize)
|
||||
return () => window.removeEventListener ('resize', handleResize)
|
||||
}, [applyWidth, getClampedWidth, getContainerWidth])
|
||||
}, [applySize, getClampedSize, getContainerSize])
|
||||
|
||||
useEffect (() => {
|
||||
onWidthChange?.(width)
|
||||
}, [onWidthChange, width])
|
||||
if (!verticalPreview)
|
||||
onWidthChange?.(size)
|
||||
}, [onWidthChange, size, verticalPreview])
|
||||
|
||||
useEffect (() => () => {
|
||||
clearLongPressTimer ()
|
||||
restoreBodyStyle ()
|
||||
}, [clearLongPressTimer, restoreBodyStyle])
|
||||
useEffect (() => () => finishResize (), [finishResize, verticalPreview])
|
||||
|
||||
const style = {
|
||||
'--sidebar-width': `${ width }px` } as CSSProperties
|
||||
'--sidebar-width': `${ size }px`,
|
||||
'--sidebar-height': `${ size }px` } as CSSProperties
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -322,7 +345,7 @@ const SidebarComponent: FC<Props> = ({
|
||||
transition={{ layout: layoutTransition }}
|
||||
style={style}
|
||||
className={cn (
|
||||
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
|
||||
'sidebar-panel relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
|
||||
className)}>
|
||||
<Helmet>
|
||||
<style>
|
||||
@@ -345,16 +368,16 @@ const SidebarComponent: FC<Props> = ({
|
||||
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={message ('sidebarWidth').text}
|
||||
lang={message ('sidebarWidth').language}
|
||||
aria-orientation={verticalPreview ? 'horizontal' : 'vertical'}
|
||||
aria-label={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').text}
|
||||
lang={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').language}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn (
|
||||
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
|
||||
'sidebar-resize hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
|
||||
'touch-none select-none',
|
||||
side === 'left' ? '-right-2' : '-left-2',
|
||||
'before:absolute before:top-0 before:bottom-0 before:w-px',
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする