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(() => ({
|
const value = useMemo(() => ({
|
||||||
height: data?.blockHeight ?? blockHeight ?? 0
|
height: data?.blockHeight ?? blockHeight ?? 0
|
||||||
}), [data, blockHeight])
|
}), [data?.blockHeight, blockHeight])
|
||||||
return (
|
return (
|
||||||
<BlockHeightContext.Provider value={value}>
|
<BlockHeightContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -20,7 +20,7 @@ export const ChainFeeProvider = ({ chainFee, children }) => {
|
||||||
})
|
})
|
||||||
const value = useMemo(() => ({
|
const value = useMemo(() => ({
|
||||||
fee: Math.floor(data?.chainFee ?? chainFee ?? 0)
|
fee: Math.floor(data?.chainFee ?? chainFee ?? 0)
|
||||||
}), [data, chainFee])
|
}), [data?.chainFee, chainFee])
|
||||||
return (
|
return (
|
||||||
<ChainFeeContext.Provider value={value}>
|
<ChainFeeContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import styles from './text.module.css'
|
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 { IMGPROXY_URL_REGEXP } from '../lib/url'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { useMe } from './me'
|
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(() => {
|
const srcSet = useMemo(() => {
|
||||||
if (!srcSetObj) return undefined
|
if (!srcSetObj) return undefined
|
||||||
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
|
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
|
||||||
|
@ -82,19 +82,45 @@ function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...pr
|
||||||
}, { w: 0, url: undefined }).url
|
}, { w: 0, url: undefined }).url
|
||||||
}, [srcSetObj])
|
}, [srcSetObj])
|
||||||
|
|
||||||
|
const handleError = useCallback(onError, [onError])
|
||||||
|
const handleClick = useCallback(() => onClick(bestResSrc), [onClick, bestResSrc])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<Image
|
||||||
className={topLevel ? styles.topLevel : undefined}
|
className={topLevel ? styles.topLevel : undefined}
|
||||||
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
||||||
src={bestResSrc}
|
src={bestResSrc}
|
||||||
srcSet={srcSet}
|
srcSet={srcSet}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
onClick={() => onClick(bestResSrc)}
|
width={dimensions?.width}
|
||||||
onError={onError}
|
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 }) {
|
export default function ZoomableImage ({ src, srcSet, ...props }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
|
@ -125,13 +151,15 @@ export default function ZoomableImage ({ src, srcSet, ...props }) {
|
||||||
</Dropdown.Item>)
|
</Dropdown.Item>)
|
||||||
}), [showModal, originalUrl, styles])
|
}), [showModal, originalUrl, styles])
|
||||||
|
|
||||||
|
const handleError = useCallback(() => setImgproxy(false), [setImgproxy])
|
||||||
|
|
||||||
if (!src) return null
|
if (!src) return null
|
||||||
|
|
||||||
if (imgproxy) {
|
if (imgproxy) {
|
||||||
return (
|
return (
|
||||||
<ImageProxy
|
<ImageProxy
|
||||||
src={src} srcSet={srcSet}
|
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 { useRouter } from 'next/router'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { UNKNOWN_LINK_REL } from '../lib/constants'
|
import { UNKNOWN_LINK_REL } from '../lib/constants'
|
||||||
|
import isEqual from 'lodash/isEqual'
|
||||||
|
|
||||||
export function SearchText ({ text }) {
|
export function SearchText ({ text }) {
|
||||||
return (
|
return (
|
||||||
|
@ -34,7 +35,7 @@ export function SearchText ({ text }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is one of the slowest components to render
|
// 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 [overflowing, setOverflowing] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [show, setShow] = useState(false)
|
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 Heading = useCallback(({ children, node, ...props }) => {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const { noFragments, topLevel } = outerProps
|
|
||||||
const id = useMemo(() =>
|
const id = useMemo(() =>
|
||||||
noFragments ? undefined : slugger?.slug(toString(node).replace(/[^\w\-\s]+/gi, '')), [node, noFragments, slugger])
|
noFragments ? undefined : slugger?.slug(toString(node).replace(/[^\w\-\s]+/gi, '')), [node, noFragments, slugger])
|
||||||
const h = useMemo(() => {
|
const h = useMemo(() => {
|
||||||
|
@ -114,7 +114,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
||||||
</a>}
|
</a>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}, [outerProps, slugger.current])
|
}, [topLevel, noFragments, slugger.current])
|
||||||
|
|
||||||
const Table = useCallback(({ node, ...props }) =>
|
const Table = useCallback(({ node, ...props }) =>
|
||||||
<span className='table-responsive'>
|
<span className='table-responsive'>
|
||||||
|
@ -144,8 +144,8 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
const srcSet = imgproxyUrls?.[url]
|
const srcSet = imgproxyUrls?.[url]
|
||||||
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} {...outerProps} />
|
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel />
|
||||||
}, [imgproxyUrls, outerProps, tab])
|
}, [imgproxyUrls, topLevel, tab])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.text} ${show ? styles.textUncontained : overflowing ? styles.textContained : ''}`} ref={containerRef}>
|
<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)
|
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) {
|
if (youtube?.groups?.id) {
|
||||||
return (
|
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
|
<YouTube
|
||||||
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
|
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
|
||||||
playerVars: {
|
playerVars: {
|
||||||
|
@ -236,4 +236,4 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
||||||
</Button>}
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}, isEqual)
|
||||||
|
|
|
@ -129,22 +129,26 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.text img {
|
.text img {
|
||||||
|
--height: 35vh;
|
||||||
|
--width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
margin-bottom: .5rem;
|
margin-bottom: .5rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: min(var(--width), 100%);
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
max-height: 25vh;
|
height: auto;
|
||||||
|
max-height: min(var(--height), 25vh);
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
object-position: left top;
|
object-position: left top;
|
||||||
min-width: 50%;
|
min-width: 50%;
|
||||||
|
aspect-ratio: var(--aspect-ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text img.topLevel {
|
.text img.topLevel {
|
||||||
margin-top: .75rem;
|
margin-top: .75rem;
|
||||||
margin-bottom: .75rem;
|
margin-bottom: .75rem;
|
||||||
max-height: 35vh;
|
max-height: min(var(--height), 35vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
img.fullScreen {
|
img.fullScreen {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { createHmac } from 'node:crypto'
|
import { createHmac } from 'node:crypto'
|
||||||
import { extractUrls } from '../lib/md.js'
|
import { extractUrls } from '../lib/md.js'
|
||||||
import { isJob } from '../lib/item.js'
|
import { isJob } from '../lib/item.js'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
|
const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
|
||||||
(process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY)
|
(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)
|
console.log('[imgproxy] id:', id, '-- not image url:', url)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
imgproxyUrls[url] = {}
|
imgproxyUrls[url] = {
|
||||||
|
dimensions: await getDimensions(url)
|
||||||
|
}
|
||||||
for (const res of resolutions) {
|
for (const res of resolutions) {
|
||||||
const [w, h] = res.split('x')
|
const [w, h] = res.split('x')
|
||||||
const processingOptions = `/rs:fit:${w}:${h}`
|
const processingOptions = `/rs:fit:${w}:${h}`
|
||||||
imgproxyUrls[url][`${w}w`] = createImgproxyUrl(url, processingOptions)
|
imgproxyUrls[url][`${w}w`] = createImgproxyUrl({ url, options: processingOptions })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return imgproxyUrls
|
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 b64Url = Buffer.from(url, 'utf-8').toString('base64url')
|
||||||
const target = `${processingOptions}/${b64Url}`
|
const target = path.join(options, b64Url)
|
||||||
const signature = sign(target)
|
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 } = {}) {
|
async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) {
|
||||||
|
|
Loading…
Reference in New Issue