0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-04 02:01:58 -05:00

Added table of contents widget to article modal (#22008)

ref https://linear.app/ghost/issue/AP-634/table-of-contents-in-reader-view

- Adds a table of contents widget to the right side of articles in
reader view that let's you navigate between headings for easier
navigation in long, complex articles
- Enhanced popover component with configurable side positioning
- Updated package version to 0.3.44
This commit is contained in:
Djordje Vlaisavljevic 2025-01-15 14:33:08 +00:00 committed by GitHub
parent 363c27423d
commit 73f8bcf0b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 318 additions and 91 deletions

View file

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

View file

@ -1,7 +1,7 @@
import FeedItem from './FeedItem';
import FeedItemStats from './FeedItemStats';
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useRef, useState} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import articleBodyStyles from '../articleBodyStyles';
import getUsername from '../../utils/get-username';
import {OptionProps, SingleValueProps, components} from 'react-select';
@ -37,14 +37,91 @@ 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}> = ({
interface TOCItem {
id: string;
text: string;
level: number;
element?: HTMLElement;
}
const TableOfContents: React.FC<{
items: TOCItem[];
activeId: string | null;
onItemClick: (id: string) => void;
}> = ({items, onItemClick}) => {
if (items.length === 0) {
return null;
}
const getLineWidth = (level: number) => {
switch (level) {
case 1:
return 'w-5';
case 2:
return 'w-3';
default:
return 'w-2';
}
};
return (
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm">
<Popover
position='center'
side='right'
trigger={
<div className="flex cursor-pointer flex-col items-end gap-2 rounded-md bg-white p-2 hover:bg-grey-75">
{items.map(item => (
<div
key={item.id}
className={`h-[2px] rounded-sm bg-grey-300 transition-all ${getLineWidth(item.level)}`}
/>
))}
</div>
}
>
<div className="w-[220px] p-4">
<nav className="max-h-[60vh] overflow-y-auto">
{items.map(item => (
<button
key={item.id}
className={`block w-full cursor-pointer truncate rounded py-1 text-left text-grey-600 hover:bg-grey-75 hover:text-grey-900`}
style={{
paddingLeft: `${(item.level - 1) * 12}px`
}}
type='button'
onClick={() => onItemClick(item.id)}
>
{item.text}
</button>
))}
</nav>
</div>
</Popover>
</div>
);
};
const ArticleBody: React.FC<{
heading: string;
image: string|undefined;
excerpt: string|undefined;
html: string;
fontSize: FontSize;
lineHeight: string;
fontFamily: SelectOption;
onHeadingsExtracted?: (headings: TOCItem[]) => void;
onIframeLoad?: (iframe: HTMLIFrameElement) => void;
}> = ({
heading,
image,
excerpt,
html,
fontSize,
lineHeight,
fontFamily
fontFamily,
onHeadingsExtracted,
onIframeLoad
}) => {
const site = useBrowseSite();
const siteData = site.data?.site;
@ -112,7 +189,15 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt:
window.addEventListener('DOMContentLoaded', initializeResize);
window.addEventListener('load', resizeIframe);
window.addEventListener('resize', resizeIframe);
new MutationObserver(resizeIframe).observe(document.body, { subtree: true, childList: true });
if (document.body) {
const observer = new MutationObserver(resizeIframe);
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true
});
}
window.addEventListener('message', (event) => {
if (event.data.type === 'triggerResize') {
@ -198,6 +283,36 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt:
}
}, [fontSize, lineHeight, fontFamily]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) {
return;
}
const handleLoad = () => {
if (!iframe.contentDocument) {
return;
}
const headings = Array.from(iframe.contentDocument.querySelectorAll('h1:not(.gh-article-title), h2, h3, h4, h5, h6')).map((el, idx) => {
const id = `heading-${idx}`;
el.id = id;
return {
id,
text: el.textContent || '',
level: parseInt(el.tagName[1]),
element: el as HTMLElement
};
});
onHeadingsExtracted?.(headings);
onIframeLoad?.(iframe);
};
iframe.addEventListener('load', handleLoad);
return () => iframe.removeEventListener('load', handleLoad);
}, [onHeadingsExtracted, onIframeLoad]);
return (
<div className='w-full pb-6'>
<div className='relative'>
@ -480,6 +595,100 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
return () => container?.removeEventListener('scroll', handleScroll);
}, []);
const [tocItems, setTocItems] = useState<TOCItem[]>([]);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const [iframeElement, setIframeElement] = useState<HTMLIFrameElement | null>(null);
const handleHeadingsExtracted = useCallback((headings: TOCItem[]) => {
setTocItems(headings);
}, []);
const handleIframeLoad = useCallback((iframe: HTMLIFrameElement) => {
setIframeElement(iframe);
}, []);
const scrollToHeading = useCallback((id: string) => {
if (!iframeElement?.contentDocument) {
return;
}
const heading = iframeElement.contentDocument.getElementById(id);
if (heading) {
const container = document.querySelector('.overflow-y-auto');
if (!container) {
return;
}
// Use offsetTop for absolute position within the document
const headingOffset = heading.offsetTop;
container.scrollTo({
top: headingOffset - 120,
behavior: 'smooth'
});
}
}, [iframeElement]);
useEffect(() => {
if (!iframeElement?.contentDocument || !tocItems.length) {
return;
}
const setupObserver = () => {
const container = document.querySelector('.overflow-y-auto');
if (!container) {
return;
}
const handleScroll = () => {
const doc = iframeElement.contentDocument;
if (!doc || !doc.documentElement) {
return;
}
// Get all heading elements and their positions
const headings = tocItems
.map(item => doc.getElementById(item.id))
.filter((el): el is HTMLElement => el !== null)
.map(el => ({
element: el,
id: el.id,
position: el.getBoundingClientRect().top - container.getBoundingClientRect().top
}));
if (!headings.length) {
return;
}
// Find the last visible heading
const viewportCenter = container.clientHeight / 2;
const buffer = 100;
// Find the last heading that's above the viewport center
const lastVisibleHeading = headings.reduce((last, current) => {
if (current.position < (viewportCenter + buffer)) {
return current;
}
return last;
}, headings[0]);
if (lastVisibleHeading && lastVisibleHeading.element.id !== activeHeadingId) {
setActiveHeadingId(lastVisibleHeading.element.id);
}
};
container.addEventListener('scroll', handleScroll);
handleScroll();
return () => {
container.removeEventListener('scroll', handleScroll);
};
};
const timeoutId = setTimeout(setupObserver, 100);
return () => clearTimeout(timeoutId);
}, [iframeElement, tocItems, activeHeadingId]);
return (
<Modal
align='right'
@ -617,96 +826,27 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
</div>
</div>
</div>
<div className='grow overflow-y-auto'>
<div className={`mx-auto px-8 pb-10 pt-5`} style={{maxWidth: currentMaxWidth}}>
{activityThreadParents.map((item) => {
return (
<>
<FeedItem
actor={item.actor}
commentCount={item.object.replyCount ?? 0}
last={false}
layout='reply'
object={item.object}
type='Note'
onClick={() => {
navigateForward(item.id, item.object, item.actor, false);
}}
onCommentClick={() => {
navigateForward(item.id, item.object, item.actor, true);
}}
/>
</>
);
})}
{object.type === 'Note' && (
<FeedItem
actor={actor}
commentCount={object.replyCount ?? 0}
last={true}
layout={'modal'}
object={object}
showHeader={(canNavigateBack || (activityThreadParents.length > 0)) ? true : false}
type='Note'
onCommentClick={() => {
repliesRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
/>
)}
{object.type === 'Article' && (
<div className='border-b border-grey-200 pb-8' id='object-content'>
<ArticleBody
excerpt={object?.preview?.content ?? ''}
fontFamily={fontFamily}
fontSize={FONT_SIZES[currentFontSizeIndex]}
heading={object.name}
html={object.content ?? ''}
image={typeof object.image === 'string' ? object.image : object.image?.url}
lineHeight={LINE_HEIGHTS[currentLineHeightIndex]}
<div className='relative flex-1'>
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && tocItems.length > 0 && (
<div className="!visible absolute inset-y-0 right-7 z-40 hidden lg:!block">
<div className="sticky top-1/2 -translate-y-1/2">
<TableOfContents
activeId={activeHeadingId}
items={tocItems}
onItemClick={scrollToHeading}
/>
<div className='ml-[-7px]'>
<FeedItemStats
commentCount={object.replyCount ?? 0}
layout={'modal'}
likeCount={1}
object={object}
onCommentClick={() => {
repliesRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
onLikeClick={onLikeClick}
/>
</div>
</div>
)}
<div ref={replyBoxRef}>
<APReplyBox
focused={isFocused}
object={object}
onNewReply={handleNewReply}
/>
</div>
<FeedItemDivider />
{isLoadingThread && <LoadingIndicator size='lg' />}
<div ref={repliesRef}>
{activityThreadChildren.map((item, index) => {
const showDivider = index !== activityThreadChildren.length - 1;
)}
<div className='grow overflow-y-auto'>
<div className={`mx-auto px-8 pb-10 pt-5`} style={{maxWidth: currentMaxWidth}}>
{activityThreadParents.map((item) => {
return (
<>
<FeedItem
actor={item.actor}
commentCount={item.object.replyCount ?? 0}
last={true}
last={false}
layout='reply'
object={item.object}
type='Note'
@ -717,16 +857,100 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
navigateForward(item.id, item.object, item.actor, true);
}}
/>
{showDivider && <FeedItemDivider />}
</>
);
})}
{object.type === 'Note' && (
<FeedItem
actor={actor}
commentCount={object.replyCount ?? 0}
last={true}
layout={'modal'}
object={object}
showHeader={(canNavigateBack || (activityThreadParents.length > 0))}
type='Note'
onCommentClick={() => {
repliesRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
/>
)}
{object.type === 'Article' && (
<div className='border-b border-grey-200 pb-8' id='object-content'>
<ArticleBody
excerpt={object?.preview?.content ?? ''}
fontFamily={fontFamily}
fontSize={FONT_SIZES[currentFontSizeIndex]}
heading={object.name}
html={object.content ?? ''}
image={typeof object.image === 'string' ? object.image : object.image?.url}
lineHeight={LINE_HEIGHTS[currentLineHeightIndex]}
onHeadingsExtracted={handleHeadingsExtracted}
onIframeLoad={handleIframeLoad}
/>
<div className='ml-[-7px]'>
<FeedItemStats
commentCount={object.replyCount ?? 0}
layout={'modal'}
likeCount={1}
object={object}
onCommentClick={() => {
repliesRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
onLikeClick={onLikeClick}
/>
</div>
</div>
)}
<div ref={replyBoxRef}>
<APReplyBox
focused={isFocused}
object={object}
onNewReply={handleNewReply}
/>
</div>
<FeedItemDivider />
{isLoadingThread && <LoadingIndicator size='lg' />}
<div ref={repliesRef}>
{activityThreadChildren.map((item, index) => {
const showDivider = index !== activityThreadChildren.length - 1;
return (
<React.Fragment key={item.id}>
<FeedItem
actor={item.actor}
commentCount={item.object.replyCount ?? 0}
last={true}
layout='reply'
object={item.object}
type='Note'
onClick={() => {
navigateForward(item.id, item.object, item.actor, false);
}}
onCommentClick={() => {
navigateForward(item.id, item.object, item.actor, true);
}}
/>
{showDivider && <FeedItemDivider />}
</React.Fragment>
);
})}
</div>
</div>
</div>
</div>
</div>
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && (
<div className='pointer-events-none sticky bottom-0 flex items-end justify-between px-10 pb-[42px]'>
<div className='pointer-events-none !visible sticky bottom-0 hidden items-end justify-between px-10 pb-[42px] lg:!flex'>
<div className='pointer-events-auto text-grey-600'>
{getReadingTime(object.content ?? '')}
</div>

View file

@ -7,6 +7,7 @@ export interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
position?: PopoverPosition;
side?: PopoverPrimitive.PopoverContentProps['side'];
closeOnItemClick?: boolean;
open?: boolean;
setOpen?: (value: boolean) => void;
@ -16,12 +17,13 @@ const Popover: React.FC<PopoverProps> = ({
trigger,
children,
position = 'start',
side = 'bottom',
closeOnItemClick,
open: openState,
setOpen: setOpenState
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const open = openState !== undefined ? openState : internalOpen;
const setOpen = setOpenState || setInternalOpen;
@ -38,7 +40,8 @@ const Popover: React.FC<PopoverProps> = ({
{trigger}
</PopoverPrimitive.Trigger>
</PopoverPrimitive.Anchor>
<PopoverPrimitive.Content align={position} className="z-[9999] mt-2 origin-top-right rounded bg-white shadow-md ring-1 ring-[rgba(0,0,0,0.01)] focus:outline-none dark:bg-grey-900 dark:text-white" data-testid='popover-content' side="bottom" onClick={handleContentClick}>
<PopoverPrimitive.Content align={position} className="z-[9999] mt-2 origin-top-right rounded bg-white shadow-md ring-1 ring-[rgba(0,0,0,0.01)] focus:outline-none dark:bg-grey-900 dark:text-white"
data-testid='popover-content' side={side} sideOffset={8} onClick={handleContentClick}>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Root>