close #28 and refactor toc renders

This commit is contained in:
amirhouieh 2023-05-14 18:29:24 +02:00 committed by Jinho Jang
parent bc2da5ebac
commit 13859dc3c1
13 changed files with 199 additions and 78 deletions

View File

@ -1,4 +1,8 @@
import { UnbodyImageBlock, UnbodyTextBlock } from '@/lib/unbody/unbody.types'
import {
TextBlockEnhanced,
UnbodyImageBlock,
UnbodyTextBlock,
} from '@/lib/unbody/unbody.types'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
import { ArticleImageBlockWrapper } from './Article.ImageBlockWrapper'
import { PostImageRatio } from '../Post/Post'
@ -9,11 +13,17 @@ import {
extractIdFromFirstTag,
extractInnerHtml,
} from '@/utils/html.utils'
import { HeadingElementsRef } from '@/utils/ui.utils'
import UnbodyDocumentTypeNames = UnbodyGraphQl.UnbodyDocumentTypeNames
import { ArticleHeading } from '@/components/Article/Article.Heading'
export const RenderArticleBlock = ({
block,
headingElementsRef,
}: {
block: UnbodyImageBlock | UnbodyTextBlock
activeId: string | null
headingElementsRef: HeadingElementsRef
}) => {
switch (block.__typename) {
case UnbodyGraphQl.UnbodyDocumentTypeNames.ImageBlock:
@ -30,17 +40,14 @@ export const RenderArticleBlock = ({
case 'h3':
case 'h4':
case 'h5':
case 'h6':
case 'h6': {
return (
<Headline
variant={block.tagName as any}
component={block.tagName as any}
genericFontFamily="sans-serif"
className={extractClassFromFirstTag(block.html) || ''}
id={extractIdFromFirstTag(block.html) || ''}
dangerouslySetInnerHTML={{ __html: extractInnerHtml(block.html) }}
<ArticleHeading
block={block}
headingElementsRef={headingElementsRef}
/>
)
}
default:
return (
<Paragraph
@ -58,11 +65,6 @@ export const RenderArticleBlock = ({
}
}
const Headline = styled(Typography)`
white-space: pre-wrap;
margin-top: 24px;
`
const Paragraph = styled(Typography)`
white-space: pre-wrap;
`

View File

@ -6,16 +6,27 @@ import {
UnbodyImageBlock,
UnbodyTextBlock,
} from '@/lib/unbody/unbody.types'
import { useState } from 'react'
import { useIntersectionObserver } from '@/utils/ui.utils'
import { useArticleContainerContext } from '@/containers/ArticleContainer.Context'
type Props = {
data: GoogleDocEnhanced
}
const ArticleBlocks = ({ data }: Props) => {
const { setTocId, tocId } = useArticleContainerContext()
const headingElementsRef = useIntersectionObserver(setTocId)
return data.blocks.length ? (
<>
{getBodyBlocks(data).map((block, idx) => (
<RenderArticleBlock key={'block-' + idx} block={block} />
<RenderArticleBlock
key={'block-' + idx}
block={block}
activeId={tocId}
headingElementsRef={headingElementsRef}
/>
))}
</>
) : null

View File

@ -0,0 +1,48 @@
import { TextBlockEnhanced, UnbodyTextBlock } from '@/lib/unbody/unbody.types'
import { HeadingElementsRef } from '@/utils/ui.utils'
import {
extractClassFromFirstTag,
extractIdFromFirstTag,
extractInnerHtml,
} from '@/utils/html.utils'
import styled from '@emotion/styled'
import { Typography, TypographyProps } from '@acid-info/lsd-react'
import { PropsWithChildren } from 'react'
type Props = PropsWithChildren<{
block: TextBlockEnhanced | UnbodyTextBlock
headingElementsRef: HeadingElementsRef
typographyProps?: TypographyProps
}>
export const ArticleHeading = ({
block,
headingElementsRef,
typographyProps,
children,
}: Props) => {
const id =
extractIdFromFirstTag(block.html) || `${block.tagName}-${block.order}`
const refProp = {
ref: (ref: any) => {
headingElementsRef.current[id] = ref
},
}
return (
<>
<span className="anchor" id={id} {...refProp} />
<Headline
variant={block.tagName as any}
component={block.tagName as any}
genericFontFamily="sans-serif"
className={extractClassFromFirstTag(block.html) || ''}
dangerouslySetInnerHTML={{ __html: `${extractInnerHtml(block.html)}` }}
{...(typographyProps || {})}
/>
</>
)
}
const Headline = styled(Typography)`
white-space: pre-wrap;
margin-top: 24px;
`

View File

@ -5,25 +5,28 @@ import { Typography } from '@acid-info/lsd-react'
import styles from './Article.module.css'
import { GoogleDocEnhanced } from '@/lib/unbody/unbody.types'
import { Collapse } from '../Collapse'
import Link from 'next/link'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
type Props = {
toc: Array<Partial<GoogleDocEnhanced>>
toc: UnbodyGraphQl.Fragments.TocItem[]
}
export const MobileToc = ({ toc }: Props) => {
const { tocIndex, setTocIndex } = useArticleContainerContext()
const { tocId, setTocId } = useArticleContainerContext()
return toc?.length > 0 ? (
<Collapse className={styles.mobileToc} label="Contents">
{toc.map((toc, idx) => (
<Content
onClick={() => setTocIndex(idx)}
active={idx === tocIndex}
variant="body3"
<TocItem
href={`${idx === 0 ? '#' : toc.href}`}
key={idx}
active={tocId ? toc.href.substring(1) === tocId : idx === 0}
>
<Typography variant="label2" genericFontFamily="sans-serif">
{toc.title}
</Content>
</Typography>
</TocItem>
))}
</Collapse>
) : null
@ -35,7 +38,7 @@ const CustomTypography = styled(Typography)`
white-space: pre-wrap;
`
const Content = styled(CustomTypography)<{ active: boolean }>`
const TocItem = styled(Link)<{ active: boolean }>`
padding: 8px 14px;
background-color: ${(p) =>
p.active

View File

@ -7,10 +7,13 @@ import ArticleStats from '../Article.Stats'
import { Typography } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import ArticleSummary from './Article.Summary'
import { UnbodyGraphQl } from '../../../lib/unbody/unbody-content.types'
import { calcReadingTime } from '@/utils/string.utils'
import { Authors } from '@/components/Authors'
import { Tags } from '@/components/Tags'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
import { ArticleHeading } from '@/components/Article/Article.Heading'
import { useArticleContainerContext } from '@/containers/ArticleContainer.Context'
import { useIntersectionObserver } from '@/utils/ui.utils'
const ArticleHeader = ({
title,
@ -22,6 +25,9 @@ const ArticleHeader = ({
modifiedAt,
blocks,
}: GoogleDocEnhanced) => {
const { setTocId, tocId } = useArticleContainerContext()
const headingElementsRef = useIntersectionObserver(setTocId)
const _thumbnail = useMemo(() => {
const coverImage = getArticleCover(blocks)
if (!coverImage) return null
@ -51,13 +57,15 @@ const ArticleHeader = ({
return (
<header>
<ArticleStats dateStr={modifiedAt} readingLength={readingTime} />
<ArticleTitle
id={toc[0].href.substring(1)}
variant={'h1'}
genericFontFamily="serif"
>
{title}
</ArticleTitle>
<ArticleHeading
block={blocks[0] as any}
typographyProps={{
variant: 'h1',
genericFontFamily: 'serif',
component: 'h1',
}}
headingElementsRef={headingElementsRef}
/>
{subtitle && (
<ArticleSubtitle
variant="body1"

View File

@ -21,7 +21,6 @@ export const ProgressBar = () => {
<NextNProgress
color={color}
height={1}
showOnShallow={true}
options={{
showSpinner: false,
}}

View File

@ -3,36 +3,39 @@ import { useArticleContainerContext } from '@/containers/ArticleContainer.Contex
import { useSticky } from '@/utils/ui.utils'
import { Typography } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import { UnbodyGoogleDoc } from '@/lib/unbody/unbody.types'
import { useArticleContext } from '@/context/article.context'
import { useSearchBarContext } from '@/context/searchbar.context'
export type TableOfContentsProps = Pick<UnbodyGoogleDoc, 'toc'>
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
type Props = {
contents?: TableOfContentsProps['toc']
contents?: UnbodyGraphQl.Fragments.TocItem[]
}
export default function TableOfContents({ contents, ...props }: Props) {
const articleContainer = useArticleContainerContext()
const { tocIndex, setTocIndex } = articleContainer
const { tocId, setTocId } = useArticleContainerContext()
const dy = uiConfigs.navbarRenderedHeight + uiConfigs.postSectionMargin
const { resultsNumber } = useSearchBarContext()
const router = useRouter()
const { sticky, stickyRef, height } = useSticky<HTMLDivElement>(dy)
const handleSectionClick = (index: number) => {
//@ts-ignore
const section = document.getElementById(contents[index].href.substring(1))
const position = section?.getBoundingClientRect()
window.scrollTo({
top: Number(position?.top) + window.scrollY - 100,
behavior: 'smooth',
})
setTocIndex(index)
useEffect(() => {
const onHashChangeStart = (url: string) => {
const hash = url.split('#')[1]
console.log('hash', contents)
if (hash) {
setTocId(hash)
} else {
setTocId(null)
}
}
router.events.on('hashChangeStart', onHashChangeStart)
return () => {
router.events.off('hashChangeStart', onHashChangeStart)
}
}, [router.events])
return (
<Container
@ -46,17 +49,16 @@ export default function TableOfContents({ contents, ...props }: Props) {
>
<Title variant="body3">Contents</Title>
<Contents height={height}>
{/* @ts-ignore */}
{contents?.map((content, index) => (
<Section
active={index === tocIndex}
onClick={() => handleSectionClick(index)}
<TocItem
href={`${index === 0 ? '#' : content.href}`}
key={index}
active={tocId ? content.href.substring(1) === tocId : index === 0}
>
<Typography variant="body3" genericFontFamily="sans-serif">
<Typography variant="label2" genericFontFamily="sans-serif">
{content.title}
</Typography>
</Section>
</TocItem>
))}
</Contents>
</Container>
@ -76,6 +78,7 @@ const Container = styled.aside<{ dy: number; height: number }>`
padding-bottom: 72px;
transition: opacity 0.3s ease-in-out;
&.hidden {
opacity: 0;
}
@ -104,9 +107,10 @@ const Contents = styled.div<{ height: number }>`
}
`
const Section = styled.section<{ active: boolean }>`
const TocItem = styled(Link)<{ active: boolean }>`
display: flex;
padding: 8px 0 8px 12px;
text-decoration: none;
border-left: ${(p) =>
p.active
? '1px solid rgb(var(--lsd-border-primary))'

View File

@ -1,7 +1,6 @@
import { PostListLayout } from '@/types/ui.types'
export const uiConfigs = {
navbarRenderedHeight: 45,
postSectionMargin: 78,
maxContainerWidth: 1400,
articleRenderedMT: 45 * 2,
}

View File

@ -1,8 +1,8 @@
import React from 'react'
export type ArticleContainerContextType = {
tocIndex: number
setTocIndex: React.Dispatch<React.SetStateAction<number>>
tocId: string | null
setTocId: React.Dispatch<React.SetStateAction<string | null>>
}
export const ArticleContainerContext =

View File

@ -13,12 +13,10 @@ interface Props {
const ArticleContainer = (props: Props) => {
const { data } = props
const [tocIndex, setTocIndex] = useState(0)
const [tocId, setTocId] = useState<string | null>(null)
return (
<ArticleContainerContext.Provider
value={{ tocIndex: tocIndex, setTocIndex: setTocIndex }}
>
<ArticleContainerContext.Provider value={{ tocId, setTocId }}>
<Grid
style={{
width: '100%',

View File

@ -58,17 +58,27 @@ export default function App({ Component, pageProps }: AppLayoutProps) {
:root {
--lpe-nav-rendered-height: ${uiConfigs.navbarRenderedHeight}px;
--lpe-article-rendered-margin-top: ${uiConfigs.articleRenderedMT}px;
}
//.lazyload,
//img.lazyloading {
// opacity: 0;
// transition: opacity 4000ms;
//}
//
//img.lazyloaded {
// opacity: 1;
//}
a,
a:visited,
a:hover,
a:active,
a:focus {
color: rgb(var(--lsd-text-primary));
}
.anchor {
margin-top: calc(-1 * var(--lpe-article-rendered-margin-top));
padding-bottom: var(--lpe-article-rendered-margin-top);
margin-bottom: -16px;
display: block;
height: 0;
background: red;
opacity: 0;
z-index: -1;
}
`}
/>
<ProgressBar />

View File

@ -309,7 +309,8 @@ class UnbodyService extends UnbodyClient {
return this.request<UnbodyGraphQlResponseGoogleDoc>(query)
.then(({ data }) => {
if (!data) return this.handleResponse([], 'No data')
if (!data || !data.Get || !data.Get.GoogleDoc)
return this.handleResponse([], 'No data')
return this.handleResponse(data.Get.GoogleDoc.map(enhanceGoogleDoc))
})
.catch((e) => this.handleResponse([], e))

View File

@ -1,4 +1,4 @@
import { RefObject, useEffect, useRef, useState } from 'react'
import { MutableRefObject, RefObject, useEffect, useRef, useState } from 'react'
export const useSticky = <T extends HTMLElement>(dy: number = 0) => {
const stickyRef = useRef<T>(null)
@ -62,3 +62,41 @@ export const useIsScrolling = () => {
}, [setIsScrolling])
return isScrolling
}
export type HeadingElementsRef = MutableRefObject<{
[key: string]: HTMLHeadingElement | null
}>
export function useIntersectionObserver(
setActiveId: (id: string) => void,
): HeadingElementsRef {
const headingElementsRef: HeadingElementsRef = useRef({})
useEffect(() => {
const callback = (headings: IntersectionObserverEntry[]) => {
headings.forEach((heading) => {
if (heading.isIntersecting && heading.target instanceof HTMLElement) {
const targetId = heading.target.getAttribute('id')
if (targetId) setActiveId(targetId)
}
})
}
const observer = new IntersectionObserver(callback, {
rootMargin: '0px 0px -80% 0px',
})
const current = headingElementsRef.current
Object.values(current).forEach((element) => {
if (element) observer.observe(element)
})
return () => {
Object.values(current).forEach((element) => {
if (element) observer.unobserve(element)
})
}
}, [setActiveId])
return headingElementsRef
}