Skip to content

Commit

Permalink
feat: toc modal in mobile
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <[email protected]>
  • Loading branch information
Innei committed Jun 25, 2023
1 parent 96dbd91 commit 5c0f853
Show file tree
Hide file tree
Showing 15 changed files with 252 additions and 172 deletions.
9 changes: 9 additions & 0 deletions src/atoms/viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,12 @@ export const useViewport = <T>(
useCallback((atomValue) => selector(atomValue), []),
),
)

export const useIsMobile = () =>
useViewport(
useCallback(
(v: ExtractAtomValue<typeof viewportAtom>) =>
(v.sm || v.md || !v.sm) && !v.lg,
[],
),
)
5 changes: 5 additions & 0 deletions src/components/layout/root/Root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { BackToTopFAB, FABContainer } from '~/components/ui/fab'
import { OnlyMobile } from '~/components/ui/viewport/OnlyMobile'
import { TocFAB } from '~/components/widgets/toc/TocFAB'

import { Content } from '../content/Content'
import { Footer } from '../footer'
Expand All @@ -13,6 +15,9 @@ export const Root: Component = ({ children }) => {
<Footer />
<FABContainer>
<BackToTopFAB />
<OnlyMobile>
<TocFAB />
</OnlyMobile>
</FABContainer>
</>
)
Expand Down
7 changes: 4 additions & 3 deletions src/components/ui/fab/FABContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ export const FABBase = (
{show && (
<motion.button
aria-label="Floating action button"
initial={{ opacity: 0, scale: 0.8 }}
initial={{ opacity: 0.3, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
exit={{ opacity: 0.3, scale: 0.8 }}
className={clsxm(
'mt-2 inline-flex h-10 w-10 items-center justify-center',
'mt-2 inline-flex items-center justify-center',
'h-12 w-12 text-lg md:h-10 md:w-10 md:text-base',
'border border-accent transition-all duration-300 hover:opacity-100 focus:opacity-100 focus:outline-none',
'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',
'bg-slate-50/80 shadow-lg dark:bg-neutral-900/80',
Expand Down
2 changes: 2 additions & 0 deletions src/components/ui/markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dynamic from 'next/dynamic'
import type { MarkdownToJSX } from 'markdown-to-jsx'
import type { FC, PropsWithChildren } from 'react'

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

return (
<As
id={MAIN_MARKDOWN_ID}
style={style}
{...wrapperProps}
ref={ref}
Expand Down
8 changes: 8 additions & 0 deletions src/components/ui/markdown/markdown.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
&:hover {
background: transparent;
}

&:not(:hover) * {
@apply !text-inherit;
}
}
}

Expand Down Expand Up @@ -95,4 +99,8 @@
pre {
@apply min-w-0 max-w-full flex-shrink flex-grow overflow-x-auto;
}

p {
@apply break-words;
}
}
15 changes: 0 additions & 15 deletions src/components/ui/markdown/renderers/collapse.module.css

This file was deleted.

20 changes: 10 additions & 10 deletions src/components/ui/markdown/renderers/collapse.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
import React, { useState } from 'react'
import React, { useCallback, useState } from 'react'
import clsx from 'clsx'
import type { FC, ReactNode } from 'react'

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

import { Collapse } from '../../collapse'
import styles from './collapse.module.css'

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

const $head = props.children[0]

const handleOpen = useCallback(() => {
setOpen((o) => !o)
}, [])
return (
<div className={styles.collapse}>
<div
className={styles.title}
onClick={() => {
setOpen((o) => !o)
}}
<div className="my-2">
<button
className="mb-2 flex cursor-pointer items-center pl-2"
onClick={handleOpen}
>
<i
className={clsx(
'icon-[mingcute--align-arrow-down-line] mr-2 transform transition-transform duration-500',
open && 'rotate-90',
!open && '-rotate-90',
)}
>
<IcRoundKeyboardDoubleArrowRight />
</i>
{$head}
</div>
</button>
<Collapse isOpened={open} className="my-2">
<div
className={clsx(
Expand Down
10 changes: 2 additions & 8 deletions src/components/ui/viewport/OnlyMobile.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
'use client'

import { useAtomValue } from 'jotai'
import { selectAtom } from 'jotai/utils'
import type { ExtractAtomValue } from 'jotai/vanilla'

import { viewportAtom } from '~/atoms/viewport'
import { useIsMobile } from '~/atoms/viewport'
import { useIsClient } from '~/hooks/common/use-is-client'

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

const isMobile = useAtomValue(selectAtom(viewportAtom, selector))
const isMobile = useIsMobile()

if (!isClient) return null

Expand Down
85 changes: 85 additions & 0 deletions src/components/widgets/toc/TocAside.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use client'

import React, { useEffect, useMemo, useRef } from 'react'
import type { ITocItem } from './TocItem'

import { throttle } from '~/lib/_'
import { useWrappedElement } from '~/providers/shared/WrappedElementProvider'
import { clsxm } from '~/utils/helper'

import { TocTree } from './TocTree'

export type TocAsideProps = {
treeClassName?: string
}

export interface TocSharedProps {
accessory?: React.ReactNode | React.FC
}
export const TocAside: Component<TocAsideProps & TocSharedProps> = ({
className,
children,
treeClassName,
accessory,
}) => {
const containerRef = useRef<HTMLUListElement>(null)
const $article = useWrappedElement()

if (typeof $article === 'undefined') {
throw new Error('<Toc /> must be used in <WrappedElementProvider />')
}
const $headings = useMemo(() => {
if (!$article) {
return []
}
return [
...$article.querySelectorAll('h1,h2,h3,h4,h5,h6'),
] as HTMLHeadingElement[]
}, [$article])

const toc: ITocItem[] = useMemo(() => {
return Array.from($headings).map((el, idx) => {
const depth = +el.tagName.slice(1)
const title = el.textContent || ''

const index = idx

return {
depth,
index: isNaN(index) ? -1 : index,
title,
anchorId: el.id,
}
})
}, [$headings])

useEffect(() => {
const setMaxWidth = throttle(() => {
if (containerRef.current) {
containerRef.current.style.maxWidth = `${
document.documentElement.getBoundingClientRect().width -
containerRef.current.getBoundingClientRect().x -
30
}px`
}
}, 14)
setMaxWidth()

window.addEventListener('resize', setMaxWidth)
return () => {
window.removeEventListener('resize', setMaxWidth)
}
}, [])

return (
<aside className={clsxm('st-toc z-[3]', 'relative font-sans', className)}>
<TocTree
$headings={$headings}
containerRef={containerRef}
className={clsxm('absolute max-h-[75vh]', treeClassName)}
accessory={accessory}
/>
{children}
</aside>
)
}
42 changes: 42 additions & 0 deletions src/components/widgets/toc/TocFAB.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client'

import { useCallback } from 'react'
import { useParams, usePathname } from 'next/navigation'

import { FABBase } from '~/components/ui/fab'
import { MAIN_MARKDOWN_ID } from '~/constants/dom-id'
import { useModalStack } from '~/providers/root/modal-stack-provider'

import { TocTree } from './TocTree'

export const TocFAB = () => {
const { present } = useModalStack()
const pathname = usePathname()
const params = useParams()

const presentToc = useCallback(() => {
const $mainMarkdownRender = document.getElementById(MAIN_MARKDOWN_ID)
if (!$mainMarkdownRender) return
const $headings = [
...$mainMarkdownRender.querySelectorAll('h1,h2,h3,h4,h5,h6'),
] as HTMLHeadingElement[]
const dispose = present({
title: 'Table of Content',
content: () => (
<TocTree
$headings={$headings}
className="space-y-3 [&>li]:py-1"
onItemClick={() => {
dispose()
}}
scrollInNextTick
/>
),
})
}, [pathname, params])
return (
<FABBase id="show-toc" aria-label="Show ToC" onClick={presentToc}>
<i className="icon-[mingcute--list-expansion-line]" />
</FABBase>
)
}
30 changes: 15 additions & 15 deletions src/components/widgets/toc/TocItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ import { tv } from 'tailwind-variants'
import type { FC, MouseEvent } from 'react'

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

import { escapeSelector } from './escapeSelector'

const styles = tv({
base: clsxm(
'leading-normal mb-[1.5px] text-neutral-content inline-block relative max-w-full min-w-0',
Expand All @@ -24,19 +21,19 @@ export interface ITocItem {
title: string
anchorId: string
index: number

$heading: HTMLHeadingElement
}

export const TocItem: FC<{
title: string
anchorId: string
depth: number
heading: ITocItem

active: boolean
rootDepth: number
onClick?: (i: number, $el: HTMLElement | null, anchorId: string) => void
index: number
// containerRef?: RefObject<HTMLDivElement>
}> = memo((props) => {
const { index, active, depth, title, rootDepth, onClick, anchorId } = props
const { active, rootDepth, onClick, heading } = props
const { $heading, anchorId, depth, index, title } = heading

const $ref = useRef<HTMLAnchorElement>(null)

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

useEffect(() => {
if (active) {
$ref.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [])

const renderDepth = useMemo(() => {
const result = depth - rootDepth

return result
}, [depth, rootDepth])
const $article = useWrappedElement()

return (
<a
ref={$ref}
Expand All @@ -76,13 +79,10 @@ export const TocItem: FC<{
onClick={useCallback(
(e: MouseEvent) => {
e.preventDefault()
const $el = $article?.querySelector(
`#${escapeSelector(anchorId)}`,
) as any as HTMLElement

onClick?.(index, $el, anchorId)
onClick?.(index, $heading, anchorId)
},
[onClick, index, $article, anchorId],
[onClick, index, $heading, anchorId],
)}
title={title}
>
Expand Down
Loading

0 comments on commit 5c0f853

Please sign in to comment.