From f6a8bf9ea2a2578071fca0d864cb40258a698752 Mon Sep 17 00:00:00 2001 From: asrient <44570278+asrient@users.noreply.github.com> Date: Sun, 11 Sep 2022 19:28:32 +0530 Subject: [PATCH] Desktop: Add PDF full screen viewer (#6821) --- .eslintignore | 18 +++ .gitignore | 18 +++ .../gui/MainScreen/commands/index.ts | 2 + .../gui/MainScreen/commands/openPdfViewer.ts | 28 ++++ .../gui/NoteEditor/utils/useMessageHandler.ts | 2 + packages/app-desktop/gui/PdfViewer.tsx | 101 +++++++++++++ packages/app-desktop/gui/Root.tsx | 6 + .../app-desktop/gui/note-viewer/index.html | 11 ++ packages/pdf-viewer/FullViewer.tsx | 127 ++++++++++++++++ packages/pdf-viewer/Page.tsx | 137 ++++++++++-------- packages/pdf-viewer/PdfDocument.ts | 66 ++++++++- packages/pdf-viewer/VerticalPages.tsx | 26 +++- packages/pdf-viewer/fullScreen.css | 64 ++++++++ packages/pdf-viewer/hooks/useIsVisible.ts | 24 ++- .../pdf-viewer/hooks/useVisibleOnSelect.ts | 27 ++++ packages/pdf-viewer/main.tsx | 16 +- packages/pdf-viewer/messageService.ts | 35 +++++ packages/pdf-viewer/miniViewer.tsx | 10 ++ packages/pdf-viewer/package.json | 1 + packages/pdf-viewer/textLayer.css | 91 ++++++++++++ packages/pdf-viewer/types.ts | 12 ++ packages/pdf-viewer/ui/GotoPage.tsx | 67 +++++++++ packages/pdf-viewer/ui/IconButtons.tsx | 14 +- packages/renderer/MdToHtml/renderMedia.ts | 2 +- yarn.lock | 17 +++ 25 files changed, 850 insertions(+), 72 deletions(-) create mode 100644 packages/app-desktop/gui/MainScreen/commands/openPdfViewer.ts create mode 100644 packages/app-desktop/gui/PdfViewer.tsx create mode 100644 packages/pdf-viewer/FullViewer.tsx create mode 100644 packages/pdf-viewer/fullScreen.css create mode 100644 packages/pdf-viewer/hooks/useVisibleOnSelect.ts create mode 100644 packages/pdf-viewer/messageService.ts create mode 100644 packages/pdf-viewer/textLayer.css create mode 100644 packages/pdf-viewer/ui/GotoPage.tsx diff --git a/.eslintignore b/.eslintignore index 289fbaba2..14b37a4fa 100644 --- a/.eslintignore +++ b/.eslintignore @@ -333,6 +333,9 @@ packages/app-desktop/gui/MainScreen/commands/openItem.js.map packages/app-desktop/gui/MainScreen/commands/openNote.d.ts packages/app-desktop/gui/MainScreen/commands/openNote.js packages/app-desktop/gui/MainScreen/commands/openNote.js.map +packages/app-desktop/gui/MainScreen/commands/openPdfViewer.d.ts +packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js +packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js.map packages/app-desktop/gui/MainScreen/commands/openTag.d.ts packages/app-desktop/gui/MainScreen/commands/openTag.js packages/app-desktop/gui/MainScreen/commands/openTag.js.map @@ -597,6 +600,9 @@ packages/app-desktop/gui/OneDriveLoginScreen.js.map packages/app-desktop/gui/PasswordInput/PasswordInput.d.ts packages/app-desktop/gui/PasswordInput/PasswordInput.js packages/app-desktop/gui/PasswordInput/PasswordInput.js.map +packages/app-desktop/gui/PdfViewer.d.ts +packages/app-desktop/gui/PdfViewer.js +packages/app-desktop/gui/PdfViewer.js.map packages/app-desktop/gui/ResizableLayout/MoveButtons.d.ts packages/app-desktop/gui/ResizableLayout/MoveButtons.js packages/app-desktop/gui/ResizableLayout/MoveButtons.js.map @@ -2019,6 +2025,9 @@ packages/lib/uuid.js.map packages/lib/versionInfo.d.ts packages/lib/versionInfo.js packages/lib/versionInfo.js.map +packages/pdf-viewer/FullViewer.d.ts +packages/pdf-viewer/FullViewer.js +packages/pdf-viewer/FullViewer.js.map packages/pdf-viewer/Page.d.ts packages/pdf-viewer/Page.js packages/pdf-viewer/Page.js.map @@ -2043,9 +2052,15 @@ packages/pdf-viewer/hooks/useScaledSize.js.map packages/pdf-viewer/hooks/useScrollSaver.d.ts packages/pdf-viewer/hooks/useScrollSaver.js packages/pdf-viewer/hooks/useScrollSaver.js.map +packages/pdf-viewer/hooks/useVisibleOnSelect.d.ts +packages/pdf-viewer/hooks/useVisibleOnSelect.js +packages/pdf-viewer/hooks/useVisibleOnSelect.js.map packages/pdf-viewer/main.d.ts packages/pdf-viewer/main.js packages/pdf-viewer/main.js.map +packages/pdf-viewer/messageService.d.ts +packages/pdf-viewer/messageService.js +packages/pdf-viewer/messageService.js.map packages/pdf-viewer/miniViewer.d.ts packages/pdf-viewer/miniViewer.js packages/pdf-viewer/miniViewer.js.map @@ -2055,6 +2070,9 @@ packages/pdf-viewer/pdfSource.test.js.map packages/pdf-viewer/types.d.ts packages/pdf-viewer/types.js packages/pdf-viewer/types.js.map +packages/pdf-viewer/ui/GotoPage.d.ts +packages/pdf-viewer/ui/GotoPage.js +packages/pdf-viewer/ui/GotoPage.js.map packages/pdf-viewer/ui/IconButtons.d.ts packages/pdf-viewer/ui/IconButtons.js packages/pdf-viewer/ui/IconButtons.js.map diff --git a/.gitignore b/.gitignore index 161850457..93c0e5150 100644 --- a/.gitignore +++ b/.gitignore @@ -321,6 +321,9 @@ packages/app-desktop/gui/MainScreen/commands/openItem.js.map packages/app-desktop/gui/MainScreen/commands/openNote.d.ts packages/app-desktop/gui/MainScreen/commands/openNote.js packages/app-desktop/gui/MainScreen/commands/openNote.js.map +packages/app-desktop/gui/MainScreen/commands/openPdfViewer.d.ts +packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js +packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js.map packages/app-desktop/gui/MainScreen/commands/openTag.d.ts packages/app-desktop/gui/MainScreen/commands/openTag.js packages/app-desktop/gui/MainScreen/commands/openTag.js.map @@ -585,6 +588,9 @@ packages/app-desktop/gui/OneDriveLoginScreen.js.map packages/app-desktop/gui/PasswordInput/PasswordInput.d.ts packages/app-desktop/gui/PasswordInput/PasswordInput.js packages/app-desktop/gui/PasswordInput/PasswordInput.js.map +packages/app-desktop/gui/PdfViewer.d.ts +packages/app-desktop/gui/PdfViewer.js +packages/app-desktop/gui/PdfViewer.js.map packages/app-desktop/gui/ResizableLayout/MoveButtons.d.ts packages/app-desktop/gui/ResizableLayout/MoveButtons.js packages/app-desktop/gui/ResizableLayout/MoveButtons.js.map @@ -2007,6 +2013,9 @@ packages/lib/uuid.js.map packages/lib/versionInfo.d.ts packages/lib/versionInfo.js packages/lib/versionInfo.js.map +packages/pdf-viewer/FullViewer.d.ts +packages/pdf-viewer/FullViewer.js +packages/pdf-viewer/FullViewer.js.map packages/pdf-viewer/Page.d.ts packages/pdf-viewer/Page.js packages/pdf-viewer/Page.js.map @@ -2031,9 +2040,15 @@ packages/pdf-viewer/hooks/useScaledSize.js.map packages/pdf-viewer/hooks/useScrollSaver.d.ts packages/pdf-viewer/hooks/useScrollSaver.js packages/pdf-viewer/hooks/useScrollSaver.js.map +packages/pdf-viewer/hooks/useVisibleOnSelect.d.ts +packages/pdf-viewer/hooks/useVisibleOnSelect.js +packages/pdf-viewer/hooks/useVisibleOnSelect.js.map packages/pdf-viewer/main.d.ts packages/pdf-viewer/main.js packages/pdf-viewer/main.js.map +packages/pdf-viewer/messageService.d.ts +packages/pdf-viewer/messageService.js +packages/pdf-viewer/messageService.js.map packages/pdf-viewer/miniViewer.d.ts packages/pdf-viewer/miniViewer.js packages/pdf-viewer/miniViewer.js.map @@ -2043,6 +2058,9 @@ packages/pdf-viewer/pdfSource.test.js.map packages/pdf-viewer/types.d.ts packages/pdf-viewer/types.js packages/pdf-viewer/types.js.map +packages/pdf-viewer/ui/GotoPage.d.ts +packages/pdf-viewer/ui/GotoPage.js +packages/pdf-viewer/ui/GotoPage.js.map packages/pdf-viewer/ui/IconButtons.d.ts packages/pdf-viewer/ui/IconButtons.js packages/pdf-viewer/ui/IconButtons.js.map diff --git a/packages/app-desktop/gui/MainScreen/commands/index.ts b/packages/app-desktop/gui/MainScreen/commands/index.ts index d65b34b18..41483977c 100644 --- a/packages/app-desktop/gui/MainScreen/commands/index.ts +++ b/packages/app-desktop/gui/MainScreen/commands/index.ts @@ -15,6 +15,7 @@ import * as openFolder from './openFolder'; import * as openFolderDialog from './openFolderDialog'; import * as openItem from './openItem'; import * as openNote from './openNote'; +import * as openPdfViewer from './openPdfViewer'; import * as openTag from './openTag'; import * as print from './print'; import * as renameFolder from './renameFolder'; @@ -55,6 +56,7 @@ const index:any[] = [ openFolderDialog, openItem, openNote, + openPdfViewer, openTag, print, renameFolder, diff --git a/packages/app-desktop/gui/MainScreen/commands/openPdfViewer.ts b/packages/app-desktop/gui/MainScreen/commands/openPdfViewer.ts new file mode 100644 index 000000000..d8c7b8bc6 --- /dev/null +++ b/packages/app-desktop/gui/MainScreen/commands/openPdfViewer.ts @@ -0,0 +1,28 @@ +import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import Resource from '@joplin/lib/models/Resource'; + +export const declaration: CommandDeclaration = { + name: 'openPdfViewer', + label: () => _('Open PDF viewer'), +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext, resourceId: string, pageNo: number) => { + + const resource = await Resource.load(resourceId); + if (!resource) throw new Error(`No such resource: ${resourceId}`); + if (resource.mime !== 'application/pdf') throw new Error(`Not a PDF: ${resource.mime}`); + console.log('Opening PDF', resource); + context.dispatch({ + type: 'DIALOG_OPEN', + name: 'pdfViewer', + props: { + resource, + pageNo: pageNo, + }, + }); + }, + }; +}; diff --git a/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts b/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts index be511a5ec..e16adbdb9 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts @@ -52,6 +52,8 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea void CommandService.instance().execute(commandName, ...commandArgs); } else if (msg === 'postMessageService.message') { void PostMessageService.instance().postMessage(arg0); + } else if (msg === 'openPdfViewer') { + await CommandService.instance().execute('openPdfViewer', arg0.resourceId, arg0.pageNo); } else { await CommandService.instance().execute('openItem', msg); // bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg)); diff --git a/packages/app-desktop/gui/PdfViewer.tsx b/packages/app-desktop/gui/PdfViewer.tsx new file mode 100644 index 000000000..39e671453 --- /dev/null +++ b/packages/app-desktop/gui/PdfViewer.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { useCallback, useRef, useEffect } from 'react'; +import Resource from '@joplin/lib/models/Resource'; +import bridge from '../services/bridge'; +import contextMenu from './NoteEditor/utils/contextMenu'; +import { ContextMenuItemType, ContextMenuOptions } from './NoteEditor/utils/contextMenuUtils'; +import CommandService from '@joplin/lib/services/CommandService'; +import styled from 'styled-components'; +import { themeStyle } from '@joplin/lib/theme'; + +const Window = styled.div` + height: 100%; + width: 100%; + position: fixed; + top: 0px; + left: 0px; + z-index: 999; + background-color: ${(props: any) => props.theme.backgroundColor}; + color: ${(props: any) => props.theme.color}; + `; + +const IFrame = styled.iframe` + height: 100%; + width: 100%; + border: none; + `; + +interface Props { + themeId: number; + dispatch: Function; + resource: any; + pageNo: number; +} + +export default function PdfViewer(props: Props) { + + const iframeRef = useRef(null); + + const onClose = useCallback(() => { + props.dispatch({ + type: 'DIALOG_CLOSE', + name: 'pdfViewer', + }); + }, [props.dispatch]); + + const openExternalViewer = useCallback(async () => { + await CommandService.instance().execute('openItem', `joplin://${props.resource.id}`); + }, [props.resource.id]); + + const textSelected = useCallback(async (text: string) => { + if (!text) return; + const itemType = ContextMenuItemType.Text; + const menu = await contextMenu({ + itemType, + resourceId: null, + filename: null, + mime: 'text/plain', + textToCopy: text, + linkToCopy: null, + htmlToCopy: '', + insertContent: () => { console.warn('insertContent() not implemented'); }, + } as ContextMenuOptions, props.dispatch); + + menu.popup(bridge().window()); + }, [props.dispatch]); + + useEffect(() => { + const onMessage_ = async (event: any) =>{ + if (!event.data || !event.data.name) { + return; + } + + if (event.data.name === 'close') { + onClose(); + } else if (event.data.name === 'externalViewer') { + await openExternalViewer(); + } else if (event.data.name === 'textSelected') { + await textSelected(event.data.text); + } else { + console.error('Unknown event received', event.data.name); + } + }; + const iframe = iframeRef.current; + iframe.contentWindow.addEventListener('message', onMessage_); + return () => { + iframe.contentWindow.removeEventListener('message', onMessage_); + }; + }, [onClose, openExternalViewer, textSelected]); + + const theme = themeStyle(props.themeId); + + return ( + + + + ); +} diff --git a/packages/app-desktop/gui/Root.tsx b/packages/app-desktop/gui/Root.tsx index 9ce59a6cc..c2dfe671c 100644 --- a/packages/app-desktop/gui/Root.tsx +++ b/packages/app-desktop/gui/Root.tsx @@ -22,6 +22,7 @@ import Dialog from './Dialog'; import SyncWizardDialog from './SyncWizard/Dialog'; import MasterPasswordDialog from './MasterPasswordDialog/Dialog'; import EditFolderDialog from './EditFolderDialog/Dialog'; +import PdfViewer from './PdfViewer'; import StyleSheetContainer from './StyleSheets/StyleSheetContainer'; const { ImportScreen } = require('./ImportScreen.min.js'); const { ResourceScreen } = require('./ResourceScreen.js'); @@ -75,6 +76,11 @@ const registeredDialogs: Record = { return ; }, }, + pdfViewer: { + render: (props: RegisteredDialogProps, customProps: any) => { + return ; + }, + }, }; const GlobalStyle = createGlobalStyle` diff --git a/packages/app-desktop/gui/note-viewer/index.html b/packages/app-desktop/gui/note-viewer/index.html index 6219cb72e..467d3158e 100644 --- a/packages/app-desktop/gui/note-viewer/index.html +++ b/packages/app-desktop/gui/note-viewer/index.html @@ -579,6 +579,17 @@ } } + ipc.textSelected = function(event) { + ipcProxySendToHost('contextMenu', { + type: 'text', + textToCopy: event.text, + }); + } + + ipc.openPdfViewer = function(event) { + ipcProxySendToHost('openPdfViewer', { resourceId: event.resourceId, mime: 'application/pdf', pageNo: event.pageNo || 1 }); + } + window.addEventListener('hashchange', webviewLib.logEnabledEventHandler(e => { if (!window.location.hash) return; diff --git a/packages/pdf-viewer/FullViewer.tsx b/packages/pdf-viewer/FullViewer.tsx new file mode 100644 index 000000000..6098bc286 --- /dev/null +++ b/packages/pdf-viewer/FullViewer.tsx @@ -0,0 +1,127 @@ +import { useRef, useState, useCallback } from 'react'; +import * as React from 'react'; +import usePdfDocument from './hooks/usePdfDocument'; +import VerticalPages from './VerticalPages'; +import MessageService from './messageService'; +import { DownloadButton, PrintButton, OpenLinkButton, CloseButton } from './ui/IconButtons'; +import ZoomControls from './ui/ZoomControls'; +import styled from 'styled-components'; +import GotoInput from './ui/GotoPage'; + +require('./fullScreen.css'); + +const TitleWrapper = styled.div` + font-size: 0.7rem; + font-weight: 400; + display: flex; + align-items: start; + flex-direction: column; + min-width: 10rem; + max-width: 18rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--secondary); + padding: 0.2rem 0.6rem; + height: 100%; + width: 100%; + justify-content: center; +`; + +const Title = styled.div` + font-size: 0.9rem; + font-weight: bold; + margin-bottom: 0.2rem; + color: var(--primary); + overflow: hidden; +`; + + +export interface FullViewerProps { + pdfPath: string; + isDarkTheme: boolean; + messageService: MessageService; + startPage: number; + title: string; +} + +export default function FullViewer(props: FullViewerProps) { + const pdfDocument = usePdfDocument(props.pdfPath); + const [zoom, setZoom] = useState(1); + const [startPage, setStartPage] = useState(props.startPage || 1); + const [selectedPage, setSelectedPage] = useState(startPage); + const mainViewerRef = useRef(null); + const thubmnailRef = useRef(null); + + const onActivePageChange = useCallback((pageNo: number) => { + setSelectedPage(pageNo); + }, []); + + const goToPage = useCallback((pageNo: number) => { + if (pageNo < 1 || pageNo > pdfDocument.pageCount || pageNo === selectedPage) return; + setSelectedPage(pageNo); + setStartPage(pageNo); + }, [pdfDocument, selectedPage]); + + if (!pdfDocument) { + return ( +
+
Loading pdf..
+
); + } + + return ( +
+
+
+ + {props.title} +
{selectedPage} of {pdfDocument.pageCount} pages
+
+
+
+ + + + + +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/packages/pdf-viewer/Page.tsx b/packages/pdf-viewer/Page.tsx index 441e9e03b..a75c34b70 100644 --- a/packages/pdf-viewer/Page.tsx +++ b/packages/pdf-viewer/Page.tsx @@ -1,32 +1,35 @@ -import { useEffect, useRef, useState, MutableRefObject } from 'react'; +import { useEffect, useRef, useState, MutableRefObject, useCallback } from 'react'; import * as React from 'react'; import useIsVisible from './hooks/useIsVisible'; +import useVisibleOnSelect, { VisibleOnSelect } from './hooks/useVisibleOnSelect'; import PdfDocument from './PdfDocument'; -import { ScaledSize } from './types'; -import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; +import { ScaledSize, RenderRequest } from './types'; import styled from 'styled-components'; -const PageWrapper = styled.div` + +require('./textLayer.css'); + +const PageWrapper = styled.div<{ isSelected?: boolean }>` display: flex; flex-direction: column; justify-content: center; align-items: center; overflow: hidden; - border: solid thin rgba(120, 120, 120, 0.498); + border: ${props => props.isSelected ? 'solid 5px #0079ff' : 'solid thin rgba(120, 120, 120, 0.498)'}; background: rgb(233, 233, 233); position: relative; - border-radius: 0px; + border-radius: ${props => props.isSelected ? '0.3rem' : '0px'}; `; -const PageInfo = styled.div` +const PageInfo = styled.div<{ isSelected?: boolean }>` position: absolute; top: 0.5rem; left: 0.5rem; padding: 0.3rem; - background: rgba(203, 203, 203, 0.509); + background: ${props => props.isSelected ? '#0079ff' : 'rgba(203, 203, 203, 0.509)'}; border-radius: 0.3rem; font-size: 0.8rem; - color: rgba(91, 91, 91, 0.829); + color: ${props => props.isSelected ? 'white' : 'rgba(91, 91, 91, 0.829)'}; backdrop-filter: blur(0.5rem); cursor: default; user-select: none; @@ -43,61 +46,64 @@ export interface PageProps { scaledSize: ScaledSize; isDarkTheme: boolean; container: MutableRefObject; - showPageNumbers?: boolean; + showPageNumbers: boolean; + isSelected: boolean; + textSelectable: boolean; + onTextSelect?: (text: string)=> void; + onClick?: (page: number)=> void; + onDoubleClick?: (page: number)=> void; } export default function Page(props: PageProps) { const [error, setError] = useState(null); - const [page, setPage] = useState(null); const scaleRef = useRef(null); - const timestampRef = useRef(null); const canvasRef = useRef(null); + const textRef = useRef(null); const wrapperRef = useRef(null); - const isVisible = useIsVisible(canvasRef, props.container); + const isVisible = useIsVisible(wrapperRef, props.container); + useVisibleOnSelect({ + isVisible, + isSelected: props.isSelected, + container: props.container, + wrapperRef, + } as VisibleOnSelect); useEffect(() => { - if (!isVisible || !page || !props.scaledSize || (scaleRef.current && props.scaledSize.scale === scaleRef.current)) return; - try { - const viewport = page.getViewport({ scale: props.scaledSize.scale || 1.0 }); - const canvas = canvasRef.current; - canvas.width = viewport.width; - canvas.height = viewport.height; - const ctx = canvas.getContext('2d'); - const pageTimestamp = new Date().getTime(); - timestampRef.current = pageTimestamp; - page.render({ - canvasContext: ctx, - viewport, - // Used so that the page rendering is throttled to some extent. - // https://stackoverflow.com/questions/18069448/halting-pdf-js-page-rendering - continueCallback: function(cont: any) { - if (timestampRef.current !== pageTimestamp) { - return; - } - cont(); - }, - }); + const isCancelled = () => props.scaledSize.scale !== scaleRef.current; + + const renderPage = async () => { + try { + const renderRequest: RenderRequest = { + pageNo: props.pageNo, + scaledSize: props.scaledSize, + getTextLayer: props.textSelectable, + isCancelled, + }; + const { canvas, textLayerDiv } = await props.pdfDocument.renderPage(renderRequest); + + wrapperRef.current.appendChild(canvas); + if (textLayerDiv) wrapperRef.current.appendChild(textLayerDiv); + + if (canvasRef.current) canvasRef.current.remove(); + if (textRef.current) textRef.current.remove(); + + canvasRef.current = canvas; + if (textLayerDiv) textRef.current = textLayerDiv; + } catch (error) { + if (isCancelled()) return; + error.message = `Error rendering page no. ${props.pageNo}: ${error.message}`; + setError(error); + throw error; + } + }; + + if (isVisible && props.scaledSize && (props.scaledSize.scale !== scaleRef.current)) { scaleRef.current = props.scaledSize.scale; - - } catch (error) { - error.message = `Error rendering page no. ${props.pageNo}: ${error.message}`; - setError(error); - throw error; + void renderPage(); } - }, [page, props.scaledSize, isVisible, props.pageNo]); - useAsyncEffect(async (event: AsyncEffectEvent) => { - if (page || !isVisible || !props.pdfDocument) return; - try { - const _page = await props.pdfDocument.getPage(props.pageNo); - if (event.cancelled) return; - setPage(_page); - } catch (error) { - console.error('Page load error', props.pageNo, error); - setError(error); - } - }, [page, props.scaledSize, isVisible]); + }, [props.scaledSize, isVisible, props.textSelectable, props.pageNo, props.pdfDocument]); useEffect(() => { if (props.focusOnLoad) { @@ -106,6 +112,11 @@ export default function Page(props: PageProps) { } }, [props.container, props.focusOnLoad]); + + const onClick = useCallback(async (_e: React.MouseEvent) => { + if (props.onClick) props.onClick(props.pageNo); + }, [props.onClick, props.pageNo]); + let style: any = {}; if (props.scaledSize) { style = { @@ -115,15 +126,23 @@ export default function Page(props: PageProps) { }; } + const onContextMenu = useCallback((e: React.MouseEvent) => { + if (!props.textSelectable || !props.onTextSelect || !window.getSelection()) return; + const text = window.getSelection().toString(); + if (!text) return; + props.onTextSelect(text); + e.preventDefault(); + e.stopPropagation(); + }, [props.textSelectable, props.onTextSelect]); + + const onDoubleClick = useCallback(() => { + if (props.onDoubleClick) props.onDoubleClick(props.pageNo); + }, [props.onDoubleClick, props.pageNo]); + return ( - - -
- {error ? 'ERROR' : 'Loading..'} -
- Page {props.pageNo} -
- {props.showPageNumbers && {props.isAnchored ? '📌' : ''} Page {props.pageNo}} + + { error &&
Error: {error}
} + {props.showPageNumbers && {props.isAnchored ? '📌' : ''} Page {props.pageNo}}
); } diff --git a/packages/pdf-viewer/PdfDocument.ts b/packages/pdf-viewer/PdfDocument.ts index 08ca7c53a..74728d5d8 100644 --- a/packages/pdf-viewer/PdfDocument.ts +++ b/packages/pdf-viewer/PdfDocument.ts @@ -1,11 +1,14 @@ import * as pdfjsLib from 'pdfjs-dist'; -import { ScaledSize } from './types'; +import { ScaledSize, RenderRequest, RenderResult } from './types'; +import { Mutex, MutexInterface, withTimeout } from 'async-mutex'; + export default class PdfDocument { public url: string | Uint8Array; private doc: any = null; public pageCount: number = null; private pages: any = {}; + private rendererMutex: MutexInterface = null; private pageSize: { height: number; width: number; @@ -14,6 +17,7 @@ export default class PdfDocument { public constructor(document: HTMLDocument) { this.document = document; + this.rendererMutex = withTimeout(new Mutex(), 40 * 1000); } public loadDoc = async (url: string | Uint8Array) => { @@ -74,6 +78,66 @@ export default class PdfDocument { return Math.min(pageNo, this.pageCount); }; + private renderPageImpl = async ({ pageNo, scaledSize, getTextLayer, isCancelled }: RenderRequest): Promise => { + + const checkCancelled = () => { + if (isCancelled()) { + throw new Error(`Render cancelled, page: ${pageNo}`); + } + }; + + const page = await this.getPage(pageNo); + checkCancelled(); + + const canvas = this.document.createElement('canvas'); + const viewport = page.getViewport({ scale: scaledSize.scale || 1.0 }); + canvas.width = viewport.width; + canvas.height = viewport.height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get canvas context'); + } + + await page.render({ + canvasContext: ctx, + viewport, + }).promise; + checkCancelled(); + + let textLayerDiv = null; + if (getTextLayer) { + textLayerDiv = this.document.createElement('div'); + textLayerDiv.classList.add('textLayer'); + const textFragment = this.document.createDocumentFragment(); + const txtContext = await page.getTextContent(); + checkCancelled(); + // Pass the data to the method for rendering of text over the pdf canvas. + textLayerDiv.style.height = `${viewport.height}px`; + textLayerDiv.style.width = `${viewport.width}px`; + await pdfjsLib.renderTextLayer({ + textContent: txtContext, + enhanceTextSelection: true, + container: textFragment, + viewport: viewport, + textDivs: [], + }).promise; + textLayerDiv.appendChild(textFragment); + } + + canvas.style.height = '100%'; + canvas.style.width = '100%'; + + return { canvas, textLayerDiv }; + }; + + public async renderPage(task: RenderRequest): Promise { + // We're using a render mutex to avoid rendering too many pages at the same time + // Which can cause the pdfjs library to abandon some of the in-progress rendering unexpectedly + return await this.rendererMutex.runExclusive(async () => { + return await this.renderPageImpl(task); + }); + } + public printPdf = () => { const frame = this.document.createElement('iframe'); frame.style.position = 'fixed'; diff --git a/packages/pdf-viewer/VerticalPages.tsx b/packages/pdf-viewer/VerticalPages.tsx index ff4189fde..f64f581d5 100644 --- a/packages/pdf-viewer/VerticalPages.tsx +++ b/packages/pdf-viewer/VerticalPages.tsx @@ -12,8 +12,8 @@ const PagesHolder = styled.div<{ pageGap: number }>` justify-content: center; align-items: center; flex-flow: column; - width: fit-content; min-width: 100%; + width: fit-content; min-height: fit-content; row-gap: ${(props)=> props.pageGap || 2}px; `; @@ -22,13 +22,19 @@ export interface VerticalPagesProps { pdfDocument: PdfDocument; isDarkTheme: boolean; anchorPage?: number; - rememberScroll?: boolean; + rememberScroll: boolean; pdfId?: string; - zoom?: number; + zoom: number; container: MutableRefObject; pageGap: number; - showPageNumbers?: boolean; - onActivePageChange: (page: number)=> void; + widthPercent?: number; + showPageNumbers: boolean; + selectedPage?: number; + textSelectable: boolean; + onTextSelect?: (text: string)=> void; + onPageClick?: (page: number)=> void; + onActivePageChange?: (page: number)=> void; + onDoubleClick?: (page: number)=> void; } export default function VerticalPages(props: VerticalPagesProps) { @@ -63,7 +69,8 @@ export default function VerticalPages(props: VerticalPagesProps) { const updateWidth = () => { if (cancelled) return; - setContainerWidth(props.container.current.clientWidth); + const factor = (props.widthPercent || 100) / 100; + setContainerWidth(props.container.current.clientWidth * factor); }; const onResize = () => { @@ -85,7 +92,7 @@ export default function VerticalPages(props: VerticalPagesProps) { resizeTimer = null; } }; - }, [props.container, props.pdfDocument]); + }, [props.container, props.pdfDocument, props.widthPercent]); return ( {scaledSize ? @@ -94,6 +101,11 @@ export default function VerticalPages(props: VerticalPagesProps) { return ; } ) : 'Calculating size...' diff --git a/packages/pdf-viewer/fullScreen.css b/packages/pdf-viewer/fullScreen.css new file mode 100644 index 000000000..d0a3f4b0a --- /dev/null +++ b/packages/pdf-viewer/fullScreen.css @@ -0,0 +1,64 @@ +.full-app{ + position: fixed; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + display: grid; + grid-template-rows: 2.8rem auto; +} + +.viewers{ + width: 100%; + height: calc(100vh - 2.8rem); + overflow: hidden; + display: grid; + grid-template-columns: 11rem auto; + padding-right: 0.4rem; +} + +.pane{ + display: block; + margin: 0px auto; + overflow-x: auto; + overflow-y: auto; + width: 100%; + height: 100%; + padding: 0rem 0.2rem; + position: relative; +} +.thumbnail-pane{ + background-color: var(--tertiary); + overflow-y: scroll; +} + +.top-bar{ + display: grid; + grid-template-columns: 18rem auto 3rem; + align-items: center; + overflow: hidden; + background-color: var(--tertiary); + flex-direction: row; +} +.top-bar > div{ + display: flex; + justify-content: space-around; + align-items: center; + padding: 0.2rem; + flex-direction: row; + column-gap: 0.6rem; + height: 100%; + width: 100%; + max-width: 30rem; + margin: 0rem auto; + overflow: hidden; +} + +@media screen and (max-width: 840px) { + .viewers{ + grid-template-columns: 9rem auto; + } + .top-bar{ + grid-template-columns: 10rem auto 3rem; + } +} diff --git a/packages/pdf-viewer/hooks/useIsVisible.ts b/packages/pdf-viewer/hooks/useIsVisible.ts index 036501f2e..a68859a86 100644 --- a/packages/pdf-viewer/hooks/useIsVisible.ts +++ b/packages/pdf-viewer/hooks/useIsVisible.ts @@ -1,21 +1,37 @@ -import { useEffect, useState, MutableRefObject } from 'react'; +import { useEffect, useState, MutableRefObject, useRef } from 'react'; const useIsVisible = (elementRef: MutableRefObject, rootRef: MutableRefObject) => { const [isVisible, setIsVisible] = useState(false); + const lastVisible = useRef(0); + const invisibleOn = useRef(0); useEffect(() => { let observer: IntersectionObserver = null; + let timeout: number = null; if (elementRef.current) { observer = new IntersectionObserver((entries, _observer) => { let visible = false; entries.forEach((entry) => { if (entry.isIntersecting) { visible = true; - setIsVisible(true); + lastVisible.current = Date.now(); + if ((invisibleOn.current - lastVisible.current) > 300) { + setIsVisible(true); + } else { + if (!timeout) { + timeout = window.setTimeout(() => { + if (invisibleOn.current < lastVisible.current) { + setIsVisible(true); + } + timeout = null; + }, 300); + } + } } }); if (!visible) { + invisibleOn.current = Date.now(); setIsVisible(false); } }, { @@ -29,6 +45,10 @@ const useIsVisible = (elementRef: MutableRefObject, rootRef: Mutabl if (observer) { observer.disconnect(); } + if (timeout) { + window.clearTimeout(timeout); + timeout = null; + } }; }, [elementRef, rootRef]); diff --git a/packages/pdf-viewer/hooks/useVisibleOnSelect.ts b/packages/pdf-viewer/hooks/useVisibleOnSelect.ts new file mode 100644 index 000000000..0c68e205d --- /dev/null +++ b/packages/pdf-viewer/hooks/useVisibleOnSelect.ts @@ -0,0 +1,27 @@ +import { useRef, useEffect, MutableRefObject } from 'react'; + +export interface VisibleOnSelect { + container: MutableRefObject; + wrapperRef: MutableRefObject; + isVisible: boolean; + isSelected: boolean; +} + +// Used in thumbnail view, to scroll to the newly selected page. + +const useVisibleOnSelect = ({ container, wrapperRef, isVisible, isSelected }: VisibleOnSelect) => { + const isVisibleRef = useRef(isVisible); + + useEffect(() => { + if (isSelected && !isVisibleRef.current) { + container.current.scrollTop = wrapperRef.current.offsetTop; + } + }, [isSelected, isVisibleRef, container, wrapperRef]); + + useEffect(() => { + isVisibleRef.current = isVisible; + } , [isVisible]); + +}; + +export default useVisibleOnSelect; diff --git a/packages/pdf-viewer/main.tsx b/packages/pdf-viewer/main.tsx index f97258e46..091526b45 100644 --- a/packages/pdf-viewer/main.tsx +++ b/packages/pdf-viewer/main.tsx @@ -4,6 +4,8 @@ shim.setReact(React); import { render } from 'react-dom'; import * as pdfjsLib from 'pdfjs-dist'; import MiniViewerApp from './miniViewer'; +import MessageService from './messageService'; +import FullViewer from './FullViewer'; require('./common.css'); @@ -15,15 +17,27 @@ const type = window.frameElement.getAttribute('x-type'); const appearance = window.frameElement.getAttribute('x-appearance'); const anchorPage = Number(window.frameElement.getAttribute('x-anchorPage')) || null; const pdfId = window.frameElement.getAttribute('id'); +const resourceId = window.frameElement.getAttribute('x-resourceid'); +const title = window.frameElement.getAttribute('x-title'); document.documentElement.setAttribute('data-theme', appearance); +const messageService = new MessageService(type); + function App() { if (type === 'mini') { return ; + pdfId={pdfId} + resourceId={resourceId} + messageService={messageService}/>; + } else if (type === 'full') { + return ; } return
Error: Unknown app type "{type}"
; } diff --git a/packages/pdf-viewer/messageService.ts b/packages/pdf-viewer/messageService.ts new file mode 100644 index 000000000..dd19726bb --- /dev/null +++ b/packages/pdf-viewer/messageService.ts @@ -0,0 +1,35 @@ +export default class MessageService { + private viewerType: string; + public constructor(type: string) { + this.viewerType = type; + } + private sendMessage = (name: string, data?: any) => { + if (this.viewerType === 'full') { + const message = { + name, + ...data, + }; + window.postMessage(message, '*'); + } else if (this.viewerType === 'mini') { + const message = { + name, + data, + target: 'webview', + }; + window.parent.postMessage(message, '*'); + } + }; + public textSelected = (text: string) => { + this.sendMessage('textSelected', { text }); + }; + public close = () => { + this.sendMessage('close'); + }; + public openExternalViewer = () => { + this.sendMessage('externalViewer'); + }; + + public openFullScreenViewer = (resourceId: string, pageNo: number) => { + this.sendMessage('openPdfViewer', { resourceId, pageNo }); + }; +} diff --git a/packages/pdf-viewer/miniViewer.tsx b/packages/pdf-viewer/miniViewer.tsx index 40ebbda63..c466fdbfe 100644 --- a/packages/pdf-viewer/miniViewer.tsx +++ b/packages/pdf-viewer/miniViewer.tsx @@ -3,6 +3,7 @@ import useIsFocused from './hooks/useIsFocused'; import usePdfDocument from './hooks/usePdfDocument'; import VerticalPages from './VerticalPages'; import ZoomControls from './ui/ZoomControls'; +import MessageService from './messageService'; import { DownloadButton, PrintButton } from './ui/IconButtons'; require('./miniViewer.css'); @@ -12,6 +13,8 @@ export interface MiniViewerAppProps { isDarkTheme: boolean; anchorPage: number; pdfId: string; + resourceId?: string; + messageService: MessageService; } export default function MiniViewerApp(props: MiniViewerAppProps) { @@ -25,6 +28,10 @@ export default function MiniViewerApp(props: MiniViewerAppProps) { setActivePage(page); }, []); + const onDoubleClick = useCallback((pageNo: number) => { + props.messageService.openFullScreenViewer(props.resourceId, pageNo); + }, [props.messageService, props.resourceId]); + if (!pdfDocument) { return (
@@ -44,6 +51,9 @@ export default function MiniViewerApp(props: MiniViewerAppProps) { container={containerEl} showPageNumbers={true} zoom={zoom} + textSelectable={true} + onTextSelect={props.messageService.textSelected} + onDoubleClick={onDoubleClick} pageGap={2} onActivePageChange={onActivePageChange} />
diff --git a/packages/pdf-viewer/package.json b/packages/pdf-viewer/package.json index 86960fff6..c4673853c 100644 --- a/packages/pdf-viewer/package.json +++ b/packages/pdf-viewer/package.json @@ -40,6 +40,7 @@ "@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/react-fontawesome": "^0.2.0", "@joplin/lib": "workspace:^", + "async-mutex": "^0.4.0", "pdfjs-dist": "^2.14.305", "react": "16.13.1", "react-dom": "16.9.0", diff --git a/packages/pdf-viewer/textLayer.css b/packages/pdf-viewer/textLayer.css new file mode 100644 index 000000000..c4ec11e9e --- /dev/null +++ b/packages/pdf-viewer/textLayer.css @@ -0,0 +1,91 @@ +/* Copyright 2014 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + .textLayer { + position: absolute; + text-align: initial; + left: 0; + top: 0; + right: 0; + bottom: 0; + overflow: hidden; + line-height: 1; + text-size-adjust: none; + } + + .textLayer span, + .textLayer br { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + transform-origin: 0% 0%; + } + + /* Only necessary in Google Chrome, see issue 14205, and most unfortunately + * the problem doesn't show up in "text" reference tests. */ + .textLayer span.markedContent { + top: 0; + height: 0; + } + + .textLayer .highlight { + margin: -1px; + padding: 1px; + background-color: rgba(180, 0, 170, 1); + border-radius: 4px; + } + + .textLayer .highlight.appended { + position: initial; + } + + .textLayer .highlight.begin { + border-radius: 4px 0 0 4px; + } + + .textLayer .highlight.end { + border-radius: 0 4px 4px 0; + } + + .textLayer .highlight.middle { + border-radius: 0; + } + + .textLayer .highlight.selected { + background-color: rgba(0, 100, 0, 1); + } + + /* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */ + .textLayer br::selection { + background: transparent; + } + + .textLayer .endOfContent { + display: block; + position: absolute; + left: 0; + top: 100%; + right: 0; + bottom: 0; + z-index: -1; + cursor: default; + user-select: none; + } + + .textLayer .endOfContent.active { + top: 0; + } + \ No newline at end of file diff --git a/packages/pdf-viewer/types.ts b/packages/pdf-viewer/types.ts index 0dfe013d5..3317afd7f 100644 --- a/packages/pdf-viewer/types.ts +++ b/packages/pdf-viewer/types.ts @@ -9,3 +9,15 @@ export interface IconButtonProps { size?: number; color?: string; } + +export interface RenderRequest { + pageNo: number; + scaledSize: ScaledSize; + getTextLayer: boolean; + isCancelled: ()=> boolean; +} + +export interface RenderResult { + canvas: HTMLCanvasElement; + textLayerDiv: HTMLDivElement; +} diff --git a/packages/pdf-viewer/ui/GotoPage.tsx b/packages/pdf-viewer/ui/GotoPage.tsx new file mode 100644 index 000000000..be2028e39 --- /dev/null +++ b/packages/pdf-viewer/ui/GotoPage.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faAngleRight, faAngleLeft } from '@fortawesome/free-solid-svg-icons'; + +const Group = styled.div<{ size: number }>` + display: flex; + justify-content: center; + align-items: center; + flex-flow: row; + color: var(--grey); + cursor: initial; + font-size: ${props => props.size}rem; + padding: 0.2rem 0.4rem; + svg:hover { + color: var(--secondary); + } +`; + +const GoToInput = styled.input` +ont-size: 0.7rem; + font-weight: 500; + max-width: 3rem; + padding: 0.4rem 0.3rem; + background: var(--bg); + border: solid 2px transparent; + border-radius: 3.5rem; + color: var(--tertialry); + text-align: center; + margin: auto 0.6rem; + &:focus { + outline: none; + border: solid 2px var(--blue); + } +`; + +export interface GotoInputProps { + onChange: (pageNo: number)=> void; + size?: number; + pageCount: number; + currentPage: number; +} + +export default function GotoInput(props: GotoInputProps) { + const inputRef = useRef(null); + + const inputFocus = useCallback(() => { + inputRef.current?.select(); + } , []); + + const onPageNoInput = useCallback((e: React.ChangeEvent) => { + if (e.target.value.length <= 0) return; + const pageNo = parseInt(e.target.value, 10); + if (pageNo < 1 || pageNo > props.pageCount || pageNo === props.currentPage) return; + props.onChange(pageNo); + }, [props.onChange, props.pageCount, props.currentPage]); + + useEffect(() => { + inputRef.current.value = props.currentPage.toString(); + } , [props.currentPage]); + + return ( + props.onChange(props.currentPage - 1)} /> + + props.onChange(props.currentPage + 1)} /> + ); +} diff --git a/packages/pdf-viewer/ui/IconButtons.tsx b/packages/pdf-viewer/ui/IconButtons.tsx index a8fb58a55..1320002ec 100644 --- a/packages/pdf-viewer/ui/IconButtons.tsx +++ b/packages/pdf-viewer/ui/IconButtons.tsx @@ -1,7 +1,7 @@ import React from 'react'; import styled from 'styled-components'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faPrint, faDownload, IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import { faPrint, faDownload, faSquareArrowUpRight, faXmark, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { IconButtonProps } from '../types'; @@ -43,6 +43,18 @@ function BaseButton({ onClick, icon, name, size, color, hoverColor }: BaseButton ); } +export function OpenLinkButton({ onClick, size, color }: IconButtonProps) { + return ( + + ); +} + +export function CloseButton({ onClick, size, color }: IconButtonProps) { + return ( + + ); +} + export function DownloadButton({ onClick, size, color }: IconButtonProps) { return ( diff --git a/packages/renderer/MdToHtml/renderMedia.ts b/packages/renderer/MdToHtml/renderMedia.ts index 6ef7ace7f..0ca02fad8 100644 --- a/packages/renderer/MdToHtml/renderMedia.ts +++ b/packages/renderer/MdToHtml/renderMedia.ts @@ -69,7 +69,7 @@ export default function(link: Link, options: Options, linkIndexes: LinkIndexes) return ``; } diff --git a/yarn.lock b/yarn.lock index a271be7b6..646ffa99d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4139,6 +4139,7 @@ __metadata: "@types/react": 16.9.55 "@types/react-dom": ^16.9.0 "@types/styled-components": ^5.1.25 + async-mutex: ^0.4.0 babel-jest: ^28.1.3 css-loader: ^6.7.1 jest: ^28.1.3 @@ -8640,6 +8641,15 @@ __metadata: languageName: node linkType: hard +"async-mutex@npm:^0.4.0": + version: 0.4.0 + resolution: "async-mutex@npm:0.4.0" + dependencies: + tslib: ^2.4.0 + checksum: 813a71728b35a4fbfd64dba719f04726d9133c67b577fcd951b7028c4a675a13ee34e69beb82d621f87bf81f5d4f135c4c44be0448550c7db728547244ef71fc + languageName: node + linkType: hard + "async-settle@npm:^1.0.0": version: 1.0.0 resolution: "async-settle@npm:1.0.0" @@ -32672,6 +32682,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.4.0": + version: 2.4.0 + resolution: "tslib@npm:2.4.0" + checksum: 8c4aa6a3c5a754bf76aefc38026134180c053b7bd2f81338cb5e5ebf96fefa0f417bff221592bf801077f5bf990562f6264fecbc42cd3309b33872cb6fc3b113 + languageName: node + linkType: hard + "tsutils@npm:^3.21.0, tsutils@npm:^3.7.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0"