diff --git a/src/app/components/MediaManager.tsx b/src/app/components/MediaManager.tsx index fd9027a..d92f03f 100644 --- a/src/app/components/MediaManager.tsx +++ b/src/app/components/MediaManager.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { AlignLeft, ArrowLeft, @@ -128,45 +128,69 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin // ─── MarqueeText ────────────────────────────────────────────────────────────── // Scrolls text left→right when it overflows its container, then back — ping-pong. -// Uses a CSS variable for the per-instance scroll distance so a single keyframe -// declaration covers every instance. +// Injects a unique per-instance @keyframes rule with the exact pixel offset so +// no CSS variables are needed (more reliable inside Radix portals). -if (typeof document !== 'undefined' && !document.getElementById('mm-marquee-kf')) { - const s = document.createElement('style'); - s.id = 'mm-marquee-kf'; - s.textContent = `@keyframes mm-scroll { - 0%,12% { transform: translateX(0) } - 88%,100%{ transform: translateX(var(--mm-dx,0px)) } - }`; - document.head.appendChild(s); -} +let _mmSeq = 0; function MarqueeText({ children, className }: { children?: string; className?: string }) { const wrapRef = useRef(null); - const textRef = useRef(null); - const [dx, setDx] = useState(0); + const idRef = useRef(`mm${++_mmSeq}`); + const [animCSS, setAnimCSS] = useState(''); - useLayoutEffect(() => { + useEffect(() => { + let cancelled = false; + const id = idRef.current; const wrap = wrapRef.current; - const text = textRef.current; - if (!wrap || !text) return; - const measure = () => setDx(Math.max(0, text.scrollWidth - wrap.offsetWidth)); - measure(); + if (!wrap) return; + + const measure = () => { + if (cancelled) return; + const ov = wrap.scrollWidth - wrap.clientWidth; + if (ov <= 0) { setAnimCSS(''); return; } + + // Inject / update the keyframe with the exact measured pixel offset. + let el = document.getElementById(`kf-${id}`) as HTMLStyleElement | null; + if (!el) { + el = document.createElement('style'); + el.id = `kf-${id}`; + document.head.appendChild(el); + } + el.textContent = `@keyframes ${id}{0%,12%{transform:translateX(0)}88%,100%{transform:translateX(-${ov}px)}}`; + + const secs = (Math.max(2000, ov * 20) / 1000).toFixed(2); + setAnimCSS(`${id} ${secs}s ease-in-out 0.8s infinite alternate`); + }; + + // Two nested RAFs ensure the portal element has a stable layout before we + // measure (one RAF fires before browser layout; two guarantees post-layout). + const r1 = requestAnimationFrame(() => { + const r2 = requestAnimationFrame(measure); + return () => cancelAnimationFrame(r2); + }); + const ro = new ResizeObserver(measure); ro.observe(wrap); - return () => ro.disconnect(); + + return () => { + cancelled = true; + cancelAnimationFrame(r1); + ro.disconnect(); + document.getElementById(`kf-${id}`)?.remove(); + setAnimCSS(''); + }; }, [children]); return ( - - 0 ? { - display: 'inline-block', - '--mm-dx': `-${dx}px`, - animation: `mm-scroll ${1.5 + dx / 80}s ease-in-out 1s infinite alternate`, - } as React.CSSProperties : undefined} - > + // overflow:hidden clips the scrolling text; minWidth:0 lets the element + // shrink inside grid/flex parents without forcing them to expand to the + // full text width (white-space:nowrap lives only on the inner span). + + {children} @@ -210,10 +234,14 @@ function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onDuplicate return ( !open && onClose()}> - - {entry?.name || '/'} + + {/* Keep DialogTitle for accessibility (aria-labelledby); styled text lives in the p below */} + {entry?.name || '/'} +

+ {entry?.name || '/'} +

- {isFolder ? 'Folder' : humanFileSize(entry?.size ?? 0)} + {isFolder ? 'Folder' : humanFileSize(entry?.size ?? 0)}
{entry && (