Files
omnivore/packages/web/components/templates/article/Article.tsx
Jackson Harper eb8cb3854c Do all scroll watching on the main window
This moves all scrolling from child divs to the main window. This
improves our keyboard handling, as focus will be given to the
body element, not the child div when navigating by keyboard
commands, so arrow keys and space bar will work after navigating
to the reader with the keyboard commands.
2022-06-17 15:59:51 -07:00

235 lines
6.4 KiB
TypeScript

import { Box } from '../../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 { Tweet } from 'react-twitter-widgets'
import { render } from 'react-dom'
import { isDarkTheme } from '../../../lib/themeUpdater'
import debounce from 'lodash/debounce'
import { ArticleMutations } from '../../../lib/articleActions'
export type ArticleProps = {
articleId: string
content: string
highlightReady: boolean
initialAnchorIndex: number
initialReadingProgress?: number
highlightHref: MutableRefObject<string | null>
articleMutations: ArticleMutations
}
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 debouncedSetReadingProgress = useMemo(
() =>
debounce((readingProgress: number) => {
console.log('setReadingProgress', readingProgress)
setReadingProgress(readingProgress)
}, 2000),
[]
)
// Stop the invocation of the debounced function
// after unmounting
useEffect(() => {
return () => {
debouncedSetReadingProgress.cancel()
}
}, [])
useEffect(() => {
;(async () => {
if (!readingProgress) return
await props.articleMutations.articleReadingProgressMutation({
id: props.articleId,
// round reading progress to 100% if more than that
readingProgressPercent: readingProgress > 100 ? 100 : readingProgress,
readingProgressAnchorIndex: readingAnchorIndex,
})
})()
// 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])
// 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])
useScrollWatcher(
(changeset: ScrollOffsetChangeset) => {
if (window && window.document.scrollingElement) {
const newReadingProgress =
window.scrollY / window.document.scrollingElement.scrollHeight
const adjustedReadingProgress =
newReadingProgress > 0.92 ? 1 : newReadingProgress
debouncedSetReadingProgress(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`)
}
}
},
[]
)
// Scroll to initial anchor position
useEffect(() => {
if (typeof window === 'undefined') {
return
}
if (props.highlightReady) {
if (!shouldScrollToInitialPosition) {
return
}
setShouldScrollToInitialPosition(false)
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
return
}
const anchorElement = props.highlightHref.current
? document.querySelector(
`[omnivore-highlight-id="${props.highlightHref.current}"]`
)
: 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
}
const calculatedOffset = calculateOffset(anchorElement)
window.document.documentElement.scroll(0, calculatedOffset - 100)
}
}
}, [
props.highlightReady,
props.initialAnchorIndex,
props.initialReadingProgress,
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"
data-testid="article-inner"
dangerouslySetInnerHTML={{
__html: props.content,
}}
/>
</>
)
}