mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-27 22:49:56 -05:00
Extracted TableOfContents into a separate component (#22019)
ref https://linear.app/ghost/issue/AP-634/table-of-contents-in-reader-view - `TableOfContents` is now a separate component to keep `ArticleModal` simpler - Switched to using constants for styling different heading levels for better performance and maintainability
This commit is contained in:
parent
7bc1102cc6
commit
0f1d6167cf
3 changed files with 88 additions and 75 deletions
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@tryghost/admin-x-activitypub",
|
"name": "@tryghost/admin-x-activitypub",
|
||||||
"version": "0.3.47",
|
"version": "0.3.48",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {useThreadForUser} from '../../hooks/useActivityPubQueries';
|
||||||
|
|
||||||
import APAvatar from '../global/APAvatar';
|
import APAvatar from '../global/APAvatar';
|
||||||
import APReplyBox from '../global/APReplyBox';
|
import APReplyBox from '../global/APReplyBox';
|
||||||
|
import TableOfContents, {TOCItem} from './TableOfContents';
|
||||||
import getReadingTime from '../../utils/get-reading-time';
|
import getReadingTime from '../../utils/get-reading-time';
|
||||||
|
|
||||||
interface ArticleModalProps {
|
interface ArticleModalProps {
|
||||||
|
@ -37,79 +38,6 @@ interface IframeWindow extends Window {
|
||||||
resizeIframe?: () => void;
|
resizeIframe?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
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-3';
|
|
||||||
case 2:
|
|
||||||
return 'w-2';
|
|
||||||
default:
|
|
||||||
return 'w-1';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHeadingPadding = (level: number) => {
|
|
||||||
switch (level) {
|
|
||||||
case 1:
|
|
||||||
return 'pl-2';
|
|
||||||
case 2:
|
|
||||||
return 'pl-6';
|
|
||||||
default:
|
|
||||||
return 'pl-10';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-base">
|
|
||||||
<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-400 pr-1 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-700 hover:bg-grey-75 hover:text-grey-900 ${getHeadingPadding(item.level)}`}
|
|
||||||
type='button'
|
|
||||||
onClick={() => onItemClick(item.id)}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ArticleBody: React.FC<{
|
const ArticleBody: React.FC<{
|
||||||
heading: string;
|
heading: string;
|
||||||
image: string|undefined;
|
image: string|undefined;
|
||||||
|
@ -859,7 +787,6 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
|
||||||
<div className="!visible absolute inset-y-0 right-7 z-40 hidden lg:!block">
|
<div className="!visible absolute inset-y-0 right-7 z-40 hidden lg:!block">
|
||||||
<div className="sticky top-1/2 -translate-y-1/2">
|
<div className="sticky top-1/2 -translate-y-1/2">
|
||||||
<TableOfContents
|
<TableOfContents
|
||||||
activeId={activeHeadingId}
|
|
||||||
items={tocItems}
|
items={tocItems}
|
||||||
onItemClick={scrollToHeading}
|
onItemClick={scrollToHeading}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {Popover} from '@tryghost/admin-x-design-system';
|
||||||
|
|
||||||
|
export interface TOCItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
level: number;
|
||||||
|
element?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableOfContentsProps {
|
||||||
|
items: TOCItem[];
|
||||||
|
onItemClick: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINE_WIDTHS = {
|
||||||
|
1: 'w-3',
|
||||||
|
2: 'w-2',
|
||||||
|
3: 'w-1'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const HEADING_PADDINGS = {
|
||||||
|
1: 'pl-2',
|
||||||
|
2: 'pl-6',
|
||||||
|
3: 'pl-10'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TableOfContents: React.FC<TableOfContentsProps> = ({items, onItemClick}) => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNormalizedLevel = (level: number) => {
|
||||||
|
return Math.min(level, 3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLineWidth = (level: number) => {
|
||||||
|
return LINE_WIDTHS[getNormalizedLevel(level) as keyof typeof LINE_WIDTHS];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHeadingPadding = (level: number) => {
|
||||||
|
return HEADING_PADDINGS[getNormalizedLevel(level) as keyof typeof HEADING_PADDINGS];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='absolute right-2 top-1/2 -translate-y-1/2 text-base'>
|
||||||
|
<Popover
|
||||||
|
aria-label='Table of Contents'
|
||||||
|
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-400 pr-1 transition-all ${getLineWidth(item.level)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='w-[220px] p-4'>
|
||||||
|
<nav
|
||||||
|
aria-label='Table of contents navigation'
|
||||||
|
className='max-h-[60vh] overflow-y-auto'
|
||||||
|
role='navigation'
|
||||||
|
>
|
||||||
|
{items.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className={`block w-full cursor-pointer truncate rounded py-1 text-left text-grey-700 hover:bg-grey-75 hover:text-grey-900 ${getHeadingPadding(item.level)}`}
|
||||||
|
title={item.text}
|
||||||
|
type='button'
|
||||||
|
onClick={() => onItemClick(item.id)}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TableOfContents;
|
Loading…
Add table
Reference in a new issue