Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/toc and collapse #125

Merged
merged 3 commits into from
Dec 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading