From d144eeb44fd15e0189c4d99ca43e2ac8fe9b5338 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 18:24:30 +0200 Subject: [PATCH] bug-fixes --- src/components/Image/index.tsx | 6 +- src/components/Note/PublicationCoverImage.tsx | 32 +++++++---- src/hooks/useNearViewport.ts | 23 ++++---- src/lib/gutenberg-cover.test.ts | 17 +++++- src/lib/gutenberg-cover.ts | 15 ++++- src/lib/utils.ts | 56 +++++++++++++++++++ vite.config.ts | 27 ++++++++- 7 files changed, 147 insertions(+), 29 deletions(-) diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 1031d8d6..7de660f1 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -74,6 +74,7 @@ export default function Image({ style: wrapperStyleProp, holdUntilClick = false, fetchPriority, + loading = 'eager', onClick, showAltCaption = false, caption, @@ -97,6 +98,8 @@ export default function Image({ errorPlaceholder?: React.ReactNode /** Passed to the inner `` (e.g. profile banner vs avatar load order). */ fetchPriority?: 'high' | 'low' | 'auto' + /** Native lazy loading — use `lazy` for below-the-fold grids; default `eager` for feeds. */ + loading?: 'lazy' | 'eager' /** * When true, the full image is not loaded until the user interacts. * The first click runs {@link onClick} (e.g. open lightbox) and also reveals the @@ -386,8 +389,7 @@ export default function Image({ alt={finalAlt} referrerPolicy="no-referrer-when-downgrade" decoding="async" - // `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly. - loading="eager" + loading={loading} {...(fetchPriority ? { fetchpriority: fetchPriority } : {})} draggable={false} onLoad={handleLoad} diff --git a/src/components/Note/PublicationCoverImage.tsx b/src/components/Note/PublicationCoverImage.tsx index a06f127f..a65f4d3d 100644 --- a/src/components/Note/PublicationCoverImage.tsx +++ b/src/components/Note/PublicationCoverImage.tsx @@ -1,4 +1,7 @@ +import { useNearViewport } from '@/hooks/useNearViewport' +import { gutenbergLibraryCoverImageUrl } from '@/lib/gutenberg-cover' import { cn } from '@/lib/utils' +import { useRef } from 'react' import Image from '../Image' /** Max cover height in the library grid (3-column cards). */ @@ -22,26 +25,35 @@ export default function PublicationCoverImage({ layout?: 'stacked' | 'row' className?: string }) { - const maxClass = size === 'library' ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS + const isLibrary = size === 'library' + const wrapperRef = useRef(null) + const isNearViewport = useNearViewport(wrapperRef, { enabled: isLibrary }) + const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS + const resolvedUrl = isLibrary ? gutenbergLibraryCoverImageUrl(imageUrl) : imageUrl return (
- + {isNearViewport ? ( + + ) : null}
) } diff --git a/src/hooks/useNearViewport.ts b/src/hooks/useNearViewport.ts index 7e7b8ab6..69dae7ca 100644 --- a/src/hooks/useNearViewport.ts +++ b/src/hooks/useNearViewport.ts @@ -1,22 +1,16 @@ +import { elementIsNearVisibleScrollport, nearestScrollportRoot } from '@/lib/utils' import { useEffect, useLayoutEffect, useState, type RefObject } from 'react' /** Pixels beyond the viewport edge to treat as visible (prefetch before scroll lands). */ export const NEAR_VIEWPORT_MARGIN_PX = 320 +/** @deprecated Prefer {@link elementIsNearVisibleScrollport} — respects nested scrollports. */ export function elementIsNearViewport(el: HTMLElement, marginPx: number): boolean { - const rect = el.getBoundingClientRect() - const vh = window.innerHeight - const vw = window.innerWidth - return ( - rect.bottom >= -marginPx && - rect.top <= vh + marginPx && - rect.right >= -marginPx && - rect.left <= vw + marginPx - ) + return elementIsNearVisibleScrollport(el, marginPx) } /** - * True when `ref` points at an element intersecting the viewport (with margin). + * True when `ref` points at an element intersecting the viewport or nearest scrollport (with margin). * When `enabled` is false, returns true immediately (no deferral). */ export function useNearViewport( @@ -37,7 +31,7 @@ export function useNearViewport( setIsNear(false) return } - if (elementIsNearViewport(el, marginPx)) { + if (elementIsNearVisibleScrollport(el, marginPx)) { setIsNear(true) return } @@ -55,13 +49,18 @@ export function useNearViewport( const attach = () => { const el = ref.current if (!el || io) return + const scrollRoot = nearestScrollportRoot(el) io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) { setIsNear(true) } }, - { root: null, rootMargin: `${marginPx}px`, threshold: 0.01 } + { + root: scrollRoot ?? null, + rootMargin: `${marginPx}px`, + threshold: 0.01 + } ) io.observe(el) } diff --git a/src/lib/gutenberg-cover.test.ts b/src/lib/gutenberg-cover.test.ts index 3e3cfbeb..6a17048d 100644 --- a/src/lib/gutenberg-cover.test.ts +++ b/src/lib/gutenberg-cover.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { gutenbergCoverImageUrl, gutenbergEbookPageUrl, + gutenbergLibraryCoverImageUrl, normalizeGutenbergCoverImageUrl, parseGutenbergEbookId, parseGutenbergEbookIdFromDTag, @@ -20,10 +21,24 @@ describe('gutenberg-cover', () => { ).toBe('16702') }) - it('builds medium cover URL', () => { + it('builds medium cover URL by default', () => { expect(gutenbergCoverImageUrl('58363')).toBe( 'https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg' ) + expect(gutenbergCoverImageUrl('58363', 'small')).toBe( + 'https://www.gutenberg.org/cache/epub/58363/pg58363.cover.small.jpg' + ) + }) + + it('gutenbergLibraryCoverImageUrl prefers small covers for library grids', () => { + expect( + gutenbergLibraryCoverImageUrl( + 'https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg' + ) + ).toBe('https://www.gutenberg.org/cache/epub/58363/pg58363.cover.small.jpg') + expect(gutenbergLibraryCoverImageUrl('https://example.com/cover.jpg')).toBe( + 'https://example.com/cover.jpg' + ) }) it('resolveGutenbergCoverImageUrl requires gutenberg source', () => { diff --git a/src/lib/gutenberg-cover.ts b/src/lib/gutenberg-cover.ts index 2add5f77..d746f919 100644 --- a/src/lib/gutenberg-cover.ts +++ b/src/lib/gutenberg-cover.ts @@ -18,9 +18,20 @@ export function parseGutenbergEbookId(source: string): string | null { return null } -export function gutenbergCoverImageUrl(ebookId: string): string { +export type GutenbergCoverSize = 'small' | 'medium' + +export function gutenbergCoverImageUrl(ebookId: string, size: GutenbergCoverSize = 'medium'): string { const id = ebookId.trim() - return `https://www.gutenberg.org/cache/epub/${id}/pg${id}.cover.medium.jpg` + return `https://www.gutenberg.org/cache/epub/${id}/pg${id}.cover.${size}.jpg` +} + +/** Use smaller cover art in library grids (faster download, sufficient at card size). */ +export function gutenbergLibraryCoverImageUrl(url: string): string { + const trimmed = url.trim() + if (!trimmed.toLowerCase().includes('gutenberg')) return trimmed + const id = parseGutenbergEbookId(trimmed) + if (!id) return trimmed + return gutenbergCoverImageUrl(id, 'small') } export function gutenbergEbookPageUrl(ebookId: string): string { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 75891e94..3aaeb632 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -82,6 +82,62 @@ export function isPartiallyInViewport(el: HTMLElement) { ) } +/** Nearest ancestor that scrolls — use as IntersectionObserver root in nested panes. */ +export function nearestScrollportRoot(el: HTMLElement | null): Element | undefined { + if (!el) return undefined + let cur: HTMLElement | null = el.parentElement + while (cur && cur !== document.documentElement) { + const st = window.getComputedStyle(cur) + const oy = st.overflowY + const ox = st.overflowX + if ( + oy === 'auto' || + oy === 'scroll' || + oy === 'overlay' || + ox === 'auto' || + ox === 'scroll' || + ox === 'overlay' + ) { + return cur + } + cur = cur.parentElement + } + return undefined +} + +function rectsOverlap(a: DOMRectReadOnly, b: DOMRectReadOnly): boolean { + return a.bottom > b.top && a.top < b.bottom && a.right > b.left && a.left < b.right +} + +/** True when `el` intersects the viewport or its nearest scrollport (with margin). */ +export function elementIsNearVisibleScrollport(el: HTMLElement, marginPx: number): boolean { + const elRect = el.getBoundingClientRect() + const root = nearestScrollportRoot(el) + if (root) { + const rootRect = root.getBoundingClientRect() + const expanded = { + top: rootRect.top - marginPx, + bottom: rootRect.bottom + marginPx, + left: rootRect.left - marginPx, + right: rootRect.right + marginPx + } + return ( + elRect.bottom >= expanded.top && + elRect.top <= expanded.bottom && + elRect.right >= expanded.left && + elRect.left <= expanded.right + ) + } + const vh = window.innerHeight + const vw = window.innerWidth + return ( + elRect.bottom >= -marginPx && + elRect.top <= vh + marginPx && + elRect.right >= -marginPx && + elRect.left <= vw + marginPx + ) +} + export function isSupportCheckConnectionType() { if (typeof window === 'undefined' || !(navigator as any).connection) return false return typeof (navigator as any).connection.type === 'string' diff --git a/vite.config.ts b/vite.config.ts index c7333369..208e2d8c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -528,8 +528,31 @@ export default defineConfig(({ mode }) => { } }, { - // Project Gutenberg covers: bypass SW cache — CacheFirst can serve stale/truncated - // HTML error bodies for .jpg URLs and the browser reports "image corrupt or truncated". + // Gutenberg cover JPGs are immutable; cache valid image responses only. + urlPattern: + /^https:\/\/(?:www\.)?gutenberg\.org\/cache\/epub\/\d+\/pg\d+\.cover\.(?:small|medium|large)\.jpg$/i, + handler: 'CacheFirst', + options: { + cacheName: 'gutenberg-covers', + expiration: { + maxEntries: 500, + maxAgeSeconds: 30 * 24 * 60 * 60 + }, + cacheableResponse: { statuses: [200] }, + plugins: [ + { + cacheWillUpdate: async ({ response }: { response: Response | undefined }) => { + if (!response?.ok) return null + const ct = (response.headers.get('content-type') ?? '').toLowerCase() + if (!ct.includes('image/')) return null + return response + } + } + ] + } + }, + { + // Other Gutenberg pages (ebooks HTML, etc.) — never cache as images. urlPattern: /^https:\/\/(?:www\.)?gutenberg\.org\//i, handler: 'NetworkOnly' },