diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 15887166f8..970dd9c0dd 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 e32490b370..6183b5dacb 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 ( +
+ + {items.map(item => ( +
+ ))} +
+ } + > +
+ +
+
+
+ ); +}; + +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 (
@@ -480,6 +595,100 @@ const ArticleModal: React.FC = ({ return () => container?.removeEventListener('scroll', handleScroll); }, []); + const [tocItems, setTocItems] = useState([]); + const [activeHeadingId, setActiveHeadingId] = useState(null); + const [iframeElement, setIframeElement] = useState(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 ( = ({
-
-
- {activityThreadParents.map((item) => { - return ( - <> - { - navigateForward(item.id, item.object, item.actor, false); - }} - onCommentClick={() => { - navigateForward(item.id, item.object, item.actor, true); - }} - /> - - ); - })} - - {object.type === 'Note' && ( - 0)) ? true : false} - type='Note' - onCommentClick={() => { - repliesRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - }} - /> - )} - {object.type === 'Article' && ( -
- + {modalSize === MODAL_SIZE_LG && object.type === 'Article' && tocItems.length > 0 && ( +
+
+ -
- { - repliesRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - }} - onLikeClick={onLikeClick} - /> -
- )} - -
-
- - - {isLoadingThread && } - -
- {activityThreadChildren.map((item, index) => { - const showDivider = index !== activityThreadChildren.length - 1; - + )} +
+
+ {activityThreadParents.map((item) => { return ( <> = ({ navigateForward(item.id, item.object, item.actor, true); }} /> - {showDivider && } ); })} + + {object.type === 'Note' && ( + 0))} + type='Note' + onCommentClick={() => { + repliesRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }} + /> + )} + {object.type === 'Article' && ( +
+ +
+ { + repliesRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }} + onLikeClick={onLikeClick} + /> +
+
+ )} + +
+ +
+ + + {isLoadingThread && } + +
+ {activityThreadChildren.map((item, index) => { + const showDivider = index !== activityThreadChildren.length - 1; + + return ( + + { + navigateForward(item.id, item.object, item.actor, false); + }} + onCommentClick={() => { + navigateForward(item.id, item.object, item.actor, true); + }} + /> + {showDivider && } + + ); + })} +
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && ( -
+
{getReadingTime(object.content ?? '')}
diff --git a/apps/admin-x-design-system/src/global/Popover.tsx b/apps/admin-x-design-system/src/global/Popover.tsx index afb1755dcd..7e35ae5055 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 = ({ 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 = ({ {trigger} - + {children}