// Alphabet index for a library grid: sticky per-letter section headings // inside the grid itself, plus a right-edge A-Z rail to jump straight to // one - see docs/architecture.md. Library-only for now (that's the only // page that asked), but nothing below assumes anything Library-specific // beyond the two elements it measures (.libraryHeader, shell/Shell.html's // .bottomStack) - reusable elsewhere later without change. import './alphaIndex.css'; import { tileHtml } from './tiles'; import type { CleanupFn } from './router'; import type { ItemDto } from './types'; const ALL_BUCKETS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))]; /** * An item's index bucket: the first letter of its `sortName` - NOT * `name` - with accents folded to their base (so "Écho" lands on "E", * matching how a French speaker would expect the index to group it, not * off in its own "É"/"#" bucket). Anything else (a digit, empty) falls * back to "#". * * `sortName`, deliberately, not the display name: `items` arrives in * Emby's own SortName order (getLibraryItems/getArtists both request * `SortBy: SortName`), and that order strips leading articles ("Le * Voyage" sorts as "Voyage") - bucketing by the raw display name instead * put a "L" heading in the middle of a "V" run the one time an article * did that, reported directly as headers in no sensible order at all. * Bucketing by the exact same key Emby used to order the array is what * guarantees a bucket is always one contiguous run, not a guess that it * happens to line up. */ function bucketFor(item: ItemDto): string { const base = (item.sortName || item.name || '').trim().normalize('NFD').replace(/[̀-ͯ]/g, ''); const c = base.charAt(0).toUpperCase(); return c >= 'A' && c <= 'Z' ? c : '#'; } /** * Same job as tiles.ts's renderTilesInChunks (chunked insertAdjacentHTML, * not one big innerHTML write - see that function's own comment for why * that matters on a 1000+-item library), plus a `.letterHeading` inserted * right before the first tile of each new bucket. `items` is assumed * already ordered by `sortName` (see bucketFor) - a chunk boundary * landing mid-bucket is fine, it's still one contiguous run either way. */ export function renderGroupedTilesInChunks(container: HTMLElement, items: ItemDto[], chunkSize = 40): void { container.innerHTML = ''; let i = 0; let lastBucket: string | null = null; function renderNextChunk(): void { const chunk = items.slice(i, i + chunkSize); if (!chunk.length) return; let html = ''; for (const item of chunk) { const bucket = bucketFor(item); if (bucket !== lastBucket) { html += `
${bucket}
`; lastBucket = bucket; } html += tileHtml(item); } container.insertAdjacentHTML('beforeend', html); i += chunkSize; if (i < items.length) setTimeout(renderNextChunk, 0); } renderNextChunk(); } /** * Hides a `.letterHeading` whenever every tile in its run is currently * `.hide`'d by tiles.ts's wireSearchFilter - without this, searching * down to matches in only one bucket would leave every other bucket's * heading floating above nothing. Call once, after wireSearchFilter * itself is wired to the same input (listener order = registration * order, so this always runs after .hide has already been updated). */ export function wireSectionSearchSync(input: HTMLInputElement, grid: HTMLElement): void { input.addEventListener('input', () => { grid.querySelectorAll('.letterHeading').forEach((heading) => { let sib = heading.nextElementSibling; let anyVisible = false; while (sib && !sib.classList.contains('letterHeading')) { if (!sib.classList.contains('hide')) { anyVisible = true; break; } sib = sib.nextElementSibling; } heading.classList.toggle('hide', !anyVisible); }); }); } /** * The right-edge A-Z rail: tap or drag it to jump the page scroll to a * letter's section heading, with a big center-screen bubble showing the * current letter while doing so (fades out on release) - same * pointerdown/move/up-on-a-dedicated-element and touch-action: none shape * as shell/Shell.ts's own nav-sheet grabber handle, so a vertical drag * here doesn't get read as the browser's pull-to-refresh either. Letters * with nothing in `items` render dimmed and inert. * * The rail and every letterHeading's sticky offset are measured live off * .libraryHeader and .bottomStack rather than hardcoded: .bottomStack * (the music bar) can appear/resize while this page stays open - a * listen party starting - and it floats above the grid on desktop too, * not just mobile, so both need to stay clear of it regardless of * breakpoint. `getComputedStyle(libraryHeader).top` picks up whichever * of Library.css's own two breakpoint values is active without this * module needing to duplicate that 720px rule itself. * * Returns a CleanupFn (see router.ts) - the two ResizeObservers and the * window-level scroll/resize listeners this sets up would otherwise keep * running after the user navigates away, the exact leak class * docs/architecture.md already documents this app getting bitten by * once before. */ export function wireAlphaRail(root: HTMLElement, grid: HTMLElement, items: ItemDto[]): CleanupFn { const present = new Set(items.map((it) => bucketFor(it))); const libraryHeader = root.querySelector('.libraryHeader') as HTMLElement; const bottomStack = document.querySelector('.bottomStack') as HTMLElement | null; const rail = document.createElement('div'); rail.className = 'alphaRail'; rail.innerHTML = ALL_BUCKETS.map((letter) => { const has = present.has(letter); return `${letter}`; }).join(''); root.appendChild(rail); const bubble = document.createElement('div'); bubble.className = 'alphaBubble'; root.appendChild(bubble); function recomputeOffsets(): void { const top = parseFloat(getComputedStyle(libraryHeader).top) || 0; const stickyBottom = top + libraryHeader.offsetHeight; grid.style.setProperty('--section-sticky-top', `${stickyBottom}px`); rail.style.top = `${stickyBottom}px`; rail.style.bottom = `${bottomStack ? bottomStack.offsetHeight : 0}px`; } recomputeOffsets(); const resizeObserver = new ResizeObserver(recomputeOffsets); resizeObserver.observe(libraryHeader); if (bottomStack) resizeObserver.observe(bottomStack); window.addEventListener('resize', recomputeOffsets); let bubbleFadeTimer: ReturnType | null = null; function showBubble(letter: string): void { bubble.textContent = letter; bubble.classList.add('show'); if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer); } function scheduleHideBubble(): void { if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer); bubbleFadeTimer = setTimeout(() => bubble.classList.remove('show'), 260); } function setActive(letter: string): void { rail.querySelectorAll('.railLetter').forEach((el) => { el.classList.toggle('active', el.getAttribute('data-letter') === letter); }); } function letterAt(clientY: number): string { const rect = rail.getBoundingClientRect(); const ratio = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)); return ALL_BUCKETS[Math.min(ALL_BUCKETS.length - 1, Math.floor(ratio * ALL_BUCKETS.length))] as string; } function jumpTo(letter: string): void { setActive(letter); showBubble(letter); const target = grid.querySelector(`[data-letter-anchor="${letter}"]`); if (target) target.scrollIntoView({ block: 'start' }); } let dragging = false; function onPointerDown(e: PointerEvent): void { dragging = true; rail.setPointerCapture(e.pointerId); jumpTo(letterAt(e.clientY)); } function onPointerMove(e: PointerEvent): void { if (!dragging) return; jumpTo(letterAt(e.clientY)); } function endDrag(): void { if (!dragging) return; dragging = false; scheduleHideBubble(); } rail.addEventListener('pointerdown', onPointerDown); rail.addEventListener('pointermove', onPointerMove); rail.addEventListener('pointerup', endDrag); rail.addEventListener('pointercancel', endDrag); let scrollSpyScheduled = false; function onWindowScroll(): void { if (dragging || scrollSpyScheduled) return; scrollSpyScheduled = true; requestAnimationFrame(() => { scrollSpyScheduled = false; const stickyBottom = parseFloat(grid.style.getPropertyValue('--section-sticky-top')) || 0; let current = ALL_BUCKETS[0] as string; grid.querySelectorAll('[data-letter-anchor]').forEach((el) => { if (el.getBoundingClientRect().top - stickyBottom <= 4) current = el.getAttribute('data-letter-anchor') as string; }); setActive(current); }); } window.addEventListener('scroll', onWindowScroll, { passive: true }); setActive(ALL_BUCKETS[0] as string); return () => { resizeObserver.disconnect(); window.removeEventListener('resize', recomputeOffsets); window.removeEventListener('scroll', onWindowScroll); if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer); }; }