(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'
},