Skip to content

Commit

Permalink
Merge pull request #125 from JohnsonMao/feature/toc-and-collapse
Browse files Browse the repository at this point in the history
Feature/toc and collapse
  • Loading branch information
JohnsonMao authored Dec 31, 2023
2 parents 2e75644 + a755547 commit eabb0ec
Show file tree
Hide file tree
Showing 12 changed files with 260 additions and 113 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"@react-spring/web": "^9.7.3",
"clsx": "^1.2.1",
"feed": "^4.2.2",
"github-slugger": "^2.0.0",
"next": "^13.5.6",
"next-mdx-remote": "^4.4.1",
"next-nprogress-bar": "^2.1.2",
Expand Down
50 changes: 9 additions & 41 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion src/app/[lang]/posts/[postId]/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ describe('[postId] page', () => {
mockData.mockReturnValueOnce({
id: 'test-id',
content: <h2>{testText}</h2>,
source: `## ${testText}`,
frontmatter: {
date: '2023/10/28',
title: 'test title',
Expand Down
15 changes: 11 additions & 4 deletions src/app/[lang]/posts/[postId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ async function PostPage({ params: { postId } }: PostParams) {

if (!post) return notFound();

const { content, frontmatter, source } = post;
const { content, frontmatter } = post;
const formattedDate = formatDate(frontmatter.date);
const id = `article-${postId}`;

return (
<>
Expand All @@ -45,15 +46,21 @@ async function PostPage({ params: { postId } }: PostParams) {
</Container>
<Container as="main" className="block py-8 lg:flex lg:px-2">
<aside className="hidden w-40 shrink-0 lg:block xl:w-60">
<nav className="sticky top-[var(--header-height)] px-4">
<nav className="top-header-height sticky px-4">
<h4 className="my-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
目錄
</h4>
<TableOfContents source={source} />
<TableOfContents
className="max-h-96 overflow-auto"
targetId={`#${id}`}
/>
</nav>
</aside>
<div>
<article className="prose prose-zinc mx-auto px-4 dark:prose-invert">
<article
id={id}
className="prose prose-zinc mx-auto px-4 dark:prose-invert"
>
{content}
</article>
<Link href="/">回首頁</Link>
Expand Down
29 changes: 29 additions & 0 deletions src/components/Collapse/Collapse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { useRef, ReactNode, HTMLAttributes } from 'react';
import { useSpring, animated } from '@react-spring/web';

type CollapseProps = {
isOpen: boolean;
children: ReactNode;
} & HTMLAttributes<HTMLElement>;

export default function Collapse({
isOpen,
children,
...props
}: CollapseProps) {
const childrenRef = useRef<HTMLDivElement>(null);
const styles = useSpring({
to: {
height: isOpen ? childrenRef.current?.clientHeight : 0,
opacity: isOpen ? 1 : 0.6,
},
});

return (
<animated.div className="overflow-hidden" style={styles} {...props}>
<div ref={childrenRef}>{children}</div>
</animated.div>
);
}
43 changes: 43 additions & 0 deletions src/components/Collapse/collapse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render, screen, waitFor } from '@testing-library/react';
import { Globals } from '@react-spring/web';

import Collapse from '.';

describe('Collapse component', () => {
beforeAll(() => {
Globals.assign({
skipAnimation: true,
});
});

it('should render correct element', async () => {
render(
<Collapse isOpen data-testid="collapse">
Test
</Collapse>
);
const collapse = screen.getByTestId('collapse');

expect(collapse).toBeInTheDocument();
expect(collapse).toHaveTextContent('Test');
});

it('should update Collapse component styles on re-render', async () => {
const { rerender } = render(
<Collapse isOpen={false} data-testid="collapse">
Test
</Collapse>
);
const collapse = screen.getByTestId('collapse');

await waitFor(() => expect(collapse).toHaveStyle('opacity: 0.6'));

rerender(
<Collapse isOpen={true} data-testid="collapse">
Test
</Collapse>
);

await waitFor(() => expect(collapse).toHaveStyle('opacity: 1'));
});
});
3 changes: 3 additions & 0 deletions src/components/Collapse/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Collapse from './Collapse';

export default Collapse;
103 changes: 66 additions & 37 deletions src/components/TableOfContents/TableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,52 @@
'use client';

import { useEffect, useState } from 'react';
import GithubSlugger from 'github-slugger';
import { useEffect, useLayoutEffect, useState } from 'react';

import useIntersectionObserver from '@/hooks/useIntersectionObserver';
import cn from '@/utils/cn';
import Collapse from '../Collapse';
import Link from '../Link';

type TableOfContentsProps = {
source: string;
targetId: `#${string}`;
className?: string;
};

function TableOfContents({ source }: TableOfContentsProps) {
type Heading = {
id: string;
text: string | undefined;
children?: Heading[];
};

function TableOfContents({ className, targetId }: TableOfContentsProps) {
const [activeId, setActiveId] = useState('');
const [headings, setHeadings] = useState<Heading[]>([]);
const [entry, setElementRef] = useIntersectionObserver();

const headingLines = source
.split('\n')
.filter((line) => line.match(/^###?\s/));

const headings = headingLines.map((raw) => {
const text = raw.replace(/^###*\s/, '');
const level = raw.slice(0, 3) === '###' ? 3 : 2;
const slugger = new GithubSlugger();
const id = slugger.slug(text);

return { text, level, id };
});

useEffect(() => {
setElementRef(
Array.from(document.querySelectorAll('article h2, article h3'))
const headingElements = Array.from(
document.querySelectorAll(`${targetId} h2, ${targetId} h3`)
);
}, [setElementRef]);
setElementRef(headingElements);
setHeadings(
headingElements.reduce<Heading[]>(
(result, { id, tagName, textContent }) => {
const heading = { id, text: textContent?.slice(1) };
const lastHeading = result.at(-1);

useEffect(() => {
if (tagName === 'H2') {
result.push(heading);
} else if (lastHeading) {
lastHeading.children = (lastHeading.children || []).concat(heading);
}
return result;
},
[]
)
);
}, [targetId, setElementRef]);

useLayoutEffect(() => {
const visibleHeadings = entry.filter(
({ isIntersecting }) => isIntersecting
);
Expand All @@ -44,24 +56,41 @@ function TableOfContents({ source }: TableOfContentsProps) {
}
}, [entry]);

const isActive = (id: string, children: Heading[]) =>
id === activeId || children.some((child) => child.id === activeId);

const getLinkClassName = (id: string, children: Heading[] = []) =>
cn(
'transition-colors mb-0.5 block overflow-hidden text-ellipsis whitespace-nowrap hover:underline',
isActive(id, children)
? 'text-primary-500 hover:text-primary-600 dark:hover:text-primary-400'
: 'text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
);

return (
<nav aria-label="Table of contents">
<ul>
{headings.map((heading) => (
<li key={heading.id}>
<Link
href={`#${heading.id}`}
title={heading.text}
className={cn(
'mb-0.5 block overflow-hidden text-ellipsis whitespace-nowrap text-left text-sm hover:underline',
heading.id === activeId
? 'font-medium text-primary-500 hover:text-primary-600 dark:hover:text-primary-400'
: 'font-normal text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200',
heading.level === 3 && 'pl-4'
)}
>
{heading.text}
<nav aria-label="Table of contents" className={className}>
<ul className="text-sm">
{headings.map(({ id, text, children }) => (
<li key={id}>
<Link href={`#${id}`} className={getLinkClassName(id, children)}>
{text}
</Link>
{children && (
<Collapse isOpen={isActive(id, children)}>
<ul className="pb-0.5 pl-4">
{children.map((child) => (
<li key={child.id}>
<Link
href={`#${child.id}`}
className={getLinkClassName(child.id)}
>
{child.text}
</Link>
</li>
))}
</ul>
</Collapse>
)}
</li>
))}
</ul>
Expand Down
Loading

0 comments on commit eabb0ec

Please sign in to comment.