From f81491fb3226c2883bf6326bc09cc23a91e7a812 Mon Sep 17 00:00:00 2001 From: Athou Date: Sat, 20 Aug 2022 23:02:55 +0200 Subject: [PATCH] show placeholders for loading img tags, this allows the entry to have its final height immediately --- commafeed-client/package-lock.json | 34 +++++++++++++ commafeed-client/package.json | 1 + commafeed-client/src/app/constants.ts | 1 + commafeed-client/src/app/utils.ts | 6 +++ .../ImageWithPlaceholderWhileLoading.tsx | 51 +++++++++++++++++++ .../src/components/content/Content.tsx | 43 +++++++++++++--- .../src/components/content/Enclosure.tsx | 18 +++---- .../src/components/content/FeedEntry.tsx | 2 +- .../src/components/content/Media.tsx | 31 ++++++----- commafeed-server/config.dev.yml | 2 +- 10 files changed, 155 insertions(+), 34 deletions(-) create mode 100644 commafeed-client/src/components/ImageWithPlaceholderWhileLoading.tsx diff --git a/commafeed-client/package-lock.json b/commafeed-client/package-lock.json index dfb77cd3..06f3c7e7 100644 --- a/commafeed-client/package-lock.json +++ b/commafeed-client/package-lock.json @@ -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", diff --git a/commafeed-client/package.json b/commafeed-client/package.json index b851b88e..9558aa79 100644 --- a/commafeed-client/package.json +++ b/commafeed-client/package.json @@ -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", diff --git a/commafeed-client/src/app/constants.ts b/commafeed-client/src/app/constants.ts index 0f2f11c2..680c7e80 100644 --- a/commafeed-client/src/app/constants.ts +++ b/commafeed-client/src/app/constants.ts @@ -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, }, diff --git a/commafeed-client/src/app/utils.ts b/commafeed-client/src/app/utils.ts index 7d9cf323..6a0d32a8 100644 --- a/commafeed-client/src/app/utils.ts +++ b/commafeed-client/src/app/utils.ts @@ -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 } +} diff --git a/commafeed-client/src/components/ImageWithPlaceholderWhileLoading.tsx b/commafeed-client/src/components/ImageWithPlaceholderWhileLoading.tsx new file mode 100644 index 00000000..5bde1400 --- /dev/null +++ b/commafeed-client/src/components/ImageWithPlaceholderWhileLoading.tsx @@ -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 && ( + +
+
+ +
+
+
+ )} + {props.alt} setLoading(false)} + style={{ display: loading ? "none" : "block" }} + /> + + ) +} diff --git a/commafeed-client/src/components/content/Content.tsx b/commafeed-client/src/components/content/Content.tsx index 82aff853..c89e61aa 100644 --- a/commafeed-client/src/components/content/Content.tsx +++ b/commafeed-client/src/components/content/Content.tsx @@ -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 ( + + ) + } + return undefined +} + export function Content(props: ContentProps) { const { classes } = useStyles() + return ( - + + + ) } diff --git a/commafeed-client/src/components/content/Enclosure.tsx b/commafeed-client/src/components/content/Enclosure.tsx index 5b6641b2..46bb9c18 100644 --- a/commafeed-client/src/components/content/Enclosure.tsx +++ b/commafeed-client/src/components/content/Enclosure.tsx @@ -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 ( - <> + {hasVideo && ( // eslint-disable-next-line jsx-a11y/media-has-caption ) } diff --git a/commafeed-client/src/components/content/FeedEntry.tsx b/commafeed-client/src/components/content/FeedEntry.tsx index ddb8e9a6..793d09dd 100644 --- a/commafeed-client/src/components/content/FeedEntry.tsx +++ b/commafeed-client/src/components/content/FeedEntry.tsx @@ -29,7 +29,7 @@ const useStyles = createStyles((theme, props: FeedEntryProps) => { }, }, body: { - maxWidth: "650px", + maxWidth: Constants.layout.entryMaxWidth, }, } }) diff --git a/commafeed-client/src/components/content/Media.tsx b/commafeed-client/src/components/content/Media.tsx index 010ab622..231136e1 100644 --- a/commafeed-client/src/components/content/Media.tsx +++ b/commafeed-client/src/components/content/Media.tsx @@ -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 ( - <> - + {props.description && ( )} - + ) } diff --git a/commafeed-server/config.dev.yml b/commafeed-server/config.dev.yml index e98bdb7a..ecbfc2cb 100644 --- a/commafeed-server/config.dev.yml +++ b/commafeed-server/config.dev.yml @@ -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