Skip to content

Commit 5c0f853

Browse files
committed
feat: toc modal in mobile
Signed-off-by: Innei <[email protected]>
1 parent 96dbd91 commit 5c0f853

File tree

15 files changed

+252
-172
lines changed

15 files changed

+252
-172
lines changed

src/atoms/viewport.ts

+9
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,12 @@ export const useViewport = <T>(
4343
useCallback((atomValue) => selector(atomValue), []),
4444
),
4545
)
46+
47+
export const useIsMobile = () =>
48+
useViewport(
49+
useCallback(
50+
(v: ExtractAtomValue<typeof viewportAtom>) =>
51+
(v.sm || v.md || !v.sm) && !v.lg,
52+
[],
53+
),
54+
)

src/components/layout/root/Root.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { BackToTopFAB, FABContainer } from '~/components/ui/fab'
2+
import { OnlyMobile } from '~/components/ui/viewport/OnlyMobile'
3+
import { TocFAB } from '~/components/widgets/toc/TocFAB'
24

35
import { Content } from '../content/Content'
46
import { Footer } from '../footer'
@@ -13,6 +15,9 @@ export const Root: Component = ({ children }) => {
1315
<Footer />
1416
<FABContainer>
1517
<BackToTopFAB />
18+
<OnlyMobile>
19+
<TocFAB />
20+
</OnlyMobile>
1621
</FABContainer>
1722
</>
1823
)

src/components/ui/fab/FABContainer.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,12 @@ export const FABBase = (
7171
{show && (
7272
<motion.button
7373
aria-label="Floating action button"
74-
initial={{ opacity: 0, scale: 0.8 }}
74+
initial={{ opacity: 0.3, scale: 0.8 }}
7575
animate={{ opacity: 1, scale: 1 }}
76-
exit={{ opacity: 0, scale: 0.8 }}
76+
exit={{ opacity: 0.3, scale: 0.8 }}
7777
className={clsxm(
78-
'mt-2 inline-flex h-10 w-10 items-center justify-center',
78+
'mt-2 inline-flex items-center justify-center',
79+
'h-12 w-12 text-lg md:h-10 md:w-10 md:text-base',
7980
'border border-accent transition-all duration-300 hover:opacity-100 focus:opacity-100 focus:outline-none',
8081
'rounded-xl border border-zinc-400/20 shadow-lg backdrop-blur-lg dark:border-zinc-500/30 dark:bg-zinc-800/80 dark:text-zinc-200',
8182
'bg-slate-50/80 shadow-lg dark:bg-neutral-900/80',

src/components/ui/markdown/Markdown.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import dynamic from 'next/dynamic'
66
import type { MarkdownToJSX } from 'markdown-to-jsx'
77
import type { FC, PropsWithChildren } from 'react'
88

9+
import { MAIN_MARKDOWN_ID } from '~/constants/dom-id'
910
import { useWrappedElementSize } from '~/providers/shared/WrappedElementProvider'
1011
import { isDev } from '~/utils/env'
1112
import { springScrollToElement } from '~/utils/scroller'
@@ -219,6 +220,7 @@ export const Markdown: FC<MdProps & MarkdownToJSX.Options & PropsWithChildren> =
219220

220221
return (
221222
<As
223+
id={MAIN_MARKDOWN_ID}
222224
style={style}
223225
{...wrapperProps}
224226
ref={ref}

src/components/ui/markdown/markdown.module.css

+8
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
&:hover {
1515
background: transparent;
1616
}
17+
18+
&:not(:hover) * {
19+
@apply !text-inherit;
20+
}
1721
}
1822
}
1923

@@ -95,4 +99,8 @@
9599
pre {
96100
@apply min-w-0 max-w-full flex-shrink flex-grow overflow-x-auto;
97101
}
102+
103+
p {
104+
@apply break-words;
105+
}
98106
}

src/components/ui/markdown/renderers/collapse.module.css

-15
This file was deleted.

src/components/ui/markdown/renderers/collapse.tsx

+10-10
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,35 @@
1-
import React, { useState } from 'react'
1+
import React, { useCallback, useState } from 'react'
22
import clsx from 'clsx'
33
import type { FC, ReactNode } from 'react'
44

55
import { IcRoundKeyboardDoubleArrowRight } from '~/components/icons/arrow'
66

77
import { Collapse } from '../../collapse'
8-
import styles from './collapse.module.css'
98

109
export const MDetails: FC<{ children: ReactNode[] }> = (props) => {
1110
const [open, setOpen] = useState(false)
1211

1312
const $head = props.children[0]
1413

14+
const handleOpen = useCallback(() => {
15+
setOpen((o) => !o)
16+
}, [])
1517
return (
16-
<div className={styles.collapse}>
17-
<div
18-
className={styles.title}
19-
onClick={() => {
20-
setOpen((o) => !o)
21-
}}
18+
<div className="my-2">
19+
<button
20+
className="mb-2 flex cursor-pointer items-center pl-2"
21+
onClick={handleOpen}
2222
>
2323
<i
2424
className={clsx(
2525
'icon-[mingcute--align-arrow-down-line] mr-2 transform transition-transform duration-500',
26-
open && 'rotate-90',
26+
!open && '-rotate-90',
2727
)}
2828
>
2929
<IcRoundKeyboardDoubleArrowRight />
3030
</i>
3131
{$head}
32-
</div>
32+
</button>
3333
<Collapse isOpened={open} className="my-2">
3434
<div
3535
className={clsx(

src/components/ui/viewport/OnlyMobile.tsx

+2-8
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
'use client'
22

3-
import { useAtomValue } from 'jotai'
4-
import { selectAtom } from 'jotai/utils'
5-
import type { ExtractAtomValue } from 'jotai/vanilla'
6-
7-
import { viewportAtom } from '~/atoms/viewport'
3+
import { useIsMobile } from '~/atoms/viewport'
84
import { useIsClient } from '~/hooks/common/use-is-client'
95

10-
const selector = (v: ExtractAtomValue<typeof viewportAtom>) =>
11-
(v.sm || v.md || !v.sm) && !v.lg
126
export const OnlyMobile: Component = ({ children }) => {
137
const isClient = useIsClient()
148

15-
const isMobile = useAtomValue(selectAtom(viewportAtom, selector))
9+
const isMobile = useIsMobile()
1610

1711
if (!isClient) return null
1812

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client'
2+
3+
import React, { useEffect, useMemo, useRef } from 'react'
4+
import type { ITocItem } from './TocItem'
5+
6+
import { throttle } from '~/lib/_'
7+
import { useWrappedElement } from '~/providers/shared/WrappedElementProvider'
8+
import { clsxm } from '~/utils/helper'
9+
10+
import { TocTree } from './TocTree'
11+
12+
export type TocAsideProps = {
13+
treeClassName?: string
14+
}
15+
16+
export interface TocSharedProps {
17+
accessory?: React.ReactNode | React.FC
18+
}
19+
export const TocAside: Component<TocAsideProps & TocSharedProps> = ({
20+
className,
21+
children,
22+
treeClassName,
23+
accessory,
24+
}) => {
25+
const containerRef = useRef<HTMLUListElement>(null)
26+
const $article = useWrappedElement()
27+
28+
if (typeof $article === 'undefined') {
29+
throw new Error('<Toc /> must be used in <WrappedElementProvider />')
30+
}
31+
const $headings = useMemo(() => {
32+
if (!$article) {
33+
return []
34+
}
35+
return [
36+
...$article.querySelectorAll('h1,h2,h3,h4,h5,h6'),
37+
] as HTMLHeadingElement[]
38+
}, [$article])
39+
40+
const toc: ITocItem[] = useMemo(() => {
41+
return Array.from($headings).map((el, idx) => {
42+
const depth = +el.tagName.slice(1)
43+
const title = el.textContent || ''
44+
45+
const index = idx
46+
47+
return {
48+
depth,
49+
index: isNaN(index) ? -1 : index,
50+
title,
51+
anchorId: el.id,
52+
}
53+
})
54+
}, [$headings])
55+
56+
useEffect(() => {
57+
const setMaxWidth = throttle(() => {
58+
if (containerRef.current) {
59+
containerRef.current.style.maxWidth = `${
60+
document.documentElement.getBoundingClientRect().width -
61+
containerRef.current.getBoundingClientRect().x -
62+
30
63+
}px`
64+
}
65+
}, 14)
66+
setMaxWidth()
67+
68+
window.addEventListener('resize', setMaxWidth)
69+
return () => {
70+
window.removeEventListener('resize', setMaxWidth)
71+
}
72+
}, [])
73+
74+
return (
75+
<aside className={clsxm('st-toc z-[3]', 'relative font-sans', className)}>
76+
<TocTree
77+
$headings={$headings}
78+
containerRef={containerRef}
79+
className={clsxm('absolute max-h-[75vh]', treeClassName)}
80+
accessory={accessory}
81+
/>
82+
{children}
83+
</aside>
84+
)
85+
}

src/components/widgets/toc/TocFAB.tsx

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client'
2+
3+
import { useCallback } from 'react'
4+
import { useParams, usePathname } from 'next/navigation'
5+
6+
import { FABBase } from '~/components/ui/fab'
7+
import { MAIN_MARKDOWN_ID } from '~/constants/dom-id'
8+
import { useModalStack } from '~/providers/root/modal-stack-provider'
9+
10+
import { TocTree } from './TocTree'
11+
12+
export const TocFAB = () => {
13+
const { present } = useModalStack()
14+
const pathname = usePathname()
15+
const params = useParams()
16+
17+
const presentToc = useCallback(() => {
18+
const $mainMarkdownRender = document.getElementById(MAIN_MARKDOWN_ID)
19+
if (!$mainMarkdownRender) return
20+
const $headings = [
21+
...$mainMarkdownRender.querySelectorAll('h1,h2,h3,h4,h5,h6'),
22+
] as HTMLHeadingElement[]
23+
const dispose = present({
24+
title: 'Table of Content',
25+
content: () => (
26+
<TocTree
27+
$headings={$headings}
28+
className="space-y-3 [&>li]:py-1"
29+
onItemClick={() => {
30+
dispose()
31+
}}
32+
scrollInNextTick
33+
/>
34+
),
35+
})
36+
}, [pathname, params])
37+
return (
38+
<FABBase id="show-toc" aria-label="Show ToC" onClick={presentToc}>
39+
<i className="icon-[mingcute--list-expansion-line]" />
40+
</FABBase>
41+
)
42+
}

src/components/widgets/toc/TocItem.tsx

+15-15
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@ import { tv } from 'tailwind-variants'
33
import type { FC, MouseEvent } from 'react'
44

55
import { getIsInteractive } from '~/atoms/is-interactive'
6-
import { useWrappedElement } from '~/providers/shared/WrappedElementProvider'
76
import { clsxm } from '~/utils/helper'
87

9-
import { escapeSelector } from './escapeSelector'
10-
118
const styles = tv({
129
base: clsxm(
1310
'leading-normal mb-[1.5px] text-neutral-content inline-block relative max-w-full min-w-0',
@@ -24,19 +21,19 @@ export interface ITocItem {
2421
title: string
2522
anchorId: string
2623
index: number
24+
25+
$heading: HTMLHeadingElement
2726
}
2827

2928
export const TocItem: FC<{
30-
title: string
31-
anchorId: string
32-
depth: number
29+
heading: ITocItem
30+
3331
active: boolean
3432
rootDepth: number
3533
onClick?: (i: number, $el: HTMLElement | null, anchorId: string) => void
36-
index: number
37-
// containerRef?: RefObject<HTMLDivElement>
3834
}> = memo((props) => {
39-
const { index, active, depth, title, rootDepth, onClick, anchorId } = props
35+
const { active, rootDepth, onClick, heading } = props
36+
const { $heading, anchorId, depth, index, title } = heading
4037

4138
const $ref = useRef<HTMLAnchorElement>(null)
4239

@@ -49,12 +46,18 @@ export const TocItem: FC<{
4946
history.replaceState(state, '', `#${anchorId}`)
5047
}, [active, anchorId])
5148

49+
useEffect(() => {
50+
if (active) {
51+
$ref.current?.scrollIntoView({ behavior: 'smooth' })
52+
}
53+
}, [])
54+
5255
const renderDepth = useMemo(() => {
5356
const result = depth - rootDepth
5457

5558
return result
5659
}, [depth, rootDepth])
57-
const $article = useWrappedElement()
60+
5861
return (
5962
<a
6063
ref={$ref}
@@ -76,13 +79,10 @@ export const TocItem: FC<{
7679
onClick={useCallback(
7780
(e: MouseEvent) => {
7881
e.preventDefault()
79-
const $el = $article?.querySelector(
80-
`#${escapeSelector(anchorId)}`,
81-
) as any as HTMLElement
8282

83-
onClick?.(index, $el, anchorId)
83+
onClick?.(index, $heading, anchorId)
8484
},
85-
[onClick, index, $article, anchorId],
85+
[onClick, index, $heading, anchorId],
8686
)}
8787
title={title}
8888
>

0 commit comments

Comments
 (0)