0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2024-12-30 22:34:01 -05:00

Improved ActivityPub reader view (#21877)

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

- Improved typography, spacing and alignment.
- Improved selection of font sizes from the reader view customization popover.
- Added a button to reset reader view customization settings to default, in case user ends up in a state they’re not happy with and want to go back to a sensible default.
- Added preview to typeface selection component so it’s easier to see what you’re selecting.
- Disabled background clicks on all AP modals for a more consistent experience and to avoid accidental clicks.
- Changed the reading progress indicator increments from 1 to 5 to attract less attention while the user is reading.
This commit is contained in:
Djordje Vlaisavljevic 2024-12-12 19:47:49 +00:00 committed by GitHub
parent c1c7828499
commit 6595697864
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 95 additions and 48 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@tryghost/admin-x-activitypub",
"version": "0.3.37",
"version": "0.3.38",
"license": "MIT",
"repository": {
"type": "git",

View file

@ -312,14 +312,14 @@ button.gh-form-input {
.gh-article-title {
font-weight: 700;
text-wrap: pretty;
font-size: calc(3rem * var(--content-spacing-factor, 1));
letter-spacing: -0.021em;
line-height: 1.4;
font-size: calc(3.6rem * var(--content-spacing-factor, 1));
letter-spacing: -0.025em;
line-height: 1.1;
}
.gh-article-excerpt {
margin-top: 16px;
font-size: calc(2rem * var(--content-spacing-factor, 1));
font-size: calc(1.8rem * var(--content-spacing-factor, 1));
line-height: 1.4;
letter-spacing: -0.017em;
text-wrap: pretty;
@ -344,7 +344,7 @@ headings, text, images and lists. We deal with cards lower down. */
.gh-content {
font-size: var(--font-size);
overflow-x: hidden;
letter-spacing: -0.013em;
letter-spacing: var(--letter-spacing);
line-height: var(--line-height);
font-family: var(--font-family);
}
@ -363,7 +363,7 @@ headings, text, images and lists. We deal with cards lower down. */
/* Add back a top margin to all headings,
unless a heading is the very first element in the post content */
.gh-content > [id]:not(:first-child) {
margin-top: calc(56px * var(--content-spacing-factor, 1));
margin-top: calc(40px * var(--content-spacing-factor, 1));
}
/* Add drop cap setting */

View file

@ -4,10 +4,11 @@ import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from '../articleBodyStyles';
import getUsername from '../../utils/get-username';
import {OptionProps, SingleValueProps, components} from 'react-select';
import {type Activity} from '../activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, LoadingIndicator, Modal, Popover, Select, SelectOption} from '@tryghost/admin-x-design-system';
import {Button, Icon, 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 {useModal} from '@ebay/nice-modal-react';
@ -24,7 +25,6 @@ interface ArticleModalProps {
focusReply: boolean;
focusReplies: boolean;
width?: 'narrow' | 'wide';
backDrop?: boolean;
updateActivity: (id: string, updated: Partial<Activity>) => void;
history: {
activityId: string;
@ -63,6 +63,7 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt:
--font-size: ${fontSize};
--line-height: ${lineHeight};
--font-family: ${fontFamily.value};
--letter-spacing: ${fontFamily.label === 'Clean sans-serif' ? '-0.013em' : '0'};
--content-spacing-factor: ${SPACING_FACTORS[FONT_SIZES.indexOf(fontSize)]};
}
body {
@ -184,6 +185,7 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt:
root.style.setProperty('--font-size', fontSize);
root.style.setProperty('--line-height', lineHeight);
root.style.setProperty('--font-family', fontFamily.value);
root.style.setProperty('--letter-spacing', fontFamily.label === 'Clean sans-serif' ? '-0.013em' : '0');
root.style.setProperty('--content-spacing-factor', SPACING_FACTORS[FONT_SIZES.indexOf(fontSize)]);
const iframeWindow = iframe.contentWindow as IframeWindow;
@ -226,28 +228,55 @@ const FeedItemDivider: React.FC = () => (
<div className="h-px bg-grey-200"></div>
);
const FONT_SIZES = ['1.4rem', '1.7rem', '1.9rem', '2.1rem', '2.4rem'] as const;
const LINE_HEIGHTS = ['1.3', '1.4', '1.5', '1.6', '1.7', '1.8'] as const;
const SPACING_FACTORS = ['0.7', '1', '1.1', '1.2', '1.3'] as const;
const FONT_SIZES = ['1.5rem', '1.6rem', '1.7rem', '1.8rem', '2rem'] as const;
const LINE_HEIGHTS = ['1.3', '1.4', '1.5', '1.6', '1.7'] as const;
const SPACING_FACTORS = ['0.85', '1', '1.1', '1.2', '1.3'] as const;
type FontSize = typeof FONT_SIZES[number];
// Add constants for localStorage keys
const STORAGE_KEYS = {
FONT_SIZE: 'ghost-ap-font-size',
LINE_HEIGHT: 'ghost-ap-line-height',
FONT_FAMILY: 'ghost-ap-font-family'
} as const;
// Add this constant near your other constants
const MAX_WIDTHS = {
'1.4rem': '544px',
'1.7rem': '644px',
'1.9rem': '684px',
'2.1rem': '724px',
'2.4rem': '764px'
'1.5rem': '544px',
'1.6rem': '644px',
'1.7rem': '684px',
'1.8rem': '724px',
'2rem': '764px'
} as const;
const SingleValue: React.FC<SingleValueProps<FontSelectOption, false>> = ({children, ...props}) => (
<components.SingleValue {...props}>
<div className='group' data-testid="select-current-option" data-value={props.data.value}>
<div className='flex items-center gap-2.5'>
<div className={`${props.data.className} flex h-8 w-8 items-center justify-center rounded-md bg-white text-[1.5rem] font-semibold dark:bg-black`}>Aa</div>
<span className={`text-md ${props.data.className}`}>{children}</span>
</div>
</div>
</components.SingleValue>
);
const Option: React.FC<OptionProps<FontSelectOption, false>> = ({children, ...props}) => (
<components.Option {...props}>
<div className={props.isSelected ? 'relative flex w-full items-center justify-between gap-2' : 'group'} data-testid="select-option" data-value={props.data.value}>
<div className='flex items-center gap-2.5'>
<div className='flex h-8 w-8 items-center justify-center rounded-md bg-grey-150 text-[1.5rem] font-semibold group-hover:bg-grey-250 dark:bg-grey-900 dark:group-hover:bg-grey-800'>Aa</div>
<span className={`text-md ${props.data.className}`}>{children}</span>
</div>
{props.isSelected && <span><Icon name='check' size='xs' /></span>}
</div>
</components.Option>
);
interface FontSelectOption {
value: string;
label: string;
className?: string;
}
const ArticleModal: React.FC<ArticleModalProps> = ({
activityId,
object,
@ -255,7 +284,6 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
focusReply,
focusReplies,
width = 'narrow',
backDrop = false,
updateActivity = () => {},
history = []
}) => {
@ -367,7 +395,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
const [currentLineHeightIndex, setCurrentLineHeightIndex] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEYS.LINE_HEIGHT);
return saved ? parseInt(saved) : 3;
return saved ? parseInt(saved) : 1;
});
const [fontFamily, setFontFamily] = useState<SelectOption>(() => {
@ -415,6 +443,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
iframeDocument.documentElement.style.setProperty('--font-size', FONT_SIZES[currentFontSizeIndex]);
iframeDocument.documentElement.style.setProperty('--line-height', LINE_HEIGHTS[currentLineHeightIndex]);
iframeDocument.documentElement.style.setProperty('--font-family', fontFamily.value);
iframeDocument.documentElement.style.setProperty('--letter-spacing', fontFamily.label === 'Clean sans-serif' ? '-0.013em' : '0');
iframeDocument.documentElement.style.setProperty('--content-spacing-factor', SPACING_FACTORS[FONT_SIZES.indexOf(FONT_SIZES[currentFontSizeIndex])]);
}
}
@ -427,7 +456,6 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
const [readingProgress, setReadingProgress] = useState(0);
// Add the scroll handler
useEffect(() => {
const container = document.querySelector('.overflow-y-auto');
const article = document.getElementById('object-content');
@ -439,14 +467,11 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
const articleRect = article.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const totalHeight = article.offsetHeight;
// Calculate how much of the article has been scrolled past the viewport
const scrolledPast = Math.max(0, containerRect.top - articleRect.top);
const totalHeight = (article as HTMLElement).offsetHeight - (container as HTMLElement).offsetHeight;
// Only add the visible portion if we've started scrolling
const visibleHeight = scrolledPast > 0 ? Math.min(containerRect.height, articleRect.height) : 0;
const progress = Math.round(Math.min(Math.max(((scrolledPast + visibleHeight) / totalHeight) * 100, 0), 100));
const rawProgress = Math.min(Math.max((scrolledPast / totalHeight) * 100, 0), 100);
const progress = Math.round(rawProgress / 5) * 5;
setReadingProgress(progress);
};
@ -458,9 +483,9 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
return (
<Modal
align='right'
allowBackgroundInteraction={true}
allowBackgroundInteraction={false}
animate={true}
backDrop={backDrop}
backDrop={false}
backDropClick={true}
footer={<></>}
height={'full'}
@ -470,9 +495,9 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
width={modalSize === MODAL_SIZE_LG ? 'toSidebar' : modalSize}
>
<div className='flex h-full flex-col'>
<div className='sticky top-0 z-50 border-b border-grey-200 bg-white py-8'>
<div className='sticky top-0 z-50 flex h-[97px] items-center justify-center border-b border-grey-200 bg-white'>
<div
className={`flex h-8 ${modalSize === MODAL_SIZE_LG ? 'grid px-8' : 'justify-between gap-2 px-8'}`}
className={`w-full ${modalSize === MODAL_SIZE_LG ? 'grid px-8' : 'flex justify-between gap-2 px-8'}`}
style={modalSize === MODAL_SIZE_LG ? {
gridTemplateColumns: `1fr minmax(0,${currentGridWidth}) 1fr`
} : undefined}
@ -495,26 +520,37 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
</div>
</div>
</div>)}
<div className='col-[3/4] flex items-center justify-end space-x-6'>
<div className='col-[3/4] flex items-center justify-end gap-2'>
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && <Popover position='end' trigger={ <Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='typography' size='sm' unstyled onClick={() => {}}/>
}>
<div className='flex min-w-[240px] flex-col p-5'>
<div className='flex min-w-[300px] flex-col p-5'>
<Select
className='mb-3'
components={{Option, SingleValue}}
controlClasses={{control: '!min-h-[40px] !py-0 !pl-1', option: '!pl-1 !py-[4px]'}}
options={[
{value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif', label: 'Clean sans-serif'},
{value: 'Georgia, Times, serif', label: 'Elegant serif'}
{
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
label: 'Clean sans-serif',
className: 'font-sans'
},
{
value: 'Georgia, Times, serif',
label: 'Elegant serif',
className: 'font-serif'
}
]}
title='Typeface'
value={fontFamily}
onSelect={option => setFontFamily(option || {
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
label: 'Clean sans-serif'
label: 'Clean sans-serif',
className: 'font-sans'
})}
/>
<div className='mb-2 flex items-center justify-between'>
<span className='text-sm font-medium'>Font size</span>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium text-grey-900'>Font size</span>
<div className='flex items-center'>
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white ${currentFontSizeIndex === 0 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentFontSizeIndex === 0}
@ -525,7 +561,6 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
unstyled={true}
onClick={decreaseFontSize}
/>
{/* <span className='text-grey-700'>{FONT_SIZES[currentFontSizeIndex]}</span> */}
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentFontSizeIndex === FONT_SIZES.length - 1 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentFontSizeIndex === FONT_SIZES.length - 1}
@ -538,9 +573,9 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
/>
</div>
</div>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>Line spacing</span>
<div className='flex items-center gap-2'>
<div className='mb-5 flex items-center justify-between'>
<span className='text-sm font-medium text-grey-900'>Line spacing</span>
<div className='flex items-center'>
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentLineHeightIndex === 0 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentLineHeightIndex === 0}
@ -551,7 +586,6 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
unstyled={true}
onClick={decreaseLineHeight}
/>
{/* <span className='text-grey-700'>{LINE_HEIGHTS[currentLineHeightIndex]}</span> */}
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentLineHeightIndex === LINE_HEIGHTS.length - 1 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentLineHeightIndex === LINE_HEIGHTS.length - 1}
@ -564,6 +598,19 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
/>
</div>
</div>
<Button
className="text-sm text-grey-600 hover:text-grey-700"
label="Reset to default"
link={true}
onClick={() => {
setCurrentFontSizeIndex(1); // Default font size
setCurrentLineHeightIndex(1); // Default line height
setFontFamily({
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
label: 'Clean sans-serif'
});
}}
/>
</div>
</Popover>}
<Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
@ -679,11 +726,11 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
</div>
</div>
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && (
<div className='pointer-events-none sticky bottom-0 flex items-end justify-between p-8'>
<div className='pointer-events-none sticky bottom-0 flex items-end justify-between px-10 pb-[42px]'>
<div className='pointer-events-auto text-grey-600'>
{getReadingTime(object.content)}
</div>
<div className='pointer-events-auto text-grey-600'>
<div className='pointer-events-auto text-grey-600 transition-all duration-200 ease-out'>
{readingProgress}%
</div>
</div>

View file

@ -293,6 +293,7 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
<Modal
align='right'
animate={true}
backDrop={false}
footer={<></>}
height={'full'}
padding={false}
@ -302,7 +303,7 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
<div className='sticky top-0 z-50 border-grey-200 bg-white py-3'>
<div className='grid h-8 grid-cols-3'>
<div className='col-[3/4] flex items-center justify-end space-x-6 px-8'>
<Button icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
<Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
</div>
</div>
</div>

View file

@ -13,7 +13,6 @@ export const handleViewContent = (
object: activity.object,
actor: authorActor,
focusReply,
backDrop: activity.object.type === 'Article' ? false : false,
width: activity.object.type === 'Article' ? 'wide' : 'narrow',
updateActivity
});