import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery' import { Box } from '../../elements/LayoutPrimitives' import { v4 as uuidv4 } from 'uuid' import { nanoid } from 'nanoid' import { useState, useEffect, useRef } from 'react' import { isDarkTheme } from '../../../lib/themeUpdater' import PSPDFKit from 'pspdfkit' import { Instance, HighlightAnnotation, List, Annotation, Rect } from 'pspdfkit' import type { Highlight } from '../../../lib/networking/fragments/highlightFragment' import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation' import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation' import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation' import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation' import { pspdfKitKey } from '../../../lib/appConfig' import { HighlightNoteModal } from './HighlightNoteModal' import { showErrorToast } from '../../../lib/toastHelpers' import { HEADER_HEIGHT } from '../homeFeed/HeaderSpacer' import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery' import SlidingPane from 'react-sliding-pane' import 'react-sliding-pane/dist/react-sliding-pane.css' import { NotebookContent } from './Notebook' import { NotebookHeader } from './NotebookHeader' import useWindowDimensions from '../../../lib/hooks/useGetWindowDimensions' export type PdfArticleContainerProps = { viewer: UserBasicData article: ArticleAttributes showHighlightsModal: boolean setShowHighlightsModal: React.Dispatch> } export default function PdfArticleContainer( props: PdfArticleContainerProps ): JSX.Element { const containerRef = useRef(null) const [notebookKey, setNotebookKey] = useState(uuidv4()) const [noteTarget, setNoteTarget] = useState(undefined) const [noteTargetPageIndex, setNoteTargetPageIndex] = useState< number | undefined >(undefined) const highlightsRef = useRef([]) const annotationOmnivoreId = (annotation: Annotation): string | undefined => { if ( annotation && annotation.customData && annotation.customData.omnivoreHighlight && (annotation.customData.omnivoreHighlight as Highlight).id ) { return (annotation.customData.omnivoreHighlight as Highlight).id } return undefined } useEffect(() => { let instance: Instance const container = containerRef.current ;(async function () { const ALLOWED_TOOLBAR_ITEM_TYPES = [ 'pager', 'zoom-out', 'zoom-in', 'zoom-mode', 'spacer', 'search', 'export-pdf', 'sidebar-bookmarks', 'sidebar-thumbnails', 'sidebar-document-outline', ] console.log('PSPDFKit.defaultToolbarItems', PSPDFKit.defaultToolbarItems) const toolbarItems = PSPDFKit.defaultToolbarItems.filter( (i) => ALLOWED_TOOLBAR_ITEM_TYPES.indexOf(i.type) !== -1 ) const positionPercentForAnnotation = (annotation: Annotation) => { let totalSize = 0 let sizeBefore = 0 for (let idx = 0; idx < annotation.pageIndex; idx++) { sizeBefore += instance.pageInfoForIndex(idx)?.height ?? 0 } for (let idx = 0; idx < instance.totalPageCount; idx++) { totalSize += instance.pageInfoForIndex(idx)?.height ?? 0 } return (sizeBefore + annotation.boundingBox.top) / totalSize } const annotationTooltipCallback = (annotation: Annotation) => { const highlightAnnotation = annotation as HighlightAnnotation const copy = { type: 'custom' as const, title: 'Copy', id: 'tooltip-copy-annotation', className: 'TooltipItem-Copy', onPress: async () => { const highlightText = await instance.getMarkupAnnotationText( highlightAnnotation ) navigator.clipboard.writeText(highlightText) instance.setSelectedAnnotation(null) }, } const remove = { type: 'custom' as const, title: 'Remove', id: 'tooltip-remove-annotation', className: 'TooltipItem-Remove', onPress: () => { const annotationId = annotationOmnivoreId(annotation) instance .delete(annotation) .then(() => { if (annotationId) { return deleteHighlightMutation(annotationId) } }) .then(() => { const highlightIdx = highlightsRef.current.findIndex( (value) => { return value.id == annotationId } ) if (highlightIdx > -1) { highlightsRef.current.splice(highlightIdx, 1) } }) .catch((err) => { showErrorToast('Error deleting highlight: ' + err) }) }, } const note = { type: 'custom' as const, title: 'Note', id: 'tooltip-note-annotation', className: 'TooltipItem-Note', onPress: async () => { if ( annotation.customData && annotation.customData.omnivoreHighlight && (annotation.customData.omnivoreHighlight as Highlight).shortId ) { const data = annotation.customData.omnivoreHighlight as Highlight const savedHighlight = highlightsRef.current.find( (other: Highlight) => { return other.id === data.id } ) data.annotation = savedHighlight?.annotation ?? data.annotation setNoteTargetPageIndex(annotation.pageIndex) setNoteTarget(data) } instance.setSelectedAnnotation(null) }, } // const share = { // type: 'custom' as const, // title: 'Share', // id: 'tooltip-share-annotation', // className: 'TooltipItem-Share', // onPress: () => { // if ( // annotation.customData && // annotation.customData.omnivoreHighlight && // (annotation.customData.omnivoreHighlight as Highlight).shortId // ) { // const data = annotation.customData.omnivoreHighlight as Highlight // handleOpenShare(data) // } // instance.setSelectedAnnotation(null) // }, // } return [copy, note, remove] } const annotationPresets = PSPDFKit.defaultAnnotationPresets annotationPresets.highlight = { opacity: 0.45, color: new PSPDFKit.Color({ r: 255, g: 210, b: 52 }), blendMode: PSPDFKit.BlendMode.multiply, } const initialPage = () => { const highlightHref = window.location.hash ? window.location.hash.split('#')[1] : null if (highlightHref) { // find the page index if possible const highlight = props.article.highlights.find( (h) => h.id === highlightHref ) if (highlight) { return highlight.highlightPositionAnchorIndex } } return props.article.readingProgressAnchorIndex } console.log( 'theme: ', isDarkTheme() ? PSPDFKit.Theme.DARK : PSPDFKit.Theme.LIGHT ) instance = await PSPDFKit.load({ container: container || '.pdf-container', toolbarItems, annotationPresets, document: props.article.url, theme: isDarkTheme() ? PSPDFKit.Theme.DARK : PSPDFKit.Theme.LIGHT, baseUrl: `${window.location.protocol}//${window.location.host}/`, licenseKey: pspdfKitKey, styleSheets: ['/static/pspdfkit-lib.css'], annotationTooltipCallback: annotationTooltipCallback, initialViewState: new PSPDFKit.ViewState({ zoom: PSPDFKit.ZoomMode.FIT_TO_WIDTH, currentPageIndex: initialPage() || 0, }), }) instance.addEventListener('annotations.willChange', async (event) => { const annotation = event.annotations.get(0) if ( !annotation || event.reason !== PSPDFKit.AnnotationsWillChangeReason.DELETE_END ) { return } const annotationId = annotationOmnivoreId(annotation) if (annotationId) { await deleteHighlightMutation(annotationId) } }) // Store the highlights in the highlightsRef and apply them to the PDF highlightsRef.current = props.article.highlights for (const highlight of props.article.highlights.filter( (h) => h.type == 'HIGHLIGHT' )) { const patch = JSON.parse(highlight.patch) if (highlight.annotation && patch.customData.omnivoreHighight) { patch.customData.omnivoreHighight.annotation = highlight.annotation } const annotation = PSPDFKit.Annotations.fromSerializableObject(patch) try { await instance.create(annotation) } catch (e) { console.log('error adding highlight') console.log(e) } } const findOverlappingHighlights = async ( instance: Instance, highlightAnnotation: HighlightAnnotation ): Promise> => { const existing = await instance.getAnnotations( highlightAnnotation.pageIndex ) const highlights = existing.filter((annotation) => { return ( annotation instanceof PSPDFKit.Annotations.HighlightAnnotation && annotation.customData && annotation.customData.omnivoreHighlight ) }) const overlapping = highlights.filter((annotation) => { const isRes = annotation.rects.some((rect: Rect) => { return highlightAnnotation.rects.some((highlightRect) => { return rect.isRectOverlapping(highlightRect) }) }) return isRes }) return overlapping } instance.addEventListener( 'annotations.create', async (createdAnnotations) => { const highlightAnnotation = createdAnnotations.get(0) if ( !( highlightAnnotation instanceof PSPDFKit.Annotations.HighlightAnnotation ) ) { return } // If the annotation already has the omnivore highlight // custom data its already been created, so we can // ignore this event. if ( highlightAnnotation.customData && highlightAnnotation.customData.omnivoreHighlight ) { // This highlight has already been created, so we skip adding it return } const overlapping = await findOverlappingHighlights( instance, highlightAnnotation ) const id = uuidv4() const shortId = nanoid(8) const quote = ( await instance.getMarkupAnnotationText(highlightAnnotation) ) .replace(/(\r\n|\n|\r)/gm, ' ') .trim() const surroundingText = { prefix: '', suffix: '' } const annotation = highlightAnnotation.set('customData', { omnivoreHighlight: { id, quote, shortId, prefix: surroundingText.prefix, suffix: surroundingText.suffix, articleId: props.article.id, }, }) await instance.update(annotation) const serialized = PSPDFKit.Annotations.toSerializableObject(annotation) if (overlapping.size === 0) { const positionPercent = positionPercentForAnnotation(annotation) const result = await createHighlightMutation({ id: id, shortId: shortId, quote: quote, articleId: props.article.id, prefix: surroundingText.prefix, suffix: surroundingText.suffix, patch: JSON.stringify(serialized), highlightPositionPercent: positionPercent * 100, highlightPositionAnchorIndex: annotation.pageIndex, }) if (result) { highlightsRef.current.push(result) } } else { // Create a new single highlight in the PDF const rects = highlightAnnotation.rects.concat( overlapping.flatMap((ha) => ha.rects as List) ) const annotation = new PSPDFKit.Annotations.HighlightAnnotation({ pageIndex: highlightAnnotation.pageIndex, rects: rects, opacity: 0.45, color: new PSPDFKit.Color({ r: 255, g: 210, b: 52 }), boundingBox: PSPDFKit.Geometry.Rect.union(rects), customData: { omnivoreHighlight: { id, quote, shortId, prefix: surroundingText.prefix, suffix: surroundingText.suffix, articleId: props.article.id, }, }, }) await instance.create(annotation) await instance.delete(overlapping) await instance.delete(highlightAnnotation) const mergedIds = overlapping.map( (ha) => (ha.customData?.omnivoreHighlight as Highlight).id ) const positionPercent = positionPercentForAnnotation(annotation) const result = await mergeHighlightMutation({ quote, id, shortId, patch: JSON.stringify(serialized), prefix: surroundingText.prefix, suffix: surroundingText.suffix, articleId: props.article.id, overlapHighlightIdList: mergedIds.toArray(), highlightPositionPercent: positionPercent * 100, highlightPositionAnchorIndex: annotation.pageIndex, }) if (result) { highlightsRef.current.push(result) } } } ) instance.addEventListener( 'viewState.currentPageIndex.change', async (pageIndex) => { const percent = Math.min( 100, Math.max(0, ((pageIndex + 1) / instance.totalPageCount) * 100) ) if (percent <= props.article.readingProgressPercent) { return } await articleReadingProgressMutation({ id: props.article.id, readingProgressPercent: percent, readingProgressAnchorIndex: pageIndex, }) } ) function getActiveElement(element = document.activeElement) { if (element && !('contentDocument' in element)) { return undefined } const shadowRoot = element?.shadowRoot const contentDocument = element?.contentDocument as Document if (shadowRoot && shadowRoot.activeElement) { return getActiveElement(shadowRoot.activeElement) } if (contentDocument && contentDocument.activeElement) { return getActiveElement(contentDocument.activeElement) } return element } function keyDownHandler(event: globalThis.KeyboardEvent) { var inputs = ['input', 'select', 'button', 'textarea'] if (event.target && 'nodeName' in event.target) { const nodeName = (event.target.nodeName as string).toLowerCase() if (inputs.indexOf(nodeName) != -1) { return } } var activeElement = event.target if (activeElement && 'nodeName' in activeElement) { if (inputs.indexOf(activeElement.nodeName as string) != -1) { return } } const key = event.key.toLowerCase() switch (key) { case 'o': document.dispatchEvent(new Event('openOriginalArticle')) break case 'u': const query = window.sessionStorage.getItem('q') if (query) { window.location.assign(`/home?${query}`) } else { window.location.replace(`/home`) } break case 'e': document.dispatchEvent(new Event('archive')) break case '#': document.dispatchEvent(new Event('delete')) break case 'h': const root = (event.target as HTMLElement).querySelector( '.PSPDFKit-Root' ) const highlight = root?.querySelector( '.PSPDFKit-Text-Markup-Inline-Toolbar-Highlight' ) console.log('root ', root) console.log('highlight overlay: ', highlight, highlight?.nodeName) if (highlight && highlight?.nodeName == 'BUTTON') { const button = highlight as HTMLButtonElement button.click() } break // case 'n': // TODO: need to set a post creation event here, then // go through the regular highlight creation // document.dispatchEvent(new Event('annotate')) // break case 't': props.setShowHighlightsModal(true) break case 'i': document.dispatchEvent(new Event('showEditModal')) break } } const isIE11 = navigator.userAgent.indexOf('Trident/') > -1 instance.contentDocument.addEventListener( 'keydown', keyDownHandler, isIE11 ? { capture: true, } : true ) })() document.addEventListener('deleteHighlightbyId', async (event) => { const annotationId = (event as CustomEvent).detail as string for (let pageIdx = 0; pageIdx < instance.totalPageCount; pageIdx++) { const annotations = await instance.getAnnotations(pageIdx) for (let annIdx = 0; annIdx < annotations.size; annIdx++) { const annotation = annotations.get(annIdx) if (!annotation) { continue } const storedId = annotationOmnivoreId(annotation) if (storedId == annotationId) { await instance.delete(annotation) await deleteHighlightMutation(annotationId) const highlightIdx = highlightsRef.current.findIndex((value) => { return value.id == annotationId }) if (highlightIdx > -1) { highlightsRef.current.splice(highlightIdx, 1) } // This is needed to force the notebook to reload the highlights setNotebookKey(uuidv4()) } } } }) document.addEventListener('scrollToHighlightId', async (event) => { const annotationId = (event as CustomEvent).detail as string for (let pageIdx = 0; pageIdx < instance.totalPageCount; pageIdx++) { const annotations = await instance.getAnnotations(pageIdx) for (let annIdx = 0; annIdx < annotations.size; annIdx++) { const annotation = annotations.get(annIdx) if (!annotation) { continue } const storedId = annotationOmnivoreId(annotation) if (storedId == annotationId) { instance.jumpToRect(pageIdx, annotation.boundingBox) } } } }) document.addEventListener('pdfReaderUpdateSettings', () => { const show = localStorage.getItem('reader-show-pdf-tool-bar') const showToolbarbar = show ? JSON.parse(show) == true : false instance.setViewState((viewState) => viewState.set('showToolbar', showToolbarbar) ) }) return () => { PSPDFKit && container && PSPDFKit.unload(container) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // We are intentially not setting exhaustive deps here, we only want to reload // the PSPDFKit instance if the theme, article URL, or page URL changes. Everything else // should be handled by the PSPDFKit instance callbacks. const windowDimensions = useWindowDimensions() return (
{noteTarget && ( { const savedHighlight = highlightsRef.current.find( (other: Highlight) => { return other.id == highlight.id } ) if (savedHighlight) { savedHighlight.annotation = highlight.annotation } }} onOpenChange={() => { setNoteTarget(undefined) }} /> )} { props.setShowHighlightsModal(false) }} > <> { const event = new CustomEvent('scrollToHighlightId', { detail: highlightId, }) document.dispatchEvent(event) }} /> ) }