From 038a3e5939789f9ac7e62c0d573433413eb6c7cc Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Wed, 11 Dec 2024 18:58:46 +0000 Subject: [PATCH] Improved ActivityPub reader view (#21854) ref https://linear.app/ghost/issue/AP-633/reader-view-customization-options, https://linear.app/ghost/issue/AP-631/estimated-reading-time-in-reader-view - Added reader view customization options (typefaces, font sizes and line height) which allow users to make the reading experience suit their personal needs and taste. Changing the font size also subtly tweaks the spacing and width of the articles. - Added an estimated reading time and a simple text-based progress indicator, so users have a better idea of the article length and their progress when reading long-form content. --- apps/admin-x-activitypub/package.json | 2 +- .../src/components/articleBodyStyles.ts | 13 +- .../src/components/feed/ArticleModal.tsx | 495 ++++++++++++++---- .../src/utils/get-reading-time.ts | 2 +- .../src/assets/icons/substract.svg | 1 + .../src/assets/icons/typography.svg | 1 + 6 files changed, 397 insertions(+), 117 deletions(-) create mode 100644 apps/admin-x-design-system/src/assets/icons/substract.svg create mode 100644 apps/admin-x-design-system/src/assets/icons/typography.svg diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 6f6b49c59b..22174dfda7 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.36", + "version": "0.3.37", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/articleBodyStyles.ts b/apps/admin-x-activitypub/src/components/articleBodyStyles.ts index 4741591060..bda970052c 100644 --- a/apps/admin-x-activitypub/src/components/articleBodyStyles.ts +++ b/apps/admin-x-activitypub/src/components/articleBodyStyles.ts @@ -23,6 +23,7 @@ const articleBodyStyles = (siteUrl: string|undefined) => { --container-width: 1320px; --container-gap: clamp(24px, 1.7032rem + 1.9355vw, 48px); --grid-gap: 42px; + --font-size: 17px; /* Default font size */ } :root.has-light-text, @@ -74,6 +75,7 @@ p, h1, h2, h3, h4, h5, h6 { } h1, h2, h3, h4, h5, h6 { + font-family: var(--font-sans); line-height: 1.2; } @@ -310,14 +312,14 @@ button.gh-form-input { .gh-article-title { font-weight: 700; text-wrap: pretty; - font-size: 3rem; + font-size: calc(3rem * var(--content-spacing-factor, 1)); letter-spacing: -0.021em; line-height: 1.4; } .gh-article-excerpt { margin-top: 16px; - font-size: 2rem; + font-size: calc(2rem * var(--content-spacing-factor, 1)); line-height: 1.4; letter-spacing: -0.017em; text-wrap: pretty; @@ -340,10 +342,11 @@ created within the Ghost editor. The main content handles headings, text, images and lists. We deal with cards lower down. */ .gh-content { - font-size: 17px; + font-size: var(--font-size); overflow-x: hidden; letter-spacing: -0.013em; - line-height: 1.6; + line-height: var(--line-height); + font-family: var(--font-family); } /* Default vertical spacing */ @@ -399,7 +402,7 @@ unless a heading is the very first element in the post content */ } .gh-content h2 { - font-size: 2.4rem; + font-size: calc(2.4rem * var(--content-spacing-factor, 1)); letter-spacing: -0.019em; line-height: 1.4166666667; } diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index 96a8c1cce8..760f1df0a4 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -1,21 +1,21 @@ import FeedItem from './FeedItem'; import FeedItemStats from './FeedItemStats'; import NiceModal from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import articleBodyStyles from '../articleBodyStyles'; import getUsername from '../../utils/get-username'; import {type Activity} from '../activities/ActivityItem'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; -import {Button, LoadingIndicator, Modal} from '@tryghost/admin-x-design-system'; +import {Button, LoadingIndicator, Modal, Popover, Select, SelectOption} from '@tryghost/admin-x-design-system'; import {renderTimestamp} from '../../utils/render-timestamp'; import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; -import {useEffect, useRef, useState} from 'react'; import {useModal} from '@ebay/nice-modal-react'; import {useThreadForUser} from '../../hooks/useActivityPubQueries'; import APAvatar from '../global/APAvatar'; import APReplyBox from '../global/APReplyBox'; +import getReadingTime from '../../utils/get-reading-time'; interface ArticleModalProps { activityId: string; @@ -33,122 +33,191 @@ interface ArticleModalProps { }[]; } -const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string}> = ({heading, image, excerpt, html}) => { +interface IframeWindow extends Window { + resizeIframe?: () => void; +} + +const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string, fontSize: FontSize, lineHeight: string, fontFamily: SelectOption}> = ({ + heading, + image, + excerpt, + html, + fontSize, + lineHeight, + fontFamily +}) => { const site = useBrowseSite(); const siteData = site.data?.site; const iframeRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); const [iframeHeight, setIframeHeight] = useState('0px'); const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, '')); const htmlContent = ` - - - ${cssContent} - - - - -
-

${heading}

- ${excerpt ? ` -

${excerpt}

- ` : ''} - ${image ? ` -
- ${heading} -
- ` : ''} -
-
- ${html} -
- - -`; + waitForImages().then(() => { + isFullyLoaded = true; + resizeIframe(); + }); + } + + window.addEventListener('DOMContentLoaded', initializeResize); + window.addEventListener('load', resizeIframe); + window.addEventListener('resize', resizeIframe); + new MutationObserver(resizeIframe).observe(document.body, { subtree: true, childList: true }); + + window.addEventListener('message', (event) => { + if (event.data.type === 'triggerResize') { + resizeIframe(); + } + }); + + + +
+

${heading}

+ ${excerpt ? ` +

${excerpt}

+ ` : ''} + ${image ? ` +
+ ${heading} +
+ ` : ''} +
+
+ ${html} +
+ + + `; useEffect(() => { const iframe = iframeRef.current; - if (iframe) { - iframe.srcdoc = htmlContent; - - const handleMessage = (event: MessageEvent) => { - if (event.data.type === 'resize') { - setIframeHeight(`${event.data.height}px`); - iframe.style.height = `${event.data.height}px`; - } - }; - - window.addEventListener('message', handleMessage); - - return () => { - window.removeEventListener('message', handleMessage); - }; + if (!iframe) { + return; } + + if (!iframe.srcdoc) { + iframe.srcdoc = htmlContent; + } + + const handleMessage = (event: MessageEvent) => { + if (event.data.type === 'resize') { + const newHeight = `${event.data.bodyHeight + 24}px`; + setIframeHeight(newHeight); + iframe.style.height = newHeight; + + if (event.data.isLoaded) { + setIsLoading(false); + } + } + }; + + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; }, [htmlContent]); + // Separate effect for style updates + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) { + return; + } + + const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document; + if (!iframeDocument) { + return; + } + + const root = iframeDocument.documentElement; + root.style.setProperty('--font-size', fontSize); + root.style.setProperty('--line-height', lineHeight); + root.style.setProperty('--font-family', fontFamily.value); + root.style.setProperty('--content-spacing-factor', SPACING_FACTORS[FONT_SIZES.indexOf(fontSize)]); + + const iframeWindow = iframe.contentWindow as IframeWindow; + if (iframeWindow && typeof iframeWindow.resizeIframe === 'function') { + iframeWindow.resizeIframe(); + } else { + // Fallback: trigger a resize event + const resizeEvent = new Event('resize'); + iframeDocument.dispatchEvent(resizeEvent); + } + }, [fontSize, lineHeight, fontFamily]); + return (
-