From dd32c1e7746d49a55bbeb22291658b22169b9915 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 30 Jun 2026 23:06:32 +0900 Subject: [PATCH 1/5] #76 --- frontend/src/components/DraggableDroppableTagRow.tsx | 2 +- frontend/src/components/TagLink.tsx | 10 +++++++--- frontend/src/components/TagSearchBox.tsx | 2 +- frontend/src/components/TagSidebar.tsx | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index e6c49ff..248d955 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -98,4 +98,4 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa ) } -export default DraggableDroppableTagRow \ No newline at end of file +export default DraggableDroppableTagRow diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index a32dd4a..e98f2ef 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -34,6 +34,7 @@ const TagLink: FC = ({ tag, withCount = true, className, ...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 }`) @@ -106,7 +107,9 @@ const TagLink: FC = ({ tag, )} {tag.matchedAlias != null && ( <> - + {tag.matchedAlias} <> → @@ -115,12 +118,13 @@ const TagLink: FC = ({ tag, ? ( {tag.name} ) : ( - {tag.name} )} diff --git a/frontend/src/components/TagSearchBox.tsx b/frontend/src/components/TagSearchBox.tsx index 795daee..634dd0e 100644 --- a/frontend/src/components/TagSearchBox.tsx +++ b/frontend/src/components/TagSearchBox.tsx @@ -28,4 +28,4 @@ const TagSearchBox: FC = ({ suggestions, activeIndex, onSelect }) => { ) } -export default TagSearchBox \ No newline at end of file +export default TagSearchBox diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index 9c08ccc..0008ef6 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -128,4 +128,4 @@ const TagSidebar: FC = ({ posts, onClick }) => { ) } -export default TagSidebar \ No newline at end of file +export default TagSidebar -- 2.34.1 From 8ba008d68324cdf5809abc4e07edb6f9a6344044 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 1 Jul 2026 01:08:34 +0900 Subject: [PATCH 2/5] #76 --- .../components/DraggableDroppableTagRow.tsx | 5 +- .../src/components/ResponsiveMarqueeText.tsx | 238 ++++++++++++++++++ frontend/src/components/TagLink.tsx | 27 +- frontend/src/components/TagSearchBox.tsx | 6 +- frontend/src/components/TagSidebar.tsx | 3 +- frontend/src/index.css | 17 ++ 6 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/ResponsiveMarqueeText.tsx diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index 248d955..97e8248 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC = ({ 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}> diff --git a/frontend/src/components/ResponsiveMarqueeText.tsx b/frontend/src/components/ResponsiveMarqueeText.tsx new file mode 100644 index 0000000..dd3cfdb --- /dev/null +++ b/frontend/src/components/ResponsiveMarqueeText.tsx @@ -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 = ({ text, className, title }) => { + const outerRef = useRef (null) + const staticRef = useRef (null) + const animatedRef = useRef (null) + const animationRef = useRef (null) + const timeoutRef = useRef (null) + const rafRef = useRef (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 (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 (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 ( + 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)}> + + {text} + + {overflowPx >= MIN_MARQUEE_OVERFLOW_PX && ( + )} + ) +} + +export default ResponsiveMarqueeText diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index e98f2ef..a1f5c5d 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -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 = ({ 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 = ({ 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) && ( - + {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) ? ( tag.materialId == null && !(tag.hasDeerjikists) @@ -101,16 +105,17 @@ const TagLink: FC = ({ tag, )} {nestLevel > 0 && ( )} {tag.matchedAlias != null && ( <> - {tag.matchedAlias} + <> → )} @@ -118,18 +123,20 @@ const TagLink: FC = ({ tag, ? ( - {tag.name} + ) : ( - {tag.name} + )} {withCount && ( - {tag.postCount})} + {tag.postCount})} ) } diff --git a/frontend/src/components/TagSearchBox.tsx b/frontend/src/components/TagSearchBox.tsx index 634dd0e..fd64cf3 100644 --- a/frontend/src/components/TagSearchBox.tsx +++ b/frontend/src/components/TagSearchBox.tsx @@ -20,10 +20,12 @@ const TagSearchBox: FC = ({ suggestions, activeIndex, onSelect }) => { rounded shadow"> {suggestions.map ((tag, i) => (
  • onSelect (tag)}> - +
    + +
  • ))} ) } diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index 0008ef6..46fd6fe 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -64,8 +64,9 @@ const TagSidebar: FC = ({ posts, onClick }) => {
      {CATEGORIES.flatMap (cat => cat in tags ? ( tags[cat].map (tag => ( -
    • +
    • diff --git a/frontend/src/index.css b/frontend/src/index.css index f3709a4..e70403a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; +} -- 2.34.1 From 57905672f8ac480871eef2fe76ea938751fc6f83 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 1 Jul 2026 01:37:15 +0900 Subject: [PATCH 3/5] #76 --- .../src/components/ResponsiveMarqueeText.tsx | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/ResponsiveMarqueeText.tsx b/frontend/src/components/ResponsiveMarqueeText.tsx index dd3cfdb..366831d 100644 --- a/frontend/src/components/ResponsiveMarqueeText.tsx +++ b/frontend/src/components/ResponsiveMarqueeText.tsx @@ -12,7 +12,7 @@ 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 MARQUEE_SCROLL_PX_PER_SECOND = 43 const MIN_MARQUEE_OVERFLOW_PX = 1 @@ -100,14 +100,19 @@ const ResponsiveMarqueeText: FC = ({ text, className, title }) => { && animated != null) const resetAnimated = () => { + const node = animatedRef.current + animationRef.current?.cancel () animationRef.current = null + node?.getAnimations?.().forEach (animation => { + animation.cancel () + }) - if (!(animated)) + if (!(node)) return - animated.style.transition = 'none' - animated.style.transform = 'translateX(0)' + node.style.transition = 'none' + node.style.transform = 'translateX(0)' } if (!canMarquee) @@ -129,21 +134,15 @@ const ResponsiveMarqueeText: FC = ({ text, className, title }) => { }) const runLoop = async () => { - let started = false - while (!cancelled) { resetAnimated () - if (!started) - setMarqueeVisible (false) + setMarqueeVisible (true) 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 @@ -165,6 +164,11 @@ const ResponsiveMarqueeText: FC = ({ text, className, title }) => { { break } + + node.style.transform = `translateX(-${ overflowPx }px)` + animation.cancel () + if (animationRef.current === animation) + animationRef.current = null } else { @@ -178,6 +182,7 @@ const ResponsiveMarqueeText: FC = ({ text, className, title }) => { }) await sleep (moveDurationMs) node.style.transition = 'none' + node.style.transform = `translateX(-${ overflowPx }px)` } if (cancelled) -- 2.34.1 From 728ff117899a2281170dcb2e308c1fc92464d6be Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 1 Jul 2026 02:00:02 +0900 Subject: [PATCH 4/5] #76 --- frontend/src/components/TagLink.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index a1f5c5d..ede6088 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -43,14 +43,18 @@ const TagLink: FC = ({ 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 textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom' + const rootClass = + 'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline' + const markerWrapClass = 'shrink-0 self-start md:self-auto' + const countClass = 'shrink-0 self-end md:self-auto' const textTitle = title ?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias } → ${ tag.name }`) return ( - <> + {(linkFlg && withWiki) && ( - + {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) ? ( tag.materialId == null && !(tag.hasDeerjikists) @@ -117,7 +121,7 @@ const TagLink: FC = ({ tag, {...props}> - <> → + )} {linkFlg ? ( @@ -136,8 +140,8 @@ const TagLink: FC = ({ tag, )} {withCount && ( - {tag.postCount})} - ) + {tag.postCount})} + ) } export default TagLink -- 2.34.1 From 77294e8caa806672e7b05dbeb423e3aa8077c9d2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 2 Jul 2026 12:42:25 +0900 Subject: [PATCH 5/5] #76 --- frontend/src/components/MaterialSidebar.tsx | 10 +++++++++- .../src/components/ResponsiveMarqueeText.tsx | 13 ++++++++++--- frontend/src/components/TagLink.tsx | 17 ++++++++++++++--- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 399d9d8..8428c09 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -318,12 +318,20 @@ const MobileMaterialTreeNode: FC<{ depth?: number + [max-height:var(--tag-link-inline-size)] + [&_.tag-marquee]:block + [&_.tag-marquee]:h-full + [&_.tag-marquee]:overflow-hidden + [&_.tag-marquee__static]:h-full + [&_.tag-marquee__static]:overflow-hidden + [&_.tag-marquee__static]:text-ellipsis + [&_.tag-marquee__static]:[text-overflow:ellipsis]"/> {tag.hasChildren && (