Write a PDF.js wrapper to replace pspdfkit

This commit is contained in:
Thomas Rogers 2024-12-12 22:56:31 +01:00
parent eab1c2a47d
commit 93d05c14e5
186 changed files with 1821 additions and 2984 deletions

View file

@ -21,6 +21,7 @@
"ignorePatterns": ["next.config.js", "jest.config.js"],
"rules": {
"functional/no-mixed-type": 0,
"react/react-in-jsx-scope": 0
"react/react-in-jsx-scope": 0,
"@typescript-eslint/ban-ts-comment" : 0
}
}

View file

@ -1,7 +1,7 @@
# Note this docker file is meant for local testing
# and not for production.
FROM node:18.16-alpine as builder
FROM node:22.12-alpine as builder
ENV NODE_OPTIONS=--max-old-space-size=8192
ARG APP_ENV
ARG BASE_URL
@ -12,7 +12,7 @@ ENV NEXT_PUBLIC_BASE_URL=$BASE_URL
ENV NEXT_PUBLIC_SERVER_BASE_URL=$SERVER_BASE_URL
ENV NEXT_PUBLIC_HIGHLIGHTS_BASE_URL=$HIGHLIGHTS_BASE_URL
RUN apk add g++ make python3
RUN apk add g++ make python3 py3-setuptools
WORKDIR /app
@ -32,7 +32,7 @@ RUN echo "module.exports = {}" > ./packages/web/next.config.js
RUN yarn workspace @omnivore/web build
FROM node:18.16-alpine as runner
FROM node:22.12-alpine as builder
LABEL org.opencontainers.image.source="https://github.com/omnivore-app/omnivore"
ENV NODE_ENV production

View file

@ -1,7 +1,7 @@
# Note this docker file is meant for local testing
# and not for production.
FROM node:18.16-alpine as builder
FROM node:22.12-alpine as builder
ENV NODE_OPTIONS=--max-old-space-size=8192
ARG APP_ENV
ARG BASE_URL
@ -12,7 +12,7 @@ ENV NEXT_PUBLIC_BASE_URL=$BASE_URL
ENV NEXT_PUBLIC_SERVER_BASE_URL=$SERVER_BASE_URL
ENV NEXT_PUBLIC_HIGHLIGHTS_BASE_URL=$HIGHLIGHTS_BASE_URL
RUN apk add g++ make python3
RUN apk add g++ make python3 py3-setuptools
WORKDIR /app
@ -31,7 +31,7 @@ COPY ./packages/web/next.config.self.js ./packages/web/next.config.js
RUN yarn workspace @omnivore/web build
FROM node:18.16-alpine as runner
FROM node:22.12-alpine as runner
LABEL org.opencontainers.image.source="https://github.com/omnivore-app/omnivore"
ENV NODE_ENV production

View file

@ -45,7 +45,7 @@ export function HighlightBar(props: HighlightBarProps): JSX.Element {
borderRadius: '5px',
border: '1px solid $thHighlightBar',
boxShadow: `0px 4px 4px 0px rgba(0, 0, 0, 0.15)`,
zIndex: 999,
...(props.displayAtBottom && {
bottom: 'calc(38px + env(safe-area-inset-bottom, 40px))',
}),

View file

@ -0,0 +1,280 @@
import {
ArticleAttributes,
ArticleReadingProgressMutationInput,
useUpdateItemReadStatus,
} from '../../../../lib/networking/library_items/useLibraryItems'
import { HStack, VStack } from '../../../elements/LayoutPrimitives'
import React, { useEffect, useRef, useState } from 'react'
import { UserBasicData } from '../../../../lib/networking/queries/useGetViewerQuery'
import 'react-sliding-pane/dist/react-sliding-pane.css'
import {
CreateHighlightInput,
useCreateHighlight,
useDeleteHighlight,
useMergeHighlight,
useUpdateHighlight,
} from '../../../../lib/networking/highlights/useItemHighlights'
import 'pdfjs-dist/web/pdf_viewer.css'
import { EventBus, PDFViewer } from 'pdfjs-dist/types/web/pdf_viewer'
import PdfViewer from './PdfViewer'
import PdfToolbar from './PdfToolbar'
import PdfSearchBar from './PdfSearchBar'
import { NotebookHeader } from '../NotebookHeader'
import { NotebookContent } from '../Notebook'
import { ResizableSidebar } from '../ResizableSidebar'
import { PDFDocumentProxy } from 'pdfjs-dist'
import { PDFLinkService } from 'pdfjs-dist/types/web/pdf_link_service'
import PdfSideBar from './PdfSideBar'
export type PdfArticleContainerProps = {
viewer: UserBasicData
article: ArticleAttributes
showHighlightsModal: boolean
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
}
export default function PdfArticleContainer(props: PdfArticleContainerProps) {
// @ts-ignore
const pdfJS = import('pdfjs-dist/build/pdf.min.mjs')
const containerRef = useRef<HTMLDivElement | null>(null)
const [pdfViewer, setPdfViewer] = useState<PDFViewer | null>(null)
const [eventBus, setEventBus] = useState<EventBus | null>(null)
const [pageNumber, setPageNumber] = useState<number>(1)
const [pageCount, setTotalPageCount] = useState<number>(0)
const [showSearch, setShowSearch] = useState(false)
const [showToolbar, setShowToolbar] = useState(true)
const [sidebarActive, setSidebarActive] = useState<boolean>(false)
const createHighlight = useCreateHighlight()
const deleteHighlight = useDeleteHighlight()
const mergeHighlight = useMergeHighlight()
const updateHighlight = useUpdateHighlight()
const updateItemReadStatus = useUpdateItemReadStatus()
const createPdfViewer = async (): Promise<PDFViewer> => {
const pdfJSLib = await pdfJS
const pdfjsViewer = await import('pdfjs-dist/web/pdf_viewer.mjs')
pdfJSLib.GlobalWorkerOptions.workerSrc =
window.location.origin + '/pdfjs-dist/build/pdf.worker.min.mjs'
const eventBus = new pdfjsViewer.EventBus()
const pdfLinkService = new pdfjsViewer.PDFLinkService({
eventBus,
})
const pdfFindController = new pdfjsViewer.PDFFindController({
eventBus,
linkService: pdfLinkService,
})
const pdfScriptingManager = new pdfjsViewer.PDFScriptingManager({
eventBus,
sandboxBundleSrc:
window.location.origin + '/pdfjs-dist/build/pdf.sandbox.mjs',
})
const pdfViewer = new pdfjsViewer.PDFViewer({
container: containerRef.current!,
eventBus,
linkService: pdfLinkService,
findController: pdfFindController,
scriptingManager: pdfScriptingManager,
})
pdfScriptingManager.setViewer(pdfViewer)
pdfLinkService.pdfViewer = pdfViewer
return pdfViewer
}
const loadPdfDocument = async (): Promise<PDFDocumentProxy> => {
const pdfJsLib = await pdfJS
const loadingTask = pdfJsLib.getDocument({
url: props.article.url,
cMapUrl: window.location.origin + '/pdfjs-dist/cmaps/',
cMapPacked: true,
enableXfa: true,
})
return loadingTask.promise
}
useEffect(() => {
// Uses the existing mechanism to hide the reader toolbar from pspdfkit
document.addEventListener('pdfReaderUpdateSettings', () => {
const show = localStorage.getItem('reader-show-pdf-tool-bar')
const showBar = show ? JSON.parse(show) == true : false
setShowToolbar(showBar)
})
;(async () => {
const pdfViewer = await createPdfViewer()
const pdfDocument = await loadPdfDocument()
setTotalPageCount(pdfDocument.numPages)
pdfViewer.setDocument(pdfDocument)
const linkService = pdfViewer.linkService as PDFLinkService
linkService.setDocument(pdfDocument, null)
// Doesn't seem to get applied straight away, causing an issue where the scale would
// be set to 0. We do a 200 ms timeout to avoid this bug.
setTimeout(() => {
pdfViewer.currentScale = 1
pdfViewer.scrollPageIntoView({
pageNumber: props.article.readingProgressAnchorIndex,
})
}, 200)
setPdfViewer(pdfViewer)
setEventBus(pdfViewer.eventBus)
pdfViewer.eventBus.on(
'pagechanging',
(e: { previous: number; pageNumber: number }) => {
console.log('Page Changing....')
setPageNumber(e.pageNumber)
}
)
})()
}, [])
return (
<VStack css={{ width: '100%' }}>
{showSearch && <PdfSearchBar pdfViewer={pdfViewer} eventBus={eventBus} />}
{showToolbar && (
<PdfToolbar
setShowSidebar={setSidebarActive}
sidebarActive={sidebarActive}
viewer={props.viewer}
article={props.article}
pdfViewer={pdfViewer}
eventBus={eventBus}
pageNumber={pageNumber}
setPageNumber={setPageNumber}
totalPageNumbers={pageCount}
showSearch={showSearch}
setShowSearch={setShowSearch}
/>
)}
<HStack>
<PdfSideBar
setPage={(page: number) => {
if (pdfViewer) {
pdfViewer.currentPageNumber = page
}
}}
pdfDocument={pdfViewer?.pdfDocument}
activePage={pageNumber}
sidebarActive={sidebarActive}
totalPages={pageCount}
/>
<PdfViewer
viewer={props.viewer}
article={props.article}
containerRef={containerRef}
eventBus={eventBus}
sidebarActive={sidebarActive}
pdfViewer={pdfViewer}
articleMutations={{
createHighlightMutation: async (input: CreateHighlightInput) => {
try {
return await createHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
input,
})
} catch (err) {
console.log('error creating highlight', err)
return undefined
}
},
deleteHighlightMutation: async (
_libraryItemId: string,
highlightId: string
) => {
try {
await deleteHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
highlightId,
})
return true
} catch (err) {
console.log('error deleting highlight', err)
return false
}
},
mergeHighlightMutation: async (input) => {
try {
const result = await mergeHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
input,
})
return result?.highlight
} catch (err) {
console.log('error merging highlight', err)
return undefined
}
},
updateHighlightMutation: async (input) => {
try {
const result = await updateHighlight.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
input,
})
return result?.id
} catch (err) {
console.log('error updating highlight', err)
return undefined
}
},
articleReadingProgressMutation: async (
input: ArticleReadingProgressMutationInput
) => {
try {
await updateItemReadStatus.mutateAsync({
itemId: props.article.id,
slug: props.article.slug,
input,
})
} catch {
return false
}
return true
},
}}
/>
</HStack>
<ResizableSidebar
isShow={props.showHighlightsModal}
onClose={() => {
props.setShowHighlightsModal(false)
}}
>
<NotebookHeader
viewer={props.viewer}
item={props.article}
setShowNotebook={props.setShowHighlightsModal}
/>
<NotebookContent
viewer={props.viewer}
item={props.article}
viewInReader={(highlightId) => {
const highlight = props.article.highlights?.filter(
(it) => it.id == highlightId
)
if (highlight && highlight.length == 1) {
const pageId = highlight[0].highlightPositionAnchorIndex
if (pdfViewer) {
pdfViewer.currentPageNumber = pageId ?? 1
}
}
}}
/>
</ResizableSidebar>
</VStack>
)
}

View file

@ -0,0 +1,125 @@
import { HStack } from '../../../elements/LayoutPrimitives'
import React, { ChangeEvent, useEffect, useState } from 'react'
import { DEFAULT_HEADER_HEIGHT } from '../../homeFeed/HeaderSpacer'
import 'react-sliding-pane/dist/react-sliding-pane.css'
import 'pdfjs-dist/web/pdf_viewer.css'
import { EventBus, PDFViewer } from 'pdfjs-dist/types/web/pdf_viewer'
import { CaretLeft, CaretRight } from '@phosphor-icons/react'
import { SearchInput, ToolbarButton } from './Style'
export type PdfSearchProps = {
pdfViewer: PDFViewer | null
eventBus: EventBus | null
}
export default function PdfSearchBar(props: PdfSearchProps) {
const [searchTerm, setSearchTerm] = useState<string | null>(null)
const [hasResults, setHasResults] = useState(false)
const [foundMatches, setFoundMatches] = useState(0)
const [currentMatch, setCurrentMatch] = useState(1)
useEffect(() => {
if (props.eventBus) {
props.eventBus.on(
'updatefindmatchescount',
(data: { matchesCount: { total: number } }) => {
setHasResults(true)
setCurrentMatch(1)
setFoundMatches(data.matchesCount.total)
}
)
}
return () => {
props.eventBus?.dispatch('find', { type: '', query: '' })
}
}, [props.eventBus])
useEffect(() => {
const timeoutId = setTimeout(() => {
if (searchTerm) {
props.eventBus?.dispatch('find', {
type: '',
caseSensitive: false,
findPrevious: false,
highlightAll: true,
phraseSearch: true,
query: searchTerm,
})
}
}, 500)
return () => clearTimeout(timeoutId)
}, [searchTerm, props.eventBus])
const search = (input: ChangeEvent<HTMLInputElement>) =>
setSearchTerm(input.target.value)
const find = (forward: number) => () => {
if (currentMatch < foundMatches && currentMatch + forward > 1) {
props.eventBus?.dispatch('find', {
type: 'again',
caseSensitive: false,
findPrevious: forward != 1,
highlightAll: true,
phraseSearch: true,
query: searchTerm,
})
setCurrentMatch(currentMatch + forward)
}
}
return (
<HStack
distribution="start"
css={{
position: 'fixed',
alignItems: 'flex-start',
width: '400px',
right: '5px',
padding: '5px',
top: `calc(${DEFAULT_HEADER_HEIGHT} + 18px)`,
height: '35px',
borderTop: '1px solid black',
boxShadow: 'rgba(61, 66, 78, 0.5) 0px 1px 2px 0px',
background: '$thBackground4',
zIndex: 100,
}}
>
<SearchInput onChange={search} />
<div
style={{
position: 'absolute',
left: '280px',
top: '10px',
fontSize: '10px',
}}
>
{hasResults ? `${currentMatch} of ${foundMatches}` : ''}
</div>
<div>
<ToolbarButton
onClick={find(-1)}
css={{
borderTopLeftRadius: '10px',
borderBottomLeftRadius: '10px',
backgroundColor: '$thBackground5',
}}
>
<CaretLeft />
</ToolbarButton>
<ToolbarButton
css={{
borderTopRightRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: '$thBackground5',
}}
onClick={find(1)}
>
<CaretRight />
</ToolbarButton>
</div>
</HStack>
)
}

View file

@ -0,0 +1,107 @@
import { VStack } from '../../../elements/LayoutPrimitives'
import React, { useEffect, useState } from 'react'
import 'react-sliding-pane/dist/react-sliding-pane.css'
import 'pdfjs-dist/web/pdf_viewer.css'
import { PDFDocumentProxy } from 'pdfjs-dist'
export type PdfSidebarProps = {
sidebarActive: boolean
pdfDocument: PDFDocumentProxy | undefined
totalPages: number
activePage: number
setPage: (page: number) => void
}
export default function PdfSideBar(props: PdfSidebarProps) {
const [images, setImages] = useState<string[]>([])
useEffect(() => {
;(async () => {
if (props.pdfDocument) {
const propsArray = [...Array(props.pdfDocument.numPages ?? 0)];
const dataUrls = await Promise.all(propsArray.map(async (_it, i) => {
const page = await props.pdfDocument!.getPage(i + 1)
const viewport = page.getViewport({ scale: 0.3 })
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
if (ctx) {
const task = page.render({ canvasContext: ctx, viewport: viewport })
return task.promise.then(() => canvas.toDataURL())
}
return "";
}))
console.log("Setting Data Urls");
setImages(dataUrls)
}
})()
}, [props.pdfDocument])
if (!props.sidebarActive) {
return null
}
return (
<VStack
distribution="start"
css={{
alignItems: 'flex-start',
position: 'fixed',
left: '0px',
top: '100px',
padding: '7px',
paddingTop: '10px',
width: '250px',
height: 'calc(100% - 100px)',
borderTop: '1px solid black',
boxShadow: 'rgba(61, 66, 78, 0.5) 0px 1px 2px 0px',
background: '$thBackground2',
overflow: 'scroll',
}}
>
{[...Array(props.totalPages)].map((_it, idx) => (
<VStack
key={`pdf-page-${idx}`}
css={{ width: '80%', margin: 'auto', height: '100%' }}
onClick={() => props.setPage(idx + 1)}
>
<div
style={
idx == props.activePage - 1
? { outline: '5px solid var(--colors-thBackgroundActive)', width: '100%' }
: { width: '100%', height: '100%' }
}
>
<img
width={'100%'}
alt={`page-${idx + 1}`}
src={images[idx]}
style={{ display: 'block', objectFit: 'contain' }}
/>
</div>
<div
style={{ margin: 'auto', marginTop: '3px', marginBottom: '3px' }}
>
<span
style={{
backgroundColor: 'var(--colors-thLeftMenuBackground)',
paddingLeft: '10px',
paddingRight: '10px',
borderRadius: '10px',
}}
>
{idx + 1}
</span>
</div>
</VStack>
))}
</VStack>
)
}

View file

@ -0,0 +1,180 @@
import { ArticleAttributes } from '../../../../lib/networking/library_items/useLibraryItems'
import { HStack } from '../../../elements/LayoutPrimitives'
import React, { useState } from 'react'
import { DEFAULT_HEADER_HEIGHT } from '../../homeFeed/HeaderSpacer'
import { UserBasicData } from '../../../../lib/networking/queries/useGetViewerQuery'
import 'react-sliding-pane/dist/react-sliding-pane.css'
import 'pdfjs-dist/web/pdf_viewer.css'
import { EventBus, PDFViewer } from 'pdfjs-dist/types/web/pdf_viewer'
import {
CaretLeft,
CaretRight,
CornersIn,
Download,
FrameCorners,
MagnifyingGlass,
MagnifyingGlassMinus,
MagnifyingGlassPlus,
Sidebar,
} from '@phosphor-icons/react'
import { PageInput, ToolbarButton, ToolbarIconButton } from './Style'
export type PdfArticleToolbarProps = {
viewer: UserBasicData
article: ArticleAttributes
pdfViewer: PDFViewer | null
eventBus: EventBus | null
pageNumber: number
totalPageNumbers: number
setPageNumber: React.Dispatch<React.SetStateAction<number>>
showSearch: boolean
setShowSearch: React.Dispatch<React.SetStateAction<boolean>>
setShowSidebar: React.Dispatch<React.SetStateAction<boolean>>
sidebarActive: boolean
}
export default function PdfToolbar(props: PdfArticleToolbarProps): JSX.Element {
const [zoomMode, setZoomMode] = useState('page-fit')
const zoom = (step: number) => () => {
setZoomMode('zoom')
if (step == 1) {
props.pdfViewer?.increaseScale({ steps: 1 })
return
}
props.pdfViewer?.decreaseScale()
}
const changePage = (step: number) => () => {
setPage({ target: { value: (props.pageNumber + step).toString() } })
}
const setPage = (e: { target: { value: string } }) => {
const newPage = e.target.value
if (!isNaN(parseInt(newPage))) {
const clamped = Math.max(
1,
Math.min(parseInt(newPage), props.totalPageNumbers)
)
props.setPageNumber(clamped)
if (props.pdfViewer) {
props.pdfViewer.currentPageNumber = clamped
}
}
}
const zoomModeToggle = async () => {
const newZoomMode = zoomMode == 'page-fit' ? 'zoom' : 'page-fit'
setZoomMode(newZoomMode)
if (newZoomMode == 'page-fit') {
if (props.pdfViewer) {
props.pdfViewer.currentScaleValue = 'page-fit'
}
return
}
if (props.pdfViewer) {
const pdfPage = await props.pdfViewer.pdfDocument?.getPage(
props.pageNumber
)
const viewPort = pdfPage!.getViewport({
scale: 1700 / pdfPage!.getViewport({ scale: 1.0 }).width,
})
props.pdfViewer.currentScale = viewPort.scale
}
}
return (
<HStack
distribution="start"
css={{
position: 'fixed',
alignItems: 'flex-start',
width: '100%',
top: `calc(${DEFAULT_HEADER_HEIGHT} - 25px)`,
height: '44px',
lineHeight: '40px',
borderTop: '1px solid black',
boxShadow: 'rgba(61, 66, 78, 0.5) 0px 1px 2px 0px',
background: '$thBackground2',
fontSize: '24px',
zIndex: 2,
}}
>
<ToolbarIconButton onClick={() => props.setShowSidebar(!props.sidebarActive)} css={ props.sidebarActive ? { background: '$thBackground' } : {}}>
<Sidebar />
</ToolbarIconButton>
<HStack style={{ padding: '2px 10px 0 10px' }}>
<span style={{ fontSize: '16px ' }}>Page {' '}</span>
<HStack
style={{ paddingLeft: '5px', paddingRight: '5px', margin: 'auto' }}
>
<ToolbarButton
onClick={changePage(-1)}
css={{
borderTopLeftRadius: '10px',
borderBottomLeftRadius: '10px',
}}
>
<CaretLeft />
</ToolbarButton>
<PageInput
type="number"
name="pageNumber"
onChange={setPage}
value={props.pageNumber}
/>
<ToolbarButton
onClick={changePage(1)}
css={{
borderTopRightRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: '$thBackground5',
}}
>
<CaretRight />
</ToolbarButton>
</HStack>
<span style={{ fontSize: '16px' }}>
{' '} of {props.totalPageNumbers}
</span>
</HStack>
<HStack>
<ToolbarIconButton onClick={zoom(-1)}>
<MagnifyingGlassMinus />
</ToolbarIconButton>
<ToolbarIconButton onClick={zoom(1)}>
<MagnifyingGlassPlus />
</ToolbarIconButton>
<ToolbarIconButton onClick={zoomModeToggle}>
{zoomMode == 'page-fit' ? <FrameCorners /> : <CornersIn />}
</ToolbarIconButton>
</HStack>
<div style={{ flexGrow: 1 }}></div>
<HStack>
<ToolbarIconButton
onClick={() => props.setShowSearch(!props.showSearch)}
css={{ backgroundColor: props.showSearch ? '$thBackground' : 'none' }}
>
<MagnifyingGlass />
</ToolbarIconButton>
<a
href={props.article.url}
target="_blank"
download
style={{ all: 'unset' }}
>
<ToolbarIconButton>
<Download />
</ToolbarIconButton>
</a>
</HStack>
</HStack>
)
}

View file

@ -0,0 +1,463 @@
import {
ArticleAttributes,
LibraryItemNode,
} from '../../../../lib/networking/library_items/useLibraryItems'
import { Box } from '../../../elements/LayoutPrimitives'
import { v4 as uuidv4 } from 'uuid'
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { DEFAULT_HEADER_HEIGHT } from '../../homeFeed/HeaderSpacer'
import { UserBasicData } from '../../../../lib/networking/queries/useGetViewerQuery'
import 'react-sliding-pane/dist/react-sliding-pane.css'
import 'pdfjs-dist/web/pdf_viewer.css'
import { EventBus, PDFViewer } from 'pdfjs-dist/types/web/pdf_viewer'
import { PDFPageView } from 'pdfjs-dist/types/web/pdf_page_view'
import { HighlightAction, HighlightBar } from '../../../patterns/HighlightBar'
import { isTouchScreenDevice } from '../../../../lib/deviceType'
import { ArticleMutations } from '../../../../lib/articleActions'
import { HighlightNoteModal } from '../HighlightNoteModal'
import type { Highlight } from '../../../../lib/networking/fragments/highlightFragment'
export type PdfArticleContainerProps = {
viewer: UserBasicData
article: ArticleAttributes
containerRef: MutableRefObject<HTMLDivElement | null>
pdfViewer: PDFViewer | null
eventBus: EventBus | null
sidebarActive: boolean
articleMutations: ArticleMutations
}
export default function PdfViewer(props: PdfArticleContainerProps) {
const [highlights, setHighlights] = useState(props.article.highlights ?? [])
const [pageCoordinates, setPageCoordinates] = useState<{
pageX: number
pageY: number
}>({ pageX: 0, pageY: 0 })
const [showHighlightModal, setShowHighlightModal] = useState(false)
const [noteTarget, setNoteTarget] = useState<Highlight | undefined>(undefined)
const [clickedHighlight, setClickedHighlight] =
useState<Highlight | null>(null)
const [currentPageNum, setCurrentPageNum] = useState(1)
const colorMap: Record<string, string> = useMemo(
() => ({
yellow: 'rgba(255, 210, 52, 0.3)',
red: 'rgba(251, 154, 154, 0.3)',
green: 'rgba(85, 198, 137, 0.3)',
blue: 'rgba(106, 177, 255, 0.3)',
}),
[]
)
const addHighlightToPage = useCallback(
(page: PDFPageView, highlight: Highlight) => {
const scale = page.viewport.scale
const element = document.createElement('div')
element.id = highlight.id
const boundingRects = JSON.parse(highlight.patch).rects
const color = highlight.color || 'yellow'
boundingRects.forEach((rect: number[]) => {
const svgElement = document.createElement('svg')
svgElement.className = 'highlight'
svgElement.setAttribute('viewBox', '0 0 1 1')
svgElement.style.borderRadius = '0px'
svgElement.style.background = colorMap[color]
svgElement.style.position = 'absolute'
svgElement.style.top = `${rect[0] * scale}px`
svgElement.style.left = `${rect[1] * scale}px`
svgElement.style.width = `${rect[2] * scale}px`
svgElement.style.height = `${rect[3] * scale}px`
svgElement.style.cursor = 'pointer'
svgElement.style.zIndex = '3'
svgElement.innerHTML = ``
svgElement.innerHTML += `
<defs>
<path id="path_p1_2" vector-effect="non-scaling-stroke" d="M0 1 V0 H1 V1 Z"></path>
<clipPath id="clip_path_p1_2" clipPathUnits="objectBoundingBox">
<use href="#path_p1_2" class="clip"></use>
</clipPath></defs>
<use href="#path_p1_2"></use>
`
svgElement.addEventListener('click', (evt) => {
setClickedHighlight(highlight)
setPageCoordinates({ pageX: evt.clientX, pageY: evt.clientY })
setShowHighlightModal(true)
})
element.prepend(svgElement)
})
page.div.children[0].append(element)
},
[colorMap]
)
const getBoundingRects = (
canvasBoundingBox: DOMRect,
highlightBoundingBoxes: DOMRectList,
scaleFactor: number
): number[][] => {
const rects = Array.from(highlightBoundingBoxes).map((rect) => [
(rect.top - canvasBoundingBox.y) / scaleFactor,
(rect.left - canvasBoundingBox.x) / scaleFactor,
rect.width / scaleFactor,
rect.height / scaleFactor,
])
return Object.values(
rects.reduce((acc, curr) => {
acc[`l${curr[1]}w${curr[2]}}`] = curr
return acc
}, {} as Record<string, number[]>)
)
}
const addNoteToNewHighlight = async (
note: string | undefined,
noteTarget: Highlight
) => {
if (props.pdfViewer && props.pdfViewer._pages) {
const currentPageIndex = props.pdfViewer.currentPageNumber - 1
const page = props.pdfViewer._pages[currentPageIndex]
const highlight = await props.articleMutations.createHighlightMutation({
id: noteTarget.id,
shortId: noteTarget.id.slice(0, 12),
articleId: noteTarget.libraryItem?.id ?? props.article.id,
quote: noteTarget.quote,
color: noteTarget?.color || 'yellow',
patch: noteTarget.patch,
highlightPositionAnchorIndex: noteTarget.highlightPositionAnchorIndex,
annotation: note,
})
if (highlight) {
addHighlightToPage(page, highlight)
setHighlights([...highlights, highlight])
}
return highlight
}
}
const copyHighlightedText = async (quote: string) => {
await navigator.clipboard.writeText(quote)
}
const viewNoteTextForClickedHighlight = (clickedHighlight: Highlight) => {
setNoteTarget(clickedHighlight)
}
const updateColorForClickedHighlight = async (
pdfPage: PDFPageView,
clickedHighlight: Highlight,
newColor: string
) => {
await props.articleMutations.updateHighlightMutation({
highlightId: clickedHighlight.id,
color: newColor,
})
document.getElementById(clickedHighlight.id)?.remove()
clickedHighlight.color = newColor
addHighlightToPage(pdfPage, clickedHighlight)
setClickedHighlight(null)
}
const createHighlightWithNote = (
id: string,
quote: string,
pageNumber: number,
rects: number[][]
) => {
setNoteTarget({
id,
type: 'HIGHLIGHT',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
sharedAt: new Date().toISOString(),
createdByMe: true,
shortId: id.slice(0, 12),
libraryItem: { id: props.article.id } as unknown as LibraryItemNode,
quote,
color: 'yellow',
patch: JSON.stringify({ page: pageNumber + 1, rects }),
highlightPositionAnchorIndex: pageNumber + 1,
})
setShowHighlightModal(false)
}
const createHighlightFromColour = async (
id: string,
quote: string,
color: string,
page: PDFPageView,
pageNumber: number,
rects: number[][]
) => {
const highlight = await props.articleMutations.createHighlightMutation({
id,
shortId: id.slice(0, 12),
articleId: props.article.id,
quote,
color,
patch: JSON.stringify({ page: pageNumber, rects }),
highlightPositionAnchorIndex: pageNumber,
})
if (highlight) {
addHighlightToPage(page as PDFPageView, highlight)
setHighlights([...highlights, highlight])
}
}
const onAddHighlightClick = async (
action: HighlightAction,
param?: string
) => {
setShowHighlightModal(false)
if (props.pdfViewer?._pages && window) {
const id = uuidv4()
const clientRects = window.getSelection()?.getRangeAt(0).getClientRects()
const scaleFactor = props.pdfViewer._pages[0].viewport.scale
const currentPageNumber = props.pdfViewer.currentPageNumber
const page = props.pdfViewer._pages[currentPageNumber - 1]
const canvas = document.querySelector(
`[data-page-number='${currentPageNumber}']`
)?.children[0]
const canvasRect = canvas?.getBoundingClientRect()
const quote = window.getSelection()?.toString()
const isTextHighlighted = clientRects && clientRects.length > 0 && quote
// We copy the highlighted text.
// If a highlight is selected, we copy the text from that instead.
if (action == 'copy') {
return copyHighlightedText(clickedHighlight?.quote ?? quote ?? '')
}
if (action == 'comment' && !quote && clickedHighlight) {
return viewNoteTextForClickedHighlight(clickedHighlight)
}
if (action == 'updateColor' && clickedHighlight) {
return updateColorForClickedHighlight(
page,
clickedHighlight,
param ?? 'yellow'
)
}
if (isTextHighlighted && canvasRect) {
const rects = getBoundingRects(canvasRect, clientRects, scaleFactor)
if (action == 'comment') {
return createHighlightWithNote(id, quote, currentPageNumber, rects)
}
if (action == 'create') {
return createHighlightFromColour(
id,
quote,
param || 'yellow',
page,
currentPageNumber,
rects
)
}
}
}
}
// Here we detect whether some text has been highlighted. If it has, we view the
// highlight modal.
useEffect(() => {
const detectHighlightedText = () => {
if (window) {
const clientRectsArray = Array.from(
window.getSelection()?.getRangeAt(0).getClientRects() ?? []
)
const quote = window.getSelection()?.toString()
if (clientRectsArray.length > 0 && quote) {
const rect = clientRectsArray[clientRectsArray.length - 1]
setPageCoordinates({ pageX: rect.x + 10, pageY: rect.y + 15 })
setShowHighlightModal(true)
setClickedHighlight(null)
return
}
setPageCoordinates({ pageX: -1000, pageY: -1000 })
setShowHighlightModal(false)
}
}
if (props.containerRef?.current) {
props.containerRef.current.addEventListener(
'mouseup',
detectHighlightedText
)
}
return () => {
if (props.containerRef && props.containerRef.current) {
props.containerRef?.current.removeEventListener(
'mouseup',
detectHighlightedText
)
}
}
}, [
props.containerRef,
setPageCoordinates,
setShowHighlightModal,
props.pdfViewer,
setHighlights,
highlights,
])
// When a page zooms, it internally re-renders using pdf.js. We add a function
// to ensure that when a page newly renders that we re-add all the highlights,
// as those are removed.
useEffect(() => {
const render = (data: { source: PDFPageView }) => {
const page = data.source
if (page) {
highlights
?.filter((it) => it.highlightPositionAnchorIndex == page.id)
.forEach((it) => addHighlightToPage(page as PDFPageView, it))
}
}
const setPage = (e: { pageNumber: number }) =>
setCurrentPageNum(e.pageNumber)
if (props.eventBus && props.pdfViewer) {
props.eventBus.on('textlayerrendered', render)
props.eventBus.on('pagechanging', setPage)
}
return () => {
if (props.eventBus) {
props.eventBus.off('textlayerrendered', render)
props.eventBus.off('pagechanging', setPage)
}
}
}, [props.eventBus, props.pdfViewer, highlights, addHighlightToPage])
// Here is where we add our percentage read markers. We debounce for 2.5s before
// adding. We also set the page number so that we can go back to the correct
// page on reload.
useEffect(() => {
let timeoutId: NodeJS.Timeout | null = null
const savePercentOnScroll = () => {
timeoutId && clearTimeout(timeoutId)
setShowHighlightModal(false)
timeoutId = setTimeout(async () => {
if (props.containerRef && props.containerRef.current) {
const bottomProgress =
(props.containerRef.current.scrollTop +
props.containerRef.current.clientHeight) /
props.containerRef.current.scrollHeight
await props.articleMutations.articleReadingProgressMutation({
id: props.article.id,
readingProgressTopPercent: bottomProgress * 100,
readingProgressPercent: bottomProgress * 100,
readingProgressAnchorIndex: currentPageNum + 1,
})
}
}, 2500)
}
if (props.containerRef?.current) {
props.containerRef.current.addEventListener('scroll', savePercentOnScroll)
}
return () => {
if (props.containerRef?.current) {
timeoutId && clearTimeout(timeoutId)
props.containerRef.current.removeEventListener(
'scroll',
savePercentOnScroll
)
}
}
}, [
props.containerRef,
currentPageNum,
props.article.id,
props.articleMutations,
])
return (
<Box id="article-wrapper" css={{ flexGrow: 1 }}>
{noteTarget && (
<HighlightNoteModal
highlight={clickedHighlight ?? undefined}
libraryItemId={props.article.id}
libraryItemSlug={props.article.slug}
createHighlightForNote={(note: string | undefined) => {
if (noteTarget) {
return addNoteToNewHighlight(note, noteTarget)
}
return Promise.resolve(noteTarget)
}}
onUpdate={(updatedHighlight: Highlight) => {
const indexOf = highlights?.findIndex(
(it) => it.id == updatedHighlight.id
)
if (indexOf && indexOf > -1) {
highlights[indexOf] = updatedHighlight
setHighlights(highlights)
}
setClickedHighlight(null)
}}
onOpenChange={() => {
setNoteTarget(undefined)
}}
/>
)}
{showHighlightModal && (
<>
<HighlightBar
anchorCoordinates={pageCoordinates}
isNewHighlight={!clickedHighlight}
handleButtonClick={onAddHighlightClick}
isSharedToFeed={false}
displayAtBottom={isTouchScreenDevice()}
highlightColor={clickedHighlight?.color || 'yellow'}
/>
</>
)}
<div
ref={props.containerRef}
className={'viewerContainer'}
style={{
width: `calc(100% - ${props.sidebarActive ? '250px' : '0px'})`,
left: !props.sidebarActive ? 0 : '250px',
height: `calc(100vh - ${DEFAULT_HEADER_HEIGHT} - 20px)`,
overflow: 'scroll',
top: '100px',
position: 'absolute',
}}
>
<div id="viewer" className="pdfViewer"></div>
</div>
</Box>
)
}

View file

@ -0,0 +1,52 @@
import { styled } from '../../../tokens/stitches.config'
export const ToolbarIconButton = styled('div', {
paddingLeft: '10px',
paddingTop: '5px',
height: '43px',
width: '44px',
'&:hover': {
background: '$thBackground'
},
})
export const ToolbarButton = styled('button', {
fontFamily: 'Inter',
fontWeight: 'normal',
color: '$grayTextContrast',
width: '26px',
height: '26px',
padding: '5px 3px 5px 3px',
background: 'none',
outline: 'inherit',
border: '1px solid black',
backgroundColor: '$thBackground5',
'&:hover': {
background: '$thBackground'
},
})
export const PageInput = styled('input', {
width: '40px',
height: '26px',
padding: '4px',
textAlign: 'center',
border: '1px solid black',
borderLeft: '0px',
borderRight: '1px',
backgroundColor: '$thBackground5',
})
export const SearchInput = styled('input', {
height: '26px',
width: '330px',
padding: '4px',
border: '1px solid black',
borderRadius: '4px',
paddingRight: '60px',
marginRight: '5px',
backgroundColor: '$thFormInput',
'&:focus': {
outline: 'none',
}
})

View file

@ -1,7 +1,7 @@
const ContentSecurityPolicy = `
default-src 'self';
base-uri 'self';
connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com wss://www.tiktok.com *.sentry.io;
connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com wss://www.tiktok.com *.sentry.io 127.0.0.1 http://localhost:1010;
font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com;
form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com;
frame-ancestors 'none';

View file

@ -52,6 +52,7 @@
"next": "^13.5.6",
"node-html-markdown": "^1.3.0",
"papaparse": "^5.4.1",
"pdfjs-dist": "^4.9.155",
"pspdfkit": "^2023.4.6",
"re-resizable": "^6.9.11",
"react": "^18.2.0",
@ -111,4 +112,4 @@
"volta": {
"extends": "../../package.json"
}
}
}

View file

@ -39,7 +39,7 @@ import {
import { useGetViewer } from '../../../lib/networking/viewer/useGetViewer'
const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
() => import(`./../../../components/templates/article/NativePdfArticleContainer`),
() => import(`./../../../components/templates/article/pdf.js/PdfArticleContainer`),
{ ssr: false }
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,3 @@
àRCopyright 1990-2009 Adobe Systems Incorporated.
All rights reserved.
See ./LICENSEáCNS2-H

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,3 @@
àRCopyright 1990-2009 Adobe Systems Incorporated.
All rights reserved.
See ./LICENSEá ETen-B5-H` ^

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,4 @@
àRCopyright 1990-2009 Adobe Systems Incorporated.
All rights reserved.
See ./LICENSE!!<21>º]aX!!]`<60>21<32>> <09>p <0B>z<EFBFBD>$]<06>"Rd<E2809A>-Uƒ7<C692>*4„%<25>+ „Z „{<7B>/%…<<3C>9K…b<E280A6>1]†.<2E>" ‰`]‡,<2C>"]ˆ
<EFBFBD>"]ˆh<CB86>"]‰F<E280B0>"]Š$<24>"]<02>"]`<60>"]Œ><3E>"]<5D><1C>"]<5D>z<EFBFBD>"]ŽX<C5BD>"]<5D>6<EFBFBD>"]<5D><14>"]<5D>r<EFBFBD>"]P<E28098>"].<2E>"]“ <0C>"]“j<E2809C>"]”H<E2809D>"]•&<26>"]<04>"]b<E28093>"]—@<40>"]˜<1E>"]˜|<7C>"]™Z<E284A2>"]š8<C5A1>"]<16>"]t<E280BA>"]œR<C593>"]<5D>0<EFBFBD>"]ž<0E>"]žl<C5BE>"]ŸJ<C5B8>"] (<28>"]¡<06>"]¡d<C2A1>"]¢B<C2A2>"]£ <20>"X£~<7E>']¤W<C2A4>"]¥5<C2A5>"]¦<13>"]¦q<C2A6>"]§O<C2A7>"]¨-<2D>"]© <0B>"]©i<C2A9>"]ªG<C2AA>"]«%<25>"]¬<03>"]¬a<C2AC>"]­?<3F>"]®<1D>"]®{<7B>"]¯Y<C2AF>"]°7<C2B0>"]±<15>"]±s<C2B1>"]²Q<C2B2>"]³/<2F>"]´ <0A>"]´k<C2B4>"]µI<C2B5>"]¶'<27>"]·<05>"]·c<C2B7>"]¸A<C2B8>"]¹<1F>"]¹}<7D>"]º[<5B>"]»9

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more