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}