Fix image rerender jitter and layout shift (#896)
* fix image jitter and layout shift * prevent unecessary context rerenders
This commit is contained in:
parent
48aef15a07
commit
2fc1ef44dd
|
@ -20,7 +20,7 @@ export const BlockHeightProvider = ({ blockHeight, children }) => {
|
|||
})
|
||||
const value = useMemo(() => ({
|
||||
height: data?.blockHeight ?? blockHeight ?? 0
|
||||
}), [data, blockHeight])
|
||||
}), [data?.blockHeight, blockHeight])
|
||||
return (
|
||||
<BlockHeightContext.Provider value={value}>
|
||||
{children}
|
||||
|
|
|
@ -20,7 +20,7 @@ export const ChainFeeProvider = ({ chainFee, children }) => {
|
|||
})
|
||||
const value = useMemo(() => ({
|
||||
fee: Math.floor(data?.chainFee ?? chainFee ?? 0)
|
||||
}), [data, chainFee])
|
||||
}), [data?.chainFee, chainFee])
|
||||
return (
|
||||
<ChainFeeContext.Provider value={value}>
|
||||
{children}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import styles from './text.module.css'
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef } from 'react'
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef, memo } from 'react'
|
||||
import { IMGPROXY_URL_REGEXP } from '../lib/url'
|
||||
import { useShowModal } from './modal'
|
||||
import { useMe } from './me'
|
||||
|
@ -63,7 +63,7 @@ function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }
|
|||
}
|
||||
}
|
||||
|
||||
function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...props }) {
|
||||
function ImageProxy ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick, topLevel, onError, ...props }) {
|
||||
const srcSet = useMemo(() => {
|
||||
if (!srcSetObj) return undefined
|
||||
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
|
||||
|
@ -82,19 +82,45 @@ function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...pr
|
|||
}, { w: 0, url: undefined }).url
|
||||
}, [srcSetObj])
|
||||
|
||||
const handleError = useCallback(onError, [onError])
|
||||
const handleClick = useCallback(() => onClick(bestResSrc), [onClick, bestResSrc])
|
||||
|
||||
return (
|
||||
<img
|
||||
<Image
|
||||
className={topLevel ? styles.topLevel : undefined}
|
||||
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
||||
src={bestResSrc}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
onClick={() => onClick(bestResSrc)}
|
||||
onError={onError}
|
||||
width={dimensions?.width}
|
||||
height={dimensions?.height}
|
||||
onClick={handleClick}
|
||||
onError={handleError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Image = memo(({ className, src, srcSet, sizes, width, height, bestResSrc, onClick, onError }) => {
|
||||
const style = width && height
|
||||
? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width / height}` }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
||||
src={bestResSrc}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
width={width}
|
||||
height={height}
|
||||
onClick={onClick}
|
||||
onError={onError}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default function ZoomableImage ({ src, srcSet, ...props }) {
|
||||
const showModal = useShowModal()
|
||||
|
||||
|
@ -125,13 +151,15 @@ export default function ZoomableImage ({ src, srcSet, ...props }) {
|
|||
</Dropdown.Item>)
|
||||
}), [showModal, originalUrl, styles])
|
||||
|
||||
const handleError = useCallback(() => setImgproxy(false), [setImgproxy])
|
||||
|
||||
if (!src) return null
|
||||
|
||||
if (imgproxy) {
|
||||
return (
|
||||
<ImageProxy
|
||||
src={src} srcSet={srcSet}
|
||||
onClick={handleClick} onError={() => setImgproxy(false)} {...props}
|
||||
onClick={handleClick} onError={handleError} {...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import { Button } from 'react-bootstrap'
|
|||
import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import { UNKNOWN_LINK_REL } from '../lib/constants'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
|
||||
export function SearchText ({ text }) {
|
||||
return (
|
||||
|
@ -34,7 +35,7 @@ export function SearchText ({ text }) {
|
|||
}
|
||||
|
||||
// this is one of the slowest components to render
|
||||
export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, ...outerProps }) {
|
||||
export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, topLevel, noFragments }) {
|
||||
const [overflowing, setOverflowing] = useState(false)
|
||||
const router = useRouter()
|
||||
const [show, setShow] = useState(false)
|
||||
|
@ -79,7 +80,6 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
|
||||
const Heading = useCallback(({ children, node, ...props }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { noFragments, topLevel } = outerProps
|
||||
const id = useMemo(() =>
|
||||
noFragments ? undefined : slugger?.slug(toString(node).replace(/[^\w\-\s]+/gi, '')), [node, noFragments, slugger])
|
||||
const h = useMemo(() => {
|
||||
|
@ -114,7 +114,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
</a>}
|
||||
</span>
|
||||
)
|
||||
}, [outerProps, slugger.current])
|
||||
}, [topLevel, noFragments, slugger.current])
|
||||
|
||||
const Table = useCallback(({ node, ...props }) =>
|
||||
<span className='table-responsive'>
|
||||
|
@ -144,8 +144,8 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
return url
|
||||
}
|
||||
const srcSet = imgproxyUrls?.[url]
|
||||
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} {...outerProps} />
|
||||
}, [imgproxyUrls, outerProps, tab])
|
||||
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel />
|
||||
}, [imgproxyUrls, topLevel, tab])
|
||||
|
||||
return (
|
||||
<div className={`${styles.text} ${show ? styles.textUncontained : overflowing ? styles.textContained : ''}`} ref={containerRef}>
|
||||
|
@ -208,7 +208,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
const youtube = href.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)((?:\?|&)(?:t|start)=(?<start>\d+))?/i)
|
||||
if (youtube?.groups?.id) {
|
||||
return (
|
||||
<div style={{ maxWidth: outerProps.topLevel ? '640px' : '320px', paddingRight: '15px', margin: '0.5rem 0' }}>
|
||||
<div style={{ maxWidth: topLevel ? '640px' : '320px', paddingRight: '15px', margin: '0.5rem 0' }}>
|
||||
<YouTube
|
||||
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
|
||||
playerVars: {
|
||||
|
@ -236,4 +236,4 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
</Button>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}, isEqual)
|
||||
|
|
|
@ -129,22 +129,26 @@
|
|||
}
|
||||
|
||||
.text img {
|
||||
--height: 35vh;
|
||||
--width: 100%;
|
||||
display: block;
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-width: min(var(--width), 100%);
|
||||
cursor: zoom-in;
|
||||
max-height: 25vh;
|
||||
height: auto;
|
||||
max-height: min(var(--height), 25vh);
|
||||
object-fit: contain;
|
||||
object-position: left top;
|
||||
min-width: 50%;
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
}
|
||||
|
||||
.text img.topLevel {
|
||||
margin-top: .75rem;
|
||||
margin-bottom: .75rem;
|
||||
max-height: 35vh;
|
||||
max-height: min(var(--height), 35vh);
|
||||
}
|
||||
|
||||
img.fullScreen {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { createHmac } from 'node:crypto'
|
||||
import { extractUrls } from '../lib/md.js'
|
||||
import { isJob } from '../lib/item.js'
|
||||
import path from 'node:path'
|
||||
|
||||
const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
|
||||
(process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY)
|
||||
|
@ -100,21 +101,31 @@ export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
|
|||
console.log('[imgproxy] id:', id, '-- not image url:', url)
|
||||
continue
|
||||
}
|
||||
imgproxyUrls[url] = {}
|
||||
imgproxyUrls[url] = {
|
||||
dimensions: await getDimensions(url)
|
||||
}
|
||||
for (const res of resolutions) {
|
||||
const [w, h] = res.split('x')
|
||||
const processingOptions = `/rs:fit:${w}:${h}`
|
||||
imgproxyUrls[url][`${w}w`] = createImgproxyUrl(url, processingOptions)
|
||||
imgproxyUrls[url][`${w}w`] = createImgproxyUrl({ url, options: processingOptions })
|
||||
}
|
||||
}
|
||||
return imgproxyUrls
|
||||
}
|
||||
|
||||
const createImgproxyUrl = (url, processingOptions) => {
|
||||
const getDimensions = async (url) => {
|
||||
const options = '/d:1'
|
||||
const imgproxyUrl = createImgproxyUrl({ url, options, pathname: 'info' })
|
||||
const res = await fetch(imgproxyUrl)
|
||||
const { width, height } = await res.json()
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const createImgproxyUrl = ({ url, pathname = '', options }) => {
|
||||
const b64Url = Buffer.from(url, 'utf-8').toString('base64url')
|
||||
const target = `${processingOptions}/${b64Url}`
|
||||
const target = path.join(options, b64Url)
|
||||
const signature = sign(target)
|
||||
return `${IMGPROXY_URL}${signature}${target}`
|
||||
return new URL(path.join(pathname, signature, target), IMGPROXY_URL).toString()
|
||||
}
|
||||
|
||||
async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) {
|
||||
|
|
Loading…
Reference in New Issue