show placeholders for loading img tags, this allows the entry to have its final height immediately

This commit is contained in:
Athou
2022-08-20 23:02:55 +02:00
parent 1f2a265c54
commit f81491fb32
10 changed files with 155 additions and 34 deletions

View File

@@ -23,6 +23,7 @@
"@reduxjs/toolkit": "^1.8.4",
"axios": "^0.27.2",
"dayjs": "^1.11.5",
"interweave": "^13.0.0",
"make-plural": "^7.1.0",
"mousetrap": "^1.6.5",
"react": "^18.2.0",
@@ -4583,6 +4584,11 @@
"node": ">=6"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -6114,6 +6120,21 @@
"node": ">= 0.4"
}
},
"node_modules/interweave": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/interweave/-/interweave-13.0.0.tgz",
"integrity": "sha512-Mckwj+ix/VtrZu1bRBIIohwrsXj12ZTvJCoYUMZlJmgtvIaQCj0i77eSZ63ckbA1TsPrz2VOvLW9/kTgm5d+mw==",
"dependencies": {
"escape-html": "^1.0.3"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/milesjohnson"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -12886,6 +12907,11 @@
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
},
"escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -13992,6 +14018,14 @@
"side-channel": "^1.0.4"
}
},
"interweave": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/interweave/-/interweave-13.0.0.tgz",
"integrity": "sha512-Mckwj+ix/VtrZu1bRBIIohwrsXj12ZTvJCoYUMZlJmgtvIaQCj0i77eSZ63ckbA1TsPrz2VOvLW9/kTgm5d+mw==",
"requires": {
"escape-html": "^1.0.3"
}
},
"invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",

View File

@@ -30,6 +30,7 @@
"@reduxjs/toolkit": "^1.8.4",
"axios": "^0.27.2",
"dayjs": "^1.11.5",
"interweave": "^13.0.0",
"make-plural": "^7.1.0",
"mousetrap": "^1.6.5",
"react": "^18.2.0",

View File

@@ -89,6 +89,7 @@ export const Constants = {
mobileBreakpoint: DEFAULT_THEME.breakpoints.md,
headerHeight: 60,
sidebarWidth: 350,
entryMaxWidth: 650,
isTopVisible: (div: HTMLDivElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight,
isBottomVisible: (div: HTMLDivElement) => div.getBoundingClientRect().bottom <= window.innerHeight,
},

View File

@@ -19,3 +19,9 @@ export function categoryUnreadCount(category?: Category): number {
.map(f => f.unread)
.reduce((total, current) => total + current, 0)
}
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
const placeholderWidth = width && Math.min(width, maxWidth)
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
return { width: placeholderWidth, height: placeholderHeight }
}

View File

@@ -0,0 +1,51 @@
import { Box, Center, createStyles } from "@mantine/core"
import { useState } from "react"
import { TbPhoto } from "react-icons/tb"
interface ImageWithPlaceholderWhileLoadingProps {
src?: string
alt?: string
title?: string
width?: number
height?: number | "auto"
placeholderWidth?: number
placeholderHeight?: number
}
const useStyles = createStyles((theme, props: ImageWithPlaceholderWhileLoadingProps) => ({
placeholder: {
width: props.placeholderWidth ?? 400,
height: props.placeholderHeight ?? 600,
maxWidth: "100%",
color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1],
},
}))
export function ImageWithPlaceholderWhileLoading(props: ImageWithPlaceholderWhileLoadingProps) {
const { classes } = useStyles(props)
const [loading, setLoading] = useState(true)
return (
<>
{loading && (
<Box>
<Center className={classes.placeholder}>
<div>
<TbPhoto size={48} />
</div>
</Center>
</Box>
)}
<img
src={props.src}
alt={props.alt}
title={props.title}
width={props.width}
height={props.height}
onLoad={() => setLoading(false)}
style={{ display: loading ? "none" : "block" }}
/>
</>
)
}

View File

@@ -1,4 +1,8 @@
import { createStyles, Text, TypographyStylesProvider } from "@mantine/core"
import { Box, createStyles, TypographyStylesProvider } from "@mantine/core"
import { Constants } from "app/constants"
import { calculatePlaceholderSize } from "app/utils"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { Interweave, TransformCallback } from "interweave"
export interface ContentProps {
content: string
@@ -11,10 +15,6 @@ const useStyles = createStyles(theme => ({
"& a": {
color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
},
"& img": {
maxWidth: "100%",
height: "auto",
},
"& iframe": {
maxWidth: "100%",
},
@@ -24,11 +24,42 @@ const useStyles = createStyles(theme => ({
},
}))
const transform: TransformCallback = node => {
if (node.tagName === "IMG") {
// show placeholders for loading img tags, this allows the entry to have its final height immediately
const src = node.getAttribute("src") ?? undefined
const alt = node.getAttribute("alt") ?? undefined
const title = node.getAttribute("title") ?? undefined
const width = node.getAttribute("width") ? parseInt(node.getAttribute("width")!, 10) : undefined
const height = node.getAttribute("height") ? parseInt(node.getAttribute("height")!, 10) : undefined
const placeholderSize = calculatePlaceholderSize({
width,
height,
maxWidth: Constants.layout.entryMaxWidth,
})
return (
<ImageWithPlaceholderWhileLoading
src={src}
alt={alt}
title={title}
width={width}
height="auto"
placeholderWidth={placeholderSize.width}
placeholderHeight={placeholderSize.height}
/>
)
}
return undefined
}
export function Content(props: ContentProps) {
const { classes } = useStyles()
return (
<TypographyStylesProvider>
<Text size="md" className={classes.content} dangerouslySetInnerHTML={{ __html: props.content }} />
<Box className={classes.content}>
<Interweave content={props.content} transform={transform} />
</Box>
</TypographyStylesProvider>
)
}

View File

@@ -1,19 +1,13 @@
import { createStyles } from "@mantine/core"
const useStyles = createStyles(() => ({
enclosureImage: {
maxWidth: "100%",
height: "auto",
},
}))
import { TypographyStylesProvider } from "@mantine/core"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
export function Enclosure(props: { enclosureType?: string; enclosureUrl?: string }) {
const { classes } = useStyles()
const hasVideo = props.enclosureType && props.enclosureType.indexOf("video") === 0
const hasAudio = props.enclosureType && props.enclosureType.indexOf("audio") === 0
const hasImage = props.enclosureType && props.enclosureType.indexOf("image") === 0
return (
<>
<TypographyStylesProvider>
{hasVideo && (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video controls>
@@ -26,7 +20,7 @@ export function Enclosure(props: { enclosureType?: string; enclosureUrl?: string
<source src={props.enclosureUrl} type={props.enclosureType} />
</audio>
)}
{hasImage && <img src={props.enclosureUrl} alt="enclosure" className={classes.enclosureImage} />}
</>
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />}
</TypographyStylesProvider>
)
}

View File

@@ -29,7 +29,7 @@ const useStyles = createStyles((theme, props: FeedEntryProps) => {
},
},
body: {
maxWidth: "650px",
maxWidth: Constants.layout.entryMaxWidth,
},
}
})

View File

@@ -1,4 +1,7 @@
import { Box, createStyles } from "@mantine/core"
import { Box, TypographyStylesProvider } from "@mantine/core"
import { Constants } from "app/constants"
import { calculatePlaceholderSize } from "app/utils"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { Content } from "./Content"
export interface MediaProps {
@@ -8,29 +11,29 @@ export interface MediaProps {
description?: string
}
const useStyles = createStyles(() => ({
image: {
maxWidth: "100%",
height: "auto",
},
}))
export function Media(props: MediaProps) {
const { classes } = useStyles()
const width = props.thumbnailWidth
const height = props.thumbnailHeight
const placeholderSize = calculatePlaceholderSize({
width,
height,
maxWidth: Constants.layout.entryMaxWidth,
})
return (
<>
<img
className={classes.image}
<TypographyStylesProvider>
<ImageWithPlaceholderWhileLoading
src={props.thumbnailUrl}
alt="media thumbnail"
width={props.thumbnailWidth}
height={props.thumbnailHeight}
alt="media thumbnail"
placeholderWidth={placeholderSize.width}
placeholderHeight={placeholderSize.height}
/>
{props.description && (
<Box pt="md">
<Content content={props.description} />
</Box>
)}
</>
</TypographyStylesProvider>
)
}

View File

@@ -50,7 +50,7 @@ app:
# if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser
# useful if commafeed is usually accessed through a restricting proxy
imageProxyEnabled: false
imageProxyEnabled: true
# database query timeout (in milliseconds), 0 to disable
queryTimeout: 0