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({ @@ -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({ @@ -97,6 +98,8 @@ export default function Image({
errorPlaceholder?: React.ReactNode
/** Passed to the inner `<img>` (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({ @@ -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}

20
src/components/Note/PublicationCoverImage.tsx

@ -1,4 +1,7 @@ @@ -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({ @@ -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<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 (
<div
ref={wrapperRef}
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted',
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 === 'library' && 'mb-2',
layout === 'stacked' && isLibrary && 'mb-2',
className
)}
>
{isNearViewport ? (
<Image
image={{ url: imageUrl, pubkey }}
image={{ url: resolvedUrl, pubkey }}
className={cn(maxClass, 'max-w-full object-contain')}
classNames={{ wrapper: 'block w-full max-w-full' }}
hideIfError
holdUntilClick={!autoLoadMedia}
loading={isLibrary ? 'lazy' : 'eager'}
fetchPriority={isLibrary ? 'low' : undefined}
/>
) : null}
</div>
)
}

23
src/hooks/useNearViewport.ts

@ -1,22 +1,16 @@ @@ -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( @@ -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( @@ -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)
}

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

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
gutenbergCoverImageUrl,
gutenbergEbookPageUrl,
gutenbergLibraryCoverImageUrl,
normalizeGutenbergCoverImageUrl,
parseGutenbergEbookId,
parseGutenbergEbookIdFromDTag,
@ -20,10 +21,24 @@ describe('gutenberg-cover', () => { @@ -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', () => {

15
src/lib/gutenberg-cover.ts

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

56
src/lib/utils.ts

@ -82,6 +82,62 @@ export function isPartiallyInViewport(el: HTMLElement) { @@ -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'

27
vite.config.ts

@ -528,8 +528,31 @@ export default defineConfig(({ mode }) => { @@ -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'
},

Loading…
Cancel
Save