diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11f784c..b4e9b04 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserRouter, Routes, useLocation } from 'react-router-dom' +import DevModeWatermark from '@/components/DevModeWatermark' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' @@ -145,6 +146,7 @@ const App: FC = () => { return ( <> + {import.meta.env.DEV && } @@ -152,7 +154,7 @@ const App: FC = () => { + className="relative flex flex-col h-dvh w-full overflow-y-hidden"> diff --git a/frontend/src/components/DevModeWatermark.tsx b/frontend/src/components/DevModeWatermark.tsx new file mode 100644 index 0000000..5de8c0b --- /dev/null +++ b/frontend/src/components/DevModeWatermark.tsx @@ -0,0 +1,46 @@ +import nikumaru from '@/assets/fonts/nikumaru.otf' + +import type { FC } from 'react' + + +const ROW_COUNT = 8 +const COLUMN_COUNT = 12 + + +const DevModeWatermark: FC = () => { + return ( + + + + {Array.from ({ length: ROW_COUNT }, (_, rowIndex) => ( + + {Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => ( + + 開発モード + ))} + ))} + + ) +} + + +export default DevModeWatermark diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index e6c49ff..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}> @@ -98,4 +101,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/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 && ( = ( + { text, className, truncateOnMobile = false, 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 = () => { + const node = animatedRef.current + + animationRef.current?.cancel () + animationRef.current = null + node?.getAnimations?.().forEach (animation => { + animation.cancel () + }) + + if (!(node)) + return + + node.style.transition = 'none' + node.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 () => { + while (!cancelled) + { + resetAnimated () + setMarqueeVisible (true) + + await sleep (MARQUEE_START_DELAY_MS) + if (cancelled || !(animatedRef.current)) + break + + 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 + } + + node.style.transform = `translateX(-${ overflowPx }px)` + animation.cancel () + if (animationRef.current === animation) + animationRef.current = null + } + 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' + node.style.transform = `translateX(-${ overflowPx }px)` + } + + 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', + (truncateOnMobile + ? 'overflow-hidden text-ellipsis whitespace-nowrap' + : 'whitespace-normal [overflow-wrap:anywhere]'), + 'md:overflow-hidden md:whitespace-nowrap', + className)}> + + {text} + + {overflowPx >= MIN_MARQUEE_OVERFLOW_PX && ( + + {text} + )} + ) +} + +export default ResponsiveMarqueeText diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index a32dd4a..180892b 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' @@ -9,6 +10,7 @@ import type { Tag } from '@/types' type CommonProps = { tag: Tag nestLevel?: number + truncateOnMobile?: boolean withWiki?: boolean withCount?: boolean } @@ -30,9 +32,11 @@ type Props = const TagLink: FC = ({ tag, nestLevel = 0, linkFlg = true, + truncateOnMobile = false, withWiki = true, withCount = true, className, + title, ...props }) => { const spanClass = cn ( `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, @@ -41,11 +45,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 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) @@ -100,33 +111,48 @@ const TagLink: FC = ({ tag, )} {nestLevel > 0 && ( ↳ )} {tag.matchedAlias != null && ( <> - - {tag.matchedAlias} + + - <> → > + → >)} {linkFlg ? ( - {tag.name} + ) : ( - - {tag.name} + )} {withCount && ( - {tag.postCount})} - >) + {tag.postCount})} + ) } export default TagLink diff --git a/frontend/src/components/TagSearchBox.tsx b/frontend/src/components/TagSearchBox.tsx index 795daee..fd64cf3 100644 --- a/frontend/src/components/TagSearchBox.tsx +++ b/frontend/src/components/TagSearchBox.tsx @@ -20,12 +20,14 @@ const TagSearchBox: FC = ({ suggestions, activeIndex, onSelect }) => { rounded shadow"> {suggestions.map ((tag, i) => ( onSelect (tag)}> - + + + ))} ) } -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..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 => ( - + @@ -128,4 +129,4 @@ const TagSidebar: FC = ({ posts, onClick }) => { ) } -export default TagSidebar \ No newline at end of file +export default TagSidebar 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; +}