mirror of
https://github.com/acid-info/logos-press-engine.git
synced 2025-02-23 14:48:08 +00:00
close #28 and refactor toc renders
This commit is contained in:
parent
bc2da5ebac
commit
13859dc3c1
@ -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;
|
||||
`
|
||||
|
@ -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
|
||||
|
48
src/components/Article/Article.Heading.tsx
Normal file
48
src/components/Article/Article.Heading.tsx
Normal 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;
|
||||
`
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -21,7 +21,6 @@ export const ProgressBar = () => {
|
||||
<NextNProgress
|
||||
color={color}
|
||||
height={1}
|
||||
showOnShallow={true}
|
||||
options={{
|
||||
showSpinner: false,
|
||||
}}
|
||||
|
@ -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))'
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { PostListLayout } from '@/types/ui.types'
|
||||
|
||||
export const uiConfigs = {
|
||||
navbarRenderedHeight: 45,
|
||||
postSectionMargin: 78,
|
||||
maxContainerWidth: 1400,
|
||||
articleRenderedMT: 45 * 2,
|
||||
}
|
||||
|
@ -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 =
|
||||
|
@ -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%',
|
||||
|
@ -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 />
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user