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

View File

@ -6,16 +6,27 @@ import {
UnbodyImageBlock, UnbodyImageBlock,
UnbodyTextBlock, UnbodyTextBlock,
} from '@/lib/unbody/unbody.types' } from '@/lib/unbody/unbody.types'
import { useState } from 'react'
import { useIntersectionObserver } from '@/utils/ui.utils'
import { useArticleContainerContext } from '@/containers/ArticleContainer.Context'
type Props = { type Props = {
data: GoogleDocEnhanced data: GoogleDocEnhanced
} }
const ArticleBlocks = ({ data }: Props) => { const ArticleBlocks = ({ data }: Props) => {
const { setTocId, tocId } = useArticleContainerContext()
const headingElementsRef = useIntersectionObserver(setTocId)
return data.blocks.length ? ( return data.blocks.length ? (
<> <>
{getBodyBlocks(data).map((block, idx) => ( {getBodyBlocks(data).map((block, idx) => (
<RenderArticleBlock key={'block-' + idx} block={block} /> <RenderArticleBlock
key={'block-' + idx}
block={block}
activeId={tocId}
headingElementsRef={headingElementsRef}
/>
))} ))}
</> </>
) : null ) : 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 styles from './Article.module.css'
import { GoogleDocEnhanced } from '@/lib/unbody/unbody.types' import { GoogleDocEnhanced } from '@/lib/unbody/unbody.types'
import { Collapse } from '../Collapse' import { Collapse } from '../Collapse'
import Link from 'next/link'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
type Props = { type Props = {
toc: Array<Partial<GoogleDocEnhanced>> toc: UnbodyGraphQl.Fragments.TocItem[]
} }
export const MobileToc = ({ toc }: Props) => { export const MobileToc = ({ toc }: Props) => {
const { tocIndex, setTocIndex } = useArticleContainerContext() const { tocId, setTocId } = useArticleContainerContext()
return toc?.length > 0 ? ( return toc?.length > 0 ? (
<Collapse className={styles.mobileToc} label="Contents"> <Collapse className={styles.mobileToc} label="Contents">
{toc.map((toc, idx) => ( {toc.map((toc, idx) => (
<Content <TocItem
onClick={() => setTocIndex(idx)} href={`${idx === 0 ? '#' : toc.href}`}
active={idx === tocIndex}
variant="body3"
key={idx} key={idx}
active={tocId ? toc.href.substring(1) === tocId : idx === 0}
> >
<Typography variant="label2" genericFontFamily="sans-serif">
{toc.title} {toc.title}
</Content> </Typography>
</TocItem>
))} ))}
</Collapse> </Collapse>
) : null ) : null
@ -35,7 +38,7 @@ const CustomTypography = styled(Typography)`
white-space: pre-wrap; white-space: pre-wrap;
` `
const Content = styled(CustomTypography)<{ active: boolean }>` const TocItem = styled(Link)<{ active: boolean }>`
padding: 8px 14px; padding: 8px 14px;
background-color: ${(p) => background-color: ${(p) =>
p.active p.active

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,17 +58,27 @@ export default function App({ Component, pageProps }: AppLayoutProps) {
:root { :root {
--lpe-nav-rendered-height: ${uiConfigs.navbarRenderedHeight}px; --lpe-nav-rendered-height: ${uiConfigs.navbarRenderedHeight}px;
--lpe-article-rendered-margin-top: ${uiConfigs.articleRenderedMT}px;
} }
//.lazyload, a,
//img.lazyloading { a:visited,
// opacity: 0; a:hover,
// transition: opacity 4000ms; a:active,
//} a:focus {
// color: rgb(var(--lsd-text-primary));
//img.lazyloaded { }
// opacity: 1;
//} .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 /> <ProgressBar />

View File

@ -309,7 +309,8 @@ class UnbodyService extends UnbodyClient {
return this.request<UnbodyGraphQlResponseGoogleDoc>(query) return this.request<UnbodyGraphQlResponseGoogleDoc>(query)
.then(({ data }) => { .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)) return this.handleResponse(data.Get.GoogleDoc.map(enhanceGoogleDoc))
}) })
.catch((e) => this.handleResponse([], e)) .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) => { export const useSticky = <T extends HTMLElement>(dy: number = 0) => {
const stickyRef = useRef<T>(null) const stickyRef = useRef<T>(null)
@ -62,3 +62,41 @@ export const useIsScrolling = () => {
}, [setIsScrolling]) }, [setIsScrolling])
return isScrolling 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
}