タグが見切れる問題対応 (#76) #395

マージ済み
みてるぞ が 7 個のコミットを feature/076 から main へマージ 2026-07-02 23:40:01 +09:00
6個のファイルの変更282行の追加14行の削除
コミット 8ba008d683 の変更だけを表示してゐます - すべてのコミットを表示
+4 -1
ファイルの表示
@@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
setDropRef (node) setDropRef (node)
}} }}
style={style} 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} {...attributes}
{...listeners}> {...listeners}>
<motion.div <motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
+238
ファイルの表示
@@ -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
+17 -10
ファイルの表示
@@ -1,4 +1,5 @@
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -33,8 +34,8 @@ const TagLink: FC<Props> = ({ tag,
withWiki = true, withWiki = true,
withCount = true, withCount = true,
className, className,
title,
...props }) => { ...props }) => {
const textWrapClass = '[overflow-wrap:anywhere]'
const spanClass = cn ( const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -42,11 +43,14 @@ const TagLink: FC<Props> = ({ tag,
spanClass, spanClass,
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`, `hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_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 ( return (
<> <>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className="mr-1"> <span className="mr-1 shrink-0">
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? ( ? (
tag.materialId == null && !(tag.hasDeerjikists) tag.materialId == null && !(tag.hasDeerjikists)
@@ -101,16 +105,17 @@ const TagLink: FC<Props> = ({ tag,
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
className="ml-1 mr-1" className="ml-1 mr-1 shrink-0"
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}> style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)} </span>)}
{tag.matchedAlias != null && ( {tag.matchedAlias != null && (
<> <>
<span <span
className={cn (spanClass, textWrapClass, className)} title={textTitle}
className={cn (spanClass, textClass, className)}
{...props}> {...props}>
{tag.matchedAlias} <ResponsiveMarqueeText text={tag.matchedAlias} title={textTitle}/>
</span> </span>
<> </> <> </>
</>)} </>)}
@@ -118,18 +123,20 @@ const TagLink: FC<Props> = ({ tag,
? ( ? (
<PrefetchLink <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={cn (linkClass, textWrapClass, className)} title={textTitle}
className={cn (linkClass, textClass, className)}
{...props}> {...props}>
{tag.name} <ResponsiveMarqueeText text={tag.name} title={textTitle}/>
</PrefetchLink>) </PrefetchLink>)
: ( : (
<span <span
className={cn (spanClass, textWrapClass, className)} title={textTitle}
className={cn (spanClass, textClass, className)}
{...props}> {...props}>
{tag.name} <ResponsiveMarqueeText text={tag.name} title={textTitle}/>
</span>)} </span>)}
{withCount && ( {withCount && (
<span className="ml-1">{tag.postCount}</span>)} <span className="ml-1 shrink-0">{tag.postCount}</span>)}
</>) </>)
} }
+4 -2
ファイルの表示
@@ -20,10 +20,12 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
rounded shadow"> rounded shadow">
{suggestions.map ((tag, i) => ( {suggestions.map ((tag, i) => (
<li key={tag.id} <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')} i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
onMouseDown={() => onSelect (tag)}> 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>))} </li>))}
</ul>) </ul>)
} }
+2 -1
ファイルの表示
@@ -64,8 +64,9 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
<ul> <ul>
{CATEGORIES.flatMap (cat => cat in tags ? ( {CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => ( 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 <motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ tag.id }`}> layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/> <TagLink tag={tag} onClick={onClick}/>
+17
ファイルの表示
@@ -132,3 +132,20 @@ body
0%, 100% { color: #f87171; } 0%, 100% { color: #f87171; }
50% { color: #60a5fa; } 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;
}