Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
d144eeb44f
  1. 6
      src/components/Image/index.tsx
  2. 20
      src/components/Note/PublicationCoverImage.tsx
  3. 23
      src/hooks/useNearViewport.ts
  4. 17
      src/lib/gutenberg-cover.test.ts
  5. 15
      src/lib/gutenberg-cover.ts
  6. 56
      src/lib/utils.ts
  7. 27
      vite.config.ts

6
src/components/Image/index.tsx

@ -74,6 +74,7 @@ export default function Image({
style: wrapperStyleProp, style: wrapperStyleProp,
holdUntilClick = false, holdUntilClick = false,
fetchPriority, fetchPriority,
loading = 'eager',
onClick, onClick,
showAltCaption = false, showAltCaption = false,
caption, caption,
@ -97,6 +98,8 @@ export default function Image({
errorPlaceholder?: React.ReactNode errorPlaceholder?: React.ReactNode
/** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */ /** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */
fetchPriority?: 'high' | 'low' | 'auto' 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. * 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 * The first click runs {@link onClick} (e.g. open lightbox) and also reveals the
@ -386,8 +389,7 @@ export default function Image({
alt={finalAlt} alt={finalAlt}
referrerPolicy="no-referrer-when-downgrade" referrerPolicy="no-referrer-when-downgrade"
decoding="async" decoding="async"
// `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly. loading={loading}
loading="eager"
{...(fetchPriority ? { fetchpriority: fetchPriority } : {})} {...(fetchPriority ? { fetchpriority: fetchPriority } : {})}
draggable={false} draggable={false}
onLoad={handleLoad} onLoad={handleLoad}

20
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 { cn } from '@/lib/utils'
import { useRef } from 'react'
import Image from '../Image' import Image from '../Image'
/** Max cover height in the library grid (3-column cards). */ /** Max cover height in the library grid (3-column cards). */
@ -22,26 +25,35 @@ export default function PublicationCoverImage({
layout?: 'stacked' | 'row' layout?: 'stacked' | 'row'
className?: string className?: string
}) { }) {
const maxClass = size === 'library' ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS const isLibrary = size === 'library'
const wrapperRef = useRef<HTMLDivElement>(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 ( return (
<div <div
ref={wrapperRef}
className={cn( className={cn(
'flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted', 'flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted',
maxClass, maxClass,
layout === 'stacked' ? 'w-full' : 'w-full max-w-[9rem] sm:max-w-[10rem]', layout === 'stacked' ? 'aspect-[3/4] w-full' : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]',
layout === 'stacked' && size === 'default' && 'mb-3', layout === 'stacked' && size === 'default' && 'mb-3',
layout === 'stacked' && size === 'library' && 'mb-2', layout === 'stacked' && isLibrary && 'mb-2',
className className
)} )}
> >
{isNearViewport ? (
<Image <Image
image={{ url: imageUrl, pubkey }} image={{ url: resolvedUrl, pubkey }}
className={cn(maxClass, 'max-w-full object-contain')} className={cn(maxClass, 'max-w-full object-contain')}
classNames={{ wrapper: 'block w-full max-w-full' }} classNames={{ wrapper: 'block w-full max-w-full' }}
hideIfError hideIfError
holdUntilClick={!autoLoadMedia} holdUntilClick={!autoLoadMedia}
loading={isLibrary ? 'lazy' : 'eager'}
fetchPriority={isLibrary ? 'low' : undefined}
/> />
) : null}
</div> </div>
) )
} }

23
src/hooks/useNearViewport.ts

@ -1,22 +1,16 @@
import { elementIsNearVisibleScrollport, nearestScrollportRoot } from '@/lib/utils'
import { useEffect, useLayoutEffect, useState, type RefObject } from 'react' import { useEffect, useLayoutEffect, useState, type RefObject } from 'react'
/** Pixels beyond the viewport edge to treat as visible (prefetch before scroll lands). */ /** Pixels beyond the viewport edge to treat as visible (prefetch before scroll lands). */
export const NEAR_VIEWPORT_MARGIN_PX = 320 export const NEAR_VIEWPORT_MARGIN_PX = 320
/** @deprecated Prefer {@link elementIsNearVisibleScrollport} — respects nested scrollports. */
export function elementIsNearViewport(el: HTMLElement, marginPx: number): boolean { export function elementIsNearViewport(el: HTMLElement, marginPx: number): boolean {
const rect = el.getBoundingClientRect() return elementIsNearVisibleScrollport(el, marginPx)
const vh = window.innerHeight
const vw = window.innerWidth
return (
rect.bottom >= -marginPx &&
rect.top <= vh + marginPx &&
rect.right >= -marginPx &&
rect.left <= vw + 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). * When `enabled` is false, returns true immediately (no deferral).
*/ */
export function useNearViewport( export function useNearViewport(
@ -37,7 +31,7 @@ export function useNearViewport(
setIsNear(false) setIsNear(false)
return return
} }
if (elementIsNearViewport(el, marginPx)) { if (elementIsNearVisibleScrollport(el, marginPx)) {
setIsNear(true) setIsNear(true)
return return
} }
@ -55,13 +49,18 @@ export function useNearViewport(
const attach = () => { const attach = () => {
const el = ref.current const el = ref.current
if (!el || io) return if (!el || io) return
const scrollRoot = nearestScrollportRoot(el)
io = new IntersectionObserver( io = new IntersectionObserver(
(entries) => { (entries) => {
if (entries.some((e) => e.isIntersecting)) { if (entries.some((e) => e.isIntersecting)) {
setIsNear(true) setIsNear(true)
} }
}, },
{ root: null, rootMargin: `${marginPx}px`, threshold: 0.01 } {
root: scrollRoot ?? null,
rootMargin: `${marginPx}px`,
threshold: 0.01
}
) )
io.observe(el) io.observe(el)
} }

17
src/lib/gutenberg-cover.test.ts

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { import {
gutenbergCoverImageUrl, gutenbergCoverImageUrl,
gutenbergEbookPageUrl, gutenbergEbookPageUrl,
gutenbergLibraryCoverImageUrl,
normalizeGutenbergCoverImageUrl, normalizeGutenbergCoverImageUrl,
parseGutenbergEbookId, parseGutenbergEbookId,
parseGutenbergEbookIdFromDTag, parseGutenbergEbookIdFromDTag,
@ -20,10 +21,24 @@ describe('gutenberg-cover', () => {
).toBe('16702') ).toBe('16702')
}) })
it('builds medium cover URL', () => { it('builds medium cover URL by default', () => {
expect(gutenbergCoverImageUrl('58363')).toBe( expect(gutenbergCoverImageUrl('58363')).toBe(
'https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg' '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', () => { it('resolveGutenbergCoverImageUrl requires gutenberg source', () => {

15
src/lib/gutenberg-cover.ts

@ -18,9 +18,20 @@ export function parseGutenbergEbookId(source: string): string | null {
return 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() 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 { export function gutenbergEbookPageUrl(ebookId: string): string {

56
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() { export function isSupportCheckConnectionType() {
if (typeof window === 'undefined' || !(navigator as any).connection) return false if (typeof window === 'undefined' || !(navigator as any).connection) return false
return typeof (navigator as any).connection.type === 'string' return typeof (navigator as any).connection.type === 'string'

27
vite.config.ts

@ -528,8 +528,31 @@ export default defineConfig(({ mode }) => {
} }
}, },
{ {
// Project Gutenberg covers: bypass SW cache — CacheFirst can serve stale/truncated // Gutenberg cover JPGs are immutable; cache valid image responses only.
// HTML error bodies for .jpg URLs and the browser reports "image corrupt or truncated". 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, urlPattern: /^https:\/\/(?:www\.)?gutenberg\.org\//i,
handler: 'NetworkOnly' handler: 'NetworkOnly'
}, },

Loading…
Cancel
Save