タグが見切れる問題対応 (#76) #395
@@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
||||
setDropRef (node)
|
||||
}}
|
||||
style={style}
|
||||
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
|
||||
className={cn (
|
||||
'min-w-0 max-w-full overflow-hidden rounded select-none',
|
||||
over && 'ring-2 ring-offset-2')}
|
||||
{...attributes}
|
||||
{...listeners}>
|
||||
<motion.div
|
||||
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
text: string
|
||||
className?: string
|
||||
title?: string }
|
||||
|
||||
const DESKTOP_MARQUEE_MEDIA =
|
||||
'(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
|
||||
const MARQUEE_START_DELAY_MS = 1600
|
||||
const MARQUEE_END_HOLD_MS = 2400
|
||||
const MARQUEE_SCROLL_PX_PER_SECOND = 64
|
||||
const MIN_MARQUEE_OVERFLOW_PX = 1
|
||||
|
||||
|
||||
const ResponsiveMarqueeText: FC<Props> = ({ text, className, title }) => {
|
||||
const outerRef = useRef<HTMLSpanElement | null> (null)
|
||||
const staticRef = useRef<HTMLSpanElement | null> (null)
|
||||
const animatedRef = useRef<HTMLSpanElement | null> (null)
|
||||
const animationRef = useRef<Animation | null> (null)
|
||||
const timeoutRef = useRef<number | null> (null)
|
||||
const rafRef = useRef<number | null> (null)
|
||||
const [overflowPx, setOverflowPx] = useState (0)
|
||||
const [desktopMarqueeEnabled, setDesktopMarqueeEnabled] = useState (false)
|
||||
const [active, setActive] = useState (false)
|
||||
const [marqueeVisible, setMarqueeVisible] = useState (false)
|
||||
|
||||
useEffect (() => {
|
||||
const outer = outerRef.current
|
||||
const inner = staticRef.current
|
||||
|
||||
if (!(outer) || !(inner))
|
||||
return
|
||||
|
||||
const measure = () => {
|
||||
const nextOverflow = Math.max (0, Math.ceil (inner.scrollWidth - outer.clientWidth))
|
||||
setOverflowPx (prev => Math.abs (prev - nextOverflow) <= 1 ? prev : nextOverflow)
|
||||
}
|
||||
|
||||
measure ()
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver (() => {
|
||||
measure ()
|
||||
})
|
||||
|
||||
resizeObserver?.observe (outer)
|
||||
resizeObserver?.observe (inner)
|
||||
addEventListener ('resize', measure)
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect ()
|
||||
removeEventListener ('resize', measure)
|
||||
}
|
||||
}, [text])
|
||||
|
||||
useEffect (() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
||||
return
|
||||
|
||||
const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA)
|
||||
const update = () => {
|
||||
setDesktopMarqueeEnabled (media.matches)
|
||||
}
|
||||
|
||||
update ()
|
||||
media.addEventListener ('change', update)
|
||||
|
||||
return () => {
|
||||
media.removeEventListener ('change', update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
const clearScheduled = () => {
|
||||
if (timeoutRef.current != null)
|
||||
{
|
||||
clearTimeout (timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
if (rafRef.current != null)
|
||||
{
|
||||
cancelAnimationFrame (rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
animationRef.current?.cancel ()
|
||||
animationRef.current = null
|
||||
}
|
||||
|
||||
const animated = animatedRef.current
|
||||
const canMarquee = (
|
||||
active
|
||||
&& desktopMarqueeEnabled
|
||||
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
|
||||
&& animated != null)
|
||||
|
||||
const resetAnimated = () => {
|
||||
animationRef.current?.cancel ()
|
||||
animationRef.current = null
|
||||
|
||||
if (!(animated))
|
||||
return
|
||||
|
||||
animated.style.transition = 'none'
|
||||
animated.style.transform = 'translateX(0)'
|
||||
}
|
||||
|
||||
if (!canMarquee)
|
||||
{
|
||||
clearScheduled ()
|
||||
resetAnimated ()
|
||||
setMarqueeVisible (false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void> (resolve => {
|
||||
timeoutRef.current = window.setTimeout (() => {
|
||||
timeoutRef.current = null
|
||||
resolve ()
|
||||
}, ms)
|
||||
})
|
||||
|
||||
const runLoop = async () => {
|
||||
let started = false
|
||||
|
||||
while (!cancelled)
|
||||
{
|
||||
resetAnimated ()
|
||||
if (!started)
|
||||
setMarqueeVisible (false)
|
||||
|
||||
await sleep (MARQUEE_START_DELAY_MS)
|
||||
if (cancelled || !(animatedRef.current))
|
||||
break
|
||||
|
||||
setMarqueeVisible (true)
|
||||
started = true
|
||||
|
||||
const moveDurationMs =
|
||||
overflowPx / MARQUEE_SCROLL_PX_PER_SECOND * 1000
|
||||
const node = animatedRef.current
|
||||
|
||||
if (typeof node.animate === 'function')
|
||||
{
|
||||
const animation = node.animate (
|
||||
[
|
||||
{ transform: 'translateX(0)' },
|
||||
{ transform: `translateX(-${ overflowPx }px)` },
|
||||
],
|
||||
{ duration: moveDurationMs, easing: 'linear', fill: 'forwards' })
|
||||
animationRef.current = animation
|
||||
try
|
||||
{
|
||||
await animation.finished
|
||||
}
|
||||
catch
|
||||
{
|
||||
break
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
node.style.transition = `transform ${ moveDurationMs }ms linear`
|
||||
await new Promise<void> (resolve => {
|
||||
rafRef.current = requestAnimationFrame (() => {
|
||||
rafRef.current = null
|
||||
node.style.transform = `translateX(-${ overflowPx }px)`
|
||||
resolve ()
|
||||
})
|
||||
})
|
||||
await sleep (moveDurationMs)
|
||||
node.style.transition = 'none'
|
||||
}
|
||||
|
||||
if (cancelled)
|
||||
break
|
||||
|
||||
await sleep (MARQUEE_END_HOLD_MS)
|
||||
if (cancelled)
|
||||
break
|
||||
|
||||
resetAnimated ()
|
||||
}
|
||||
}
|
||||
|
||||
void runLoop ()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearScheduled ()
|
||||
resetAnimated ()
|
||||
setMarqueeVisible (false)
|
||||
}
|
||||
}, [active, desktopMarqueeEnabled, overflowPx, text])
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={outerRef}
|
||||
title={title ?? text}
|
||||
onMouseEnter={() => setActive (true)}
|
||||
onMouseLeave={() => setActive (false)}
|
||||
onFocus={() => setActive (true)}
|
||||
onBlur={() => setActive (false)}
|
||||
className={cn (
|
||||
'tag-marquee inline-block max-w-full min-w-0 align-bottom',
|
||||
'whitespace-normal [overflow-wrap:anywhere]',
|
||||
'md:overflow-hidden md:whitespace-nowrap',
|
||||
className)}>
|
||||
<span
|
||||
ref={staticRef}
|
||||
className={cn (
|
||||
'tag-marquee__static block max-w-full',
|
||||
'whitespace-normal [overflow-wrap:anywhere]',
|
||||
'md:overflow-hidden md:text-ellipsis md:whitespace-nowrap',
|
||||
marqueeVisible && 'md:opacity-0')}>
|
||||
{text}
|
||||
</span>
|
||||
{overflowPx >= MIN_MARQUEE_OVERFLOW_PX && (
|
||||
<span
|
||||
ref={animatedRef}
|
||||
aria-hidden="true"
|
||||
className={cn (
|
||||
'tag-marquee__animated hidden md:block',
|
||||
marqueeVisible ? 'md:opacity-100' : 'md:opacity-0')}>
|
||||
{text}
|
||||
</span>)}
|
||||
</span>)
|
||||
}
|
||||
|
||||
export default ResponsiveMarqueeText
|
||||
@@ -1,4 +1,5 @@
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
|
||||
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -33,8 +34,8 @@ const TagLink: FC<Props> = ({ tag,
|
||||
withWiki = true,
|
||||
withCount = true,
|
||||
className,
|
||||
title,
|
||||
...props }) => {
|
||||
const textWrapClass = '[overflow-wrap:anywhere]'
|
||||
const spanClass = cn (
|
||||
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
||||
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
|
||||
@@ -42,11 +43,14 @@ const TagLink: FC<Props> = ({ tag,
|
||||
spanClass,
|
||||
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
|
||||
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
|
||||
const textClass = 'group inline-block min-w-0 max-w-full align-bottom'
|
||||
const textTitle = title
|
||||
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias } → ${ tag.name }`)
|
||||
|
||||
return (
|
||||
<>
|
||||
{(linkFlg && withWiki) && (
|
||||
<span className="mr-1">
|
||||
<span className="mr-1 shrink-0">
|
||||
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
||||
? (
|
||||
tag.materialId == null && !(tag.hasDeerjikists)
|
||||
@@ -101,16 +105,17 @@ const TagLink: FC<Props> = ({ tag,
|
||||
</span>)}
|
||||
{nestLevel > 0 && (
|
||||
<span
|
||||
className="ml-1 mr-1"
|
||||
className="ml-1 mr-1 shrink-0"
|
||||
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
||||
↳
|
||||
</span>)}
|
||||
{tag.matchedAlias != null && (
|
||||
<>
|
||||
<span
|
||||
className={cn (spanClass, textWrapClass, className)}
|
||||
title={textTitle}
|
||||
className={cn (spanClass, textClass, className)}
|
||||
{...props}>
|
||||
{tag.matchedAlias}
|
||||
<ResponsiveMarqueeText text={tag.matchedAlias} title={textTitle}/>
|
||||
</span>
|
||||
<> → </>
|
||||
</>)}
|
||||
@@ -118,18 +123,20 @@ const TagLink: FC<Props> = ({ tag,
|
||||
? (
|
||||
<PrefetchLink
|
||||
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
||||
className={cn (linkClass, textWrapClass, className)}
|
||||
title={textTitle}
|
||||
className={cn (linkClass, textClass, className)}
|
||||
{...props}>
|
||||
{tag.name}
|
||||
<ResponsiveMarqueeText text={tag.name} title={textTitle}/>
|
||||
</PrefetchLink>)
|
||||
: (
|
||||
<span
|
||||
className={cn (spanClass, textWrapClass, className)}
|
||||
title={textTitle}
|
||||
className={cn (spanClass, textClass, className)}
|
||||
{...props}>
|
||||
{tag.name}
|
||||
<ResponsiveMarqueeText text={tag.name} title={textTitle}/>
|
||||
</span>)}
|
||||
{withCount && (
|
||||
<span className="ml-1">{tag.postCount}</span>)}
|
||||
<span className="ml-1 shrink-0">{tag.postCount}</span>)}
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,12 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
|
||||
rounded shadow">
|
||||
{suggestions.map ((tag, i) => (
|
||||
<li key={tag.id}
|
||||
className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
||||
className={cn ('min-w-0 overflow-hidden px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
||||
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
|
||||
onMouseDown={() => onSelect (tag)}>
|
||||
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
|
||||
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
|
||||
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
|
||||
</div>
|
||||
</li>))}
|
||||
</ul>)
|
||||
}
|
||||
|
||||
@@ -64,8 +64,9 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||
<ul>
|
||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||
tags[cat].map (tag => (
|
||||
<li key={tag.id} className="mb-1">
|
||||
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden">
|
||||
<motion.div
|
||||
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
layoutId={`tag-${ tag.id }`}>
|
||||
<TagLink tag={tag} onClick={onClick}/>
|
||||
|
||||
@@ -132,3 +132,20 @@ body
|
||||
0%, 100% { color: #f87171; }
|
||||
50% { color: #60a5fa; }
|
||||
}
|
||||
|
||||
.tag-marquee
|
||||
{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tag-marquee__animated
|
||||
{
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする