diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 15887166f8b3..970dd9c0dd80 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.43", + "version": "0.3.44", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index e32490b370f7..6183b5dacb94 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -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> diff --git a/apps/admin-x-design-system/src/global/Popover.tsx b/apps/admin-x-design-system/src/global/Popover.tsx index afb1755dcda0..7e35ae505513 100644 --- a/apps/admin-x-design-system/src/global/Popover.tsx +++ b/apps/admin-x-design-system/src/global/Popover.tsx @@ -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>