Files
omnivore/packages/web/components/templates/article/Article.tsx

237 lines
6.4 KiB
TypeScript

import { Box } from './../../../components/elements/LayoutPrimitives'
import { useReadingProgressAnchor } from '../../../lib/hooks/useReadingProgressAnchor'
import {
ScrollOffsetChangeset,
useScrollWatcher,
} from '../../../lib/hooks/useScrollWatcher'
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
import { Tweet } from 'react-twitter-widgets'
import { render } from 'react-dom'
import { isDarkTheme } from '../../../lib/themeUpdater'
import { debounce } from 'lodash'
export type ArticleProps = {
articleId: string
content: string
initialAnchorIndex: number
initialReadingProgress?: number
scrollElementRef: MutableRefObject<HTMLDivElement | null>
}
export function Article(props: ArticleProps): JSX.Element {
const highlightTheme = isDarkTheme() ? 'dark' : 'default'
const [readingProgress, setReadingProgress] = useState(
props.initialReadingProgress
)
const [readingAnchorIndex, setReadingAnchorIndex] = useState(
props.initialAnchorIndex
)
const [shouldScrollToInitialPosition, setShouldScrollToInitialPosition] =
useState(true)
const articleContentRef = useRef<HTMLDivElement | null>(null)
useReadingProgressAnchor(articleContentRef, setReadingAnchorIndex)
const debouncedReadingProgress = useMemo(
() =>
debounce(async () => {
if (!readingProgress) return
await articleReadingProgressMutation({
id: props.articleId,
readingProgressPercent: readingProgress,
readingProgressAnchorIndex: readingAnchorIndex,
})
}, 3000),
[readingProgress]
)
useEffect(() => {
;(async () => {
await debouncedReadingProgress()
})()
// We don't react to changes to readingAnchorIndex we
// only care about the progress (scroll position) changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.articleId, readingProgress, readingAnchorIndex])
// Post message to webkit so apple app embeds get progress updates
useEffect(() => {
if (typeof window?.webkit != 'undefined') {
window.webkit.messageHandlers.readingProgressUpdate?.postMessage({
progress: readingProgress,
})
}
}, [readingProgress])
const setScrollWatchedElement = useScrollWatcher(
(changeset: ScrollOffsetChangeset) => {
const scrollContainer = props.scrollElementRef.current
if (scrollContainer) {
const newReadingProgress =
(changeset.current.y + scrollContainer.clientHeight) /
scrollContainer.scrollHeight
setReadingProgress(newReadingProgress * 100)
} else if (window && window.document.scrollingElement) {
const newReadingProgress =
window.scrollY / window.document.scrollingElement.scrollHeight
const adjustedReadingProgress =
newReadingProgress > 0.92 ? 1 : newReadingProgress
setReadingProgress(adjustedReadingProgress * 100)
}
},
1000
)
const layoutImages = useCallback(
(image: HTMLImageElement, container: HTMLDivElement | null) => {
if (!container) return
const containerWidth = container.clientWidth + 140
if (!image.closest('blockquote, table')) {
let imageWidth = parseFloat(image.getAttribute('width') || '')
imageWidth = isNaN(imageWidth) ? image.naturalWidth : imageWidth
if (imageWidth > containerWidth) {
image.style.setProperty(
'width',
`${Math.min(imageWidth, containerWidth)}px`
)
image.style.setProperty('max-width', 'unset')
image.style.setProperty('margin-left', `-${Math.round(140 / 2)}px`)
}
}
},
[]
)
useEffect(() => {
setScrollWatchedElement(props.scrollElementRef.current)
}, [props.scrollElementRef, setScrollWatchedElement])
// Scroll to initial anchor position
useEffect(() => {
if (typeof window === 'undefined') {
return
}
if (!shouldScrollToInitialPosition) {
return
}
setShouldScrollToInitialPosition(false)
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
return
}
const anchorElement = document.querySelector(
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
)
if (anchorElement) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const calculateOffset = (obj: any): number => {
let offset = 0
if (obj.offsetParent) {
do {
offset += obj.offsetTop
} while ((obj = obj.offsetParent))
return offset
}
return 0
}
if (props.scrollElementRef.current) {
props.scrollElementRef.current?.scroll(
0,
calculateOffset(anchorElement)
)
} else {
window.document.documentElement.scroll(
0,
calculateOffset(anchorElement)
)
}
}
}, [
props.initialAnchorIndex,
props.initialReadingProgress,
props.scrollElementRef,
shouldScrollToInitialPosition,
])
useEffect(() => {
if (typeof window?.MathJax?.typeset === 'function') {
window.MathJax.typeset()
}
const tweets = Array.from(
document.getElementsByClassName('tweet-placeholder')
)
tweets.forEach((tweet) => {
render(
<Tweet
tweetId={tweet.getAttribute('data-tweet-id') || ''}
options={{
theme: isDarkTheme() ? 'dark' : 'light',
align: 'center',
}}
/>,
tweet
)
})
}, [])
const onLoadImageHandler = useCallback(() => {
const images = articleContentRef.current?.querySelectorAll('img')
images?.forEach((image) => {
layoutImages(image, articleContentRef.current)
})
}, [layoutImages])
useEffect(() => {
window.addEventListener('load', onLoadImageHandler)
return () => {
window.removeEventListener('load', onLoadImageHandler)
}
}, [onLoadImageHandler])
return (
<>
<link
rel="stylesheet"
href={`https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/styles/${highlightTheme}.min.css`}
/>
<Box
ref={articleContentRef}
css={{
maxWidth: '100%',
}}
className="article-inner-css"
dangerouslySetInnerHTML={{
__html: props.content,
}}
/>
</>
)
}