1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Desktop: Add PDF full screen viewer (#6821)

This commit is contained in:
asrient 2022-09-11 19:28:32 +05:30 committed by GitHub
parent e3ba02281b
commit f6a8bf9ea2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 850 additions and 72 deletions

View File

@ -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

18
.gitignore vendored
View File

@ -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

View File

@ -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,

View File

@ -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,
},
});
},
};
};

View File

@ -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));

View File

@ -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<HTMLIFrameElement>(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 (
<Window theme={theme}>
<IFrame src="./vendor/lib/@joplin/pdf-viewer/index.html" x-url={Resource.fullPath(props.resource)}
x-appearance={theme.appearance} ref={iframeRef}
x-title={props.resource.title}
x-anchorpage={props.pageNo}
x-type="full"></IFrame>
</Window>
);
}

View File

@ -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<string, RegisteredDialog> = {
return <EditFolderDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
},
},
pdfViewer: {
render: (props: RegisteredDialogProps, customProps: any) => {
return <PdfViewer key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
},
},
};
const GlobalStyle = createGlobalStyle`

View File

@ -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;

View File

@ -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<number>(1);
const [startPage, setStartPage] = useState<number>(props.startPage || 1);
const [selectedPage, setSelectedPage] = useState<number>(startPage);
const mainViewerRef = useRef<HTMLDivElement>(null);
const thubmnailRef = useRef<HTMLDivElement>(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 (
<div className="full-app loading">
<div>Loading pdf..</div>
</div>);
}
return (
<div className="full-app">
<div className="top-bar">
<div>
<TitleWrapper>
<Title title={props.title}>{props.title}</Title>
<div>{selectedPage} of {pdfDocument.pageCount} pages</div>
</TitleWrapper>
</div>
<div>
<ZoomControls onChange={setZoom} zoom={zoom} size={1} />
<OpenLinkButton onClick={props.messageService.openExternalViewer} size={1.3} />
<PrintButton onClick={pdfDocument?.printPdf} size={1.3}/>
<DownloadButton onClick={pdfDocument?.downloadPdf} size={1.3}/>
<GotoInput onChange={goToPage} size={1.3} pageCount={pdfDocument.pageCount} currentPage={selectedPage} />
</div>
<div>
<CloseButton onClick={props.messageService.close} size={1.3} />
</div>
</div>
<div className="viewers dark-bg">
<div className="pane thumbnail-pane" ref={thubmnailRef}>
<VerticalPages
pdfDocument={pdfDocument}
isDarkTheme={true}
rememberScroll={false}
container={thubmnailRef}
pageGap={16}
widthPercent={86}
showPageNumbers={true}
selectedPage={selectedPage}
onPageClick={goToPage}
textSelectable={false}
zoom={1}
/>
</div>
<div className="pane main-pane" ref={mainViewerRef}>
<VerticalPages
pdfDocument={pdfDocument}
isDarkTheme={true}
rememberScroll={false}
container={mainViewerRef}
zoom={zoom}
pageGap={5}
anchorPage={startPage}
onActivePageChange={onActivePageChange}
textSelectable={true}
onTextSelect={props.messageService.textSelected}
showPageNumbers={false}
/>
</div>
</div>
</div>
);
}

View File

@ -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<HTMLElement>;
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<number>(null);
const timestampRef = useRef<number>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const textRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<PageWrapper ref={wrapperRef} style={style}>
<canvas ref={canvasRef} className="page-canvas" style={style}>
<div>
{error ? 'ERROR' : 'Loading..'}
</div>
Page {props.pageNo}
</canvas>
{props.showPageNumbers && <PageInfo>{props.isAnchored ? '📌' : ''} Page {props.pageNo}</PageInfo>}
<PageWrapper onDoubleClick={onDoubleClick} isSelected={!!props.isSelected} onContextMenu={onContextMenu} onClick={onClick} ref={wrapperRef} style={style}>
{ error && <div>Error: {error}</div> }
{props.showPageNumbers && <PageInfo isSelected={!!props.isSelected}>{props.isAnchored ? '📌' : ''} Page {props.pageNo}</PageInfo>}
</PageWrapper>
);
}

View File

@ -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<RenderResult> => {
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<RenderResult> {
// 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';

View File

@ -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<HTMLElement>;
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 (<PagesHolder pageGap={props.pageGap || 2} ref={innerContainerEl} >
{scaledSize ?
@ -94,6 +101,11 @@ export default function VerticalPages(props: VerticalPagesProps) {
return <Page pdfDocument={props.pdfDocument} pageNo={i + 1} focusOnLoad={scaledSize && props.anchorPage && props.anchorPage === i + 1}
isAnchored={props.anchorPage && props.anchorPage === i + 1}
showPageNumbers={props.showPageNumbers}
isSelected={scaledSize && props.selectedPage && props.selectedPage === i + 1}
onClick={props.onPageClick}
textSelectable={props.textSelectable}
onTextSelect={props.onTextSelect}
onDoubleClick={props.onDoubleClick}
isDarkTheme={props.isDarkTheme} scaledSize={scaledSize} container={props.container} key={i} />;
}
) : 'Calculating size...'

View File

@ -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;
}
}

View File

@ -1,21 +1,37 @@
import { useEffect, useState, MutableRefObject } from 'react';
import { useEffect, useState, MutableRefObject, useRef } from 'react';
const useIsVisible = (elementRef: MutableRefObject<HTMLElement>, rootRef: MutableRefObject<HTMLElement>) => {
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<HTMLElement>, rootRef: Mutabl
if (observer) {
observer.disconnect();
}
if (timeout) {
window.clearTimeout(timeout);
timeout = null;
}
};
}, [elementRef, rootRef]);

View File

@ -0,0 +1,27 @@
import { useRef, useEffect, MutableRefObject } from 'react';
export interface VisibleOnSelect {
container: MutableRefObject<HTMLElement>;
wrapperRef: MutableRefObject<HTMLElement>;
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;

View File

@ -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 <MiniViewerApp pdfPath={url}
isDarkTheme={appearance === 'dark'}
anchorPage={anchorPage}
pdfId={pdfId} />;
pdfId={pdfId}
resourceId={resourceId}
messageService={messageService}/>;
} else if (type === 'full') {
return <FullViewer pdfPath={url}
isDarkTheme={appearance === 'dark'}
startPage={anchorPage || 1}
title={title}
messageService={messageService} />;
}
return <div>Error: Unknown app type "{type}"</div>;
}

View File

@ -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 });
};
}

View File

@ -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 (
<div className="mini-app loading">
@ -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} />
</div>

View File

@ -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",

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<HTMLInputElement>(null);
const inputFocus = useCallback(() => {
inputRef.current?.select();
} , []);
const onPageNoInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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 (<Group size={props.size || 1}>
<FontAwesomeIcon icon={faAngleLeft} title="Previous Page" style={{ cursor: 'pointer' }} onClick={() => props.onChange(props.currentPage - 1)} />
<GoToInput onChange={onPageNoInput} placeholder="Page" ref={inputRef} onFocus={inputFocus} />
<FontAwesomeIcon icon={faAngleRight} title="Next Page" style={{ cursor: 'pointer' }} onClick={() => props.onChange(props.currentPage + 1)} />
</Group>);
}

View File

@ -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 (
<BaseButton onClick={onClick} icon={faSquareArrowUpRight} name='Open in another app' size={size} color={color} />
);
}
export function CloseButton({ onClick, size, color }: IconButtonProps) {
return (
<BaseButton onClick={onClick} icon={faXmark} name='Close' size={size} color={color} hoverColor={'red'} />
);
}
export function DownloadButton({ onClick, size, color }: IconButtonProps) {
return (
<BaseButton onClick={onClick} icon={faDownload} name='Download' size={size} color={color} />

View File

@ -69,7 +69,7 @@ export default function(link: Link, options: Options, linkIndexes: LinkIndexes)
return `<iframe src="${src}" x-url="${escapedResourcePath}"
x-appearance="${options.theme.appearance}" ${anchorPageNo ? `x-anchorPage="${anchorPageNo}"` : ''} id="${id}"
x-type="mini"
x-type="mini" x-resourceid="${resourceId}"
class="media-player media-pdf"></iframe>`;
}

View File

@ -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"