Commit e56ec90b authored by Robert Knight's avatar Robert Knight

Convert PDFIntegration to TypeScript

Aside from the JSDoc => TS conversion, the privacy of all instance fields was
clarified and underscore prefixes added to private fields for consistency.
parent d108a4d5
...@@ -20,22 +20,21 @@ import { offsetRelativeTo, scrollElement } from '../util/scroll'; ...@@ -20,22 +20,21 @@ import { offsetRelativeTo, scrollElement } from '../util/scroll';
import { PDFMetadata } from './pdf-metadata'; import { PDFMetadata } from './pdf-metadata';
/** import type {
* @typedef {import('../../types/annotator').Anchor} Anchor Anchor,
* @typedef {import('../../types/annotator').AnnotationData} AnnotationData AnnotationData,
* @typedef {import('../../types/annotator').Annotator} Annotator Annotator,
* @typedef {import('../../types/annotator').ContentInfoConfig} ContentInfoConfig ContentInfoConfig,
* @typedef {import('../../types/annotator').Integration} Integration Integration,
* @typedef {import('../../types/annotator').SidebarLayout} SidebarLayout SidebarLayout,
* @typedef {import('../../types/api').Selector} Selector } from '../../types/annotator';
* @typedef {import('../../types/pdfjs').PDFViewerApplication} PDFViewerApplication import type { Selector } from '../../types/api';
*/ import type { PDFViewer, PDFViewerApplication } from '../../types/pdfjs';
/** /**
* Window with additional globals set by PDF.js. * Window with additional globals set by PDF.js.
*
* @typedef {Window & { PDFViewerApplication: PDFViewerApplication}} PDFWindow
*/ */
type PDFWindow = Window & { PDFViewerApplication: PDFViewerApplication };
// The viewport and controls for PDF.js start breaking down below about 670px // The viewport and controls for PDF.js start breaking down below about 670px
// of available space, so only render PDF and sidebar side-by-side if there // of available space, so only render PDF and sidebar side-by-side if there
...@@ -44,16 +43,13 @@ const MIN_PDF_WIDTH = 680; ...@@ -44,16 +43,13 @@ const MIN_PDF_WIDTH = 680;
/** /**
* Return true if `anchor` is in an un-rendered page. * Return true if `anchor` is in an un-rendered page.
*
* @param {Anchor} anchor
*/ */
function anchorIsInPlaceholder(anchor) { function anchorIsInPlaceholder(anchor: Anchor) {
const highlight = anchor.highlights?.[0]; const highlight = anchor.highlights?.[0];
return highlight && isInPlaceholder(highlight); return highlight && isInPlaceholder(highlight);
} }
/** @param {number} ms */ function delay(ms: number) {
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
...@@ -61,67 +57,84 @@ function delay(ms) { ...@@ -61,67 +57,84 @@ function delay(ms) {
* Is the current document the PDF.js viewer application? * Is the current document the PDF.js viewer application?
*/ */
export function isPDF() { export function isPDF() {
// @ts-ignore - TS doesn't know about PDFViewerApplication global. const maybePDFJS: Window & { PDFViewerApplication?: PDFViewerApplication } =
return typeof PDFViewerApplication !== 'undefined'; window;
return typeof maybePDFJS.PDFViewerApplication !== 'undefined';
} }
/** /**
* Integration that works with PDF.js * Integration that works with PDF.js
* @implements {Integration}
*/ */
export class PDFIntegration extends TinyEmitter { export class PDFIntegration extends TinyEmitter implements Integration {
private _annotator: Annotator;
/** Banners shown at the top of the PDF viewer. */
private _banner: HTMLElement | null;
/** State indicating which banners to show above the PDF viewer. */
private _bannerState: {
contentInfo: ContentInfoConfig | null;
/** Warning that the current PDF does not have selectable text. */
noTextWarning: boolean;
};
/**
* A flag that indicates whether `destroy` has been called. Used to handle
* `destroy` being called during async code elsewhere in the class.
*/
private _destroyed: boolean;
private _listeners: ListenerCollection;
private _observer: MutationObserver;
private _pdfContainer: HTMLElement;
private _pdfMetadata: PDFMetadata;
private _pdfViewer: PDFViewer;
/**
* Amount of time to wait for re-anchoring to complete when scrolling to
* an anchor in a not-yet-rendered page.
*/
private _reanchoringMaxWait: number;
private _updateAnnotationLayerVisibility: () => void;
/** /**
* @param {Annotator} annotator * @param annotator
* @param {object} options * @param options
* @param {number} [options.reanchoringMaxWait] - Max time to wait for * @param [options.reanchoringMaxWait] - Max time to wait for
* re-anchoring to complete when scrolling to an un-rendered page. * re-anchoring to complete when scrolling to an un-rendered page.
*/ */
constructor(annotator, options = {}) { constructor(
annotator: Annotator,
options: { reanchoringMaxWait?: number } = {}
) {
super(); super();
this.annotator = annotator; this._annotator = annotator;
// Assume this class is only used if we're in the PDF.js viewer. // Assume this class is only used if we're in the PDF.js viewer.
const pdfWindow = /** @type {PDFWindow} */ ( const pdfWindow = window as unknown as PDFWindow;
/** @type {unknown} */ (window)
);
const pdfViewerApp = pdfWindow.PDFViewerApplication; const pdfViewerApp = pdfWindow.PDFViewerApplication;
this.pdfViewer = pdfViewerApp.pdfViewer; this._pdfViewer = pdfViewerApp.pdfViewer;
this.pdfViewer.viewer.classList.add('has-transparent-text-layer'); this._pdfViewer.viewer.classList.add('has-transparent-text-layer');
// Get the element that contains all of the PDF.js UI. This is typically // Get the element that contains all of the PDF.js UI. This is typically
// `document.body`. // `document.body`.
this.pdfContainer = pdfViewerApp.appConfig?.appContainer ?? document.body; this._pdfContainer = pdfViewerApp.appConfig?.appContainer ?? document.body;
this.pdfMetadata = new PDFMetadata(pdfViewerApp); this._pdfMetadata = new PDFMetadata(pdfViewerApp);
this.observer = new MutationObserver(debounce(() => this._update(), 100)); this._observer = new MutationObserver(debounce(() => this._update(), 100));
this.observer.observe(this.pdfViewer.viewer, { this._observer.observe(this._pdfViewer.viewer, {
attributes: true, attributes: true,
attributeFilter: ['data-loaded'], attributeFilter: ['data-loaded'],
childList: true, childList: true,
subtree: true, subtree: true,
}); });
/**
* Amount of time to wait for re-anchoring to complete when scrolling to
* an anchor in a not-yet-rendered page.
*/
this._reanchoringMaxWait = options.reanchoringMaxWait ?? 3000; this._reanchoringMaxWait = options.reanchoringMaxWait ?? 3000;
/**
* Banners shown at the top of the PDF viewer.
*
* @type {HTMLElement|null}
*/
this._banner = null; this._banner = null;
/** State indicating which banners to show above the PDF viewer. */
this._bannerState = { this._bannerState = {
/** @type {ContentInfoConfig|null} */
contentInfo: null, contentInfo: null,
/** Warning that the current PDF does not have selectable text. */
noTextWarning: false, noTextWarning: false,
}; };
this._updateBannerState(this._bannerState); this._updateBannerState(this._bannerState);
...@@ -131,11 +144,11 @@ export class PDFIntegration extends TinyEmitter { ...@@ -131,11 +144,11 @@ export class PDFIntegration extends TinyEmitter {
// layer appears above the invisible text layer and can interfere with text // layer appears above the invisible text layer and can interfere with text
// selection. See https://github.com/hypothesis/client/issues/1464. // selection. See https://github.com/hypothesis/client/issues/1464.
this._updateAnnotationLayerVisibility = () => { this._updateAnnotationLayerVisibility = () => {
const selection = /** @type {Selection} */ (pdfWindow.getSelection()); const selection = pdfWindow.getSelection()!;
// Add CSS class to indicate whether there is a selection. Annotation // Add CSS class to indicate whether there is a selection. Annotation
// layers are then hidden by a CSS rule in `pdfjs-overrides.scss`. // layers are then hidden by a CSS rule in `pdfjs-overrides.scss`.
this.pdfViewer.viewer.classList.toggle( this._pdfViewer.viewer.classList.toggle(
'is-selecting', 'is-selecting',
!selection.isCollapsed !selection.isCollapsed
); );
...@@ -148,15 +161,13 @@ export class PDFIntegration extends TinyEmitter { ...@@ -148,15 +161,13 @@ export class PDFIntegration extends TinyEmitter {
this._updateAnnotationLayerVisibility this._updateAnnotationLayerVisibility
); );
// A flag that indicates whether `destroy` has been called. Used to handle
// `destroy` being called during async code elsewhere in the class.
this._destroyed = false; this._destroyed = false;
} }
destroy() { destroy() {
this._listeners.removeAll(); this._listeners.removeAll();
this.pdfViewer.viewer.classList.remove('has-transparent-text-layer'); this._pdfViewer.viewer.classList.remove('has-transparent-text-layer');
this.observer.disconnect(); this._observer.disconnect();
this._banner?.remove(); this._banner?.remove();
this._destroyed = true; this._destroyed = true;
} }
...@@ -165,34 +176,28 @@ export class PDFIntegration extends TinyEmitter { ...@@ -165,34 +176,28 @@ export class PDFIntegration extends TinyEmitter {
* Return the URL of the currently loaded PDF document. * Return the URL of the currently loaded PDF document.
*/ */
uri() { uri() {
return this.pdfMetadata.getUri(); return this._pdfMetadata.getUri();
} }
/** /**
* Return the metadata (eg. title) for the currently loaded PDF document. * Return the metadata (eg. title) for the currently loaded PDF document.
*/ */
getMetadata() { getMetadata() {
return this.pdfMetadata.getMetadata(); return this._pdfMetadata.getMetadata();
} }
/** /**
* Display a banner at the top of the PDF viewer showing information about the * Display a banner at the top of the PDF viewer showing information about the
* current document. * current document.
*
* @param {ContentInfoConfig} contentInfo
*/ */
showContentInfo(contentInfo) { showContentInfo(contentInfo: ContentInfoConfig) {
this._updateBannerState({ contentInfo }); this._updateBannerState({ contentInfo });
} }
/** /**
* Resolve serialized `selectors` from an annotation to a range. * Resolve serialized `selectors` from an annotation to a range.
*
* @param {HTMLElement} root
* @param {Selector[]} selectors
* @return {Promise<Range>}
*/ */
anchor(root, selectors) { anchor(root: HTMLElement, selectors: Selector[]): Promise<Range> {
// nb. The `root` argument is not really used by `anchor`. It existed for // nb. The `root` argument is not really used by `anchor`. It existed for
// consistency between HTML and PDF anchoring and could be removed. // consistency between HTML and PDF anchoring and could be removed.
return anchor(root, selectors); return anchor(root, selectors);
...@@ -202,10 +207,8 @@ export class PDFIntegration extends TinyEmitter { ...@@ -202,10 +207,8 @@ export class PDFIntegration extends TinyEmitter {
* Trim `range` to remove leading or trailing empty content, then check to see * Trim `range` to remove leading or trailing empty content, then check to see
* if that trimmed Range lies within a single PDF page's text layer. If so, * if that trimmed Range lies within a single PDF page's text layer. If so,
* return the trimmed Range. * return the trimmed Range.
*
* @param {Range} range
*/ */
getAnnotatableRange(range) { getAnnotatableRange(range: Range) {
try { try {
const trimmedRange = TextRange.trimmedRange(range); const trimmedRange = TextRange.trimmedRange(range);
if (canDescribe(trimmedRange)) { if (canDescribe(trimmedRange)) {
...@@ -226,12 +229,8 @@ export class PDFIntegration extends TinyEmitter { ...@@ -226,12 +229,8 @@ export class PDFIntegration extends TinyEmitter {
/** /**
* Generate selectors for the text in `range`. * Generate selectors for the text in `range`.
*
* @param {HTMLElement} root
* @param {Range} range
* @return {Promise<Selector[]>}
*/ */
describe(root, range) { describe(root: HTMLElement, range: Range): Promise<Selector[]> {
// nb. The `root` argument is not really used by `anchor`. It existed for // nb. The `root` argument is not really used by `anchor`. It existed for
// consistency between HTML and PDF anchoring and could be removed. // consistency between HTML and PDF anchoring and could be removed.
return describe(root, range); return describe(root, range);
...@@ -265,17 +264,17 @@ export class PDFIntegration extends TinyEmitter { ...@@ -265,17 +264,17 @@ export class PDFIntegration extends TinyEmitter {
/** /**
* Update banners shown above the PDF viewer. * Update banners shown above the PDF viewer.
*
* @param {Partial<typeof PDFIntegration.prototype._bannerState>} state
*/ */
_updateBannerState(state) { _updateBannerState(
state: Partial<typeof PDFIntegration.prototype._bannerState>
) {
this._bannerState = { ...this._bannerState, ...state }; this._bannerState = { ...this._bannerState, ...state };
// Get a reference to the top-level DOM element associated with the PDF.js // Get a reference to the top-level DOM element associated with the PDF.js
// viewer. // viewer.
const outerContainer = /** @type {HTMLElement} */ ( const outerContainer = document.querySelector(
document.querySelector('#outerContainer') '#outerContainer'
); ) as HTMLElement;
const showBanner = const showBanner =
this._bannerState.contentInfo || this._bannerState.noTextWarning; this._bannerState.contentInfo || this._bannerState.noTextWarning;
...@@ -304,7 +303,7 @@ export class PDFIntegration extends TinyEmitter { ...@@ -304,7 +303,7 @@ export class PDFIntegration extends TinyEmitter {
)} )}
{this._bannerState.noTextWarning && <WarningBanner />} {this._bannerState.noTextWarning && <WarningBanner />}
</Banners>, </Banners>,
/** @type {ShadowRoot} */ (this._banner.shadowRoot) this._banner.shadowRoot!
); );
const bannerHeight = this._banner.getBoundingClientRect().height; const bannerHeight = this._banner.getBoundingClientRect().height;
...@@ -321,11 +320,11 @@ export class PDFIntegration extends TinyEmitter { ...@@ -321,11 +320,11 @@ export class PDFIntegration extends TinyEmitter {
// This method (re-)anchors annotations when pages are rendered and destroyed. // This method (re-)anchors annotations when pages are rendered and destroyed.
_update() { _update() {
// A list of annotations that need to be refreshed. // A list of annotations that need to be refreshed.
const refreshAnnotations = /** @type {AnnotationData[]} */ ([]); const refreshAnnotations = [] as AnnotationData[];
const pageCount = this.pdfViewer.pagesCount; const pageCount = this._pdfViewer.pagesCount;
for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {
const page = this.pdfViewer.getPageView(pageIndex); const page = this._pdfViewer.getPageView(pageIndex);
if (!page?.textLayer?.renderingDone) { if (!page?.textLayer?.renderingDone) {
continue; continue;
} }
...@@ -348,7 +347,7 @@ export class PDFIntegration extends TinyEmitter { ...@@ -348,7 +347,7 @@ export class PDFIntegration extends TinyEmitter {
} }
// Find all the anchors that have been invalidated by page state changes. // Find all the anchors that have been invalidated by page state changes.
for (let anchor of this.annotator.anchors) { for (const anchor of this._annotator.anchors) {
// Skip any we already know about. // Skip any we already know about.
if (anchor.highlights) { if (anchor.highlights) {
if (refreshAnnotations.includes(anchor.annotation)) { if (refreshAnnotations.includes(anchor.annotation)) {
...@@ -370,18 +369,14 @@ export class PDFIntegration extends TinyEmitter { ...@@ -370,18 +369,14 @@ export class PDFIntegration extends TinyEmitter {
} }
} }
refreshAnnotations.map(annotation => this.annotator.anchor(annotation)); refreshAnnotations.map(annotation => this._annotator.anchor(annotation));
} }
/** /**
* Return the scrollable element which contains the document content. * Return the scrollable element which contains the document content.
*
* @return {HTMLElement}
*/ */
contentContainer() { contentContainer(): HTMLElement {
return /** @type {HTMLElement} */ ( return document.querySelector('#viewerContainer') as HTMLElement;
document.querySelector('#viewerContainer')
);
} }
/** /**
...@@ -391,10 +386,9 @@ export class PDFIntegration extends TinyEmitter { ...@@ -391,10 +386,9 @@ export class PDFIntegration extends TinyEmitter {
* for the sidebar, and prompt PDF.js to re-render the PDF pages to scale * for the sidebar, and prompt PDF.js to re-render the PDF pages to scale
* within that resized container. * within that resized container.
* *
* @param {SidebarLayout} sidebarLayout * @return - True if side-by-side mode was activated
* @return {boolean} - True if side-by-side mode was activated
*/ */
fitSideBySide(sidebarLayout) { fitSideBySide(sidebarLayout: SidebarLayout): boolean {
const maximumWidthToFit = window.innerWidth - sidebarLayout.width; const maximumWidthToFit = window.innerWidth - sidebarLayout.width;
const active = sidebarLayout.expanded && maximumWidthToFit >= MIN_PDF_WIDTH; const active = sidebarLayout.expanded && maximumWidthToFit >= MIN_PDF_WIDTH;
...@@ -408,10 +402,10 @@ export class PDFIntegration extends TinyEmitter { ...@@ -408,10 +402,10 @@ export class PDFIntegration extends TinyEmitter {
const reservedSpace = active const reservedSpace = active
? sidebarLayout.width ? sidebarLayout.width
: sidebarLayout.toolbarWidth; : sidebarLayout.toolbarWidth;
this.pdfContainer.style.width = `calc(100% - ${reservedSpace}px)`; this._pdfContainer.style.width = `calc(100% - ${reservedSpace}px)`;
// The following logic is pulled from PDF.js `webViewerResize` // The following logic is pulled from PDF.js `webViewerResize`
const currentScaleValue = this.pdfViewer.currentScaleValue; const currentScaleValue = this._pdfViewer.currentScaleValue;
if ( if (
currentScaleValue === 'auto' || currentScaleValue === 'auto' ||
currentScaleValue === 'page-fit' || currentScaleValue === 'page-fit' ||
...@@ -419,10 +413,10 @@ export class PDFIntegration extends TinyEmitter { ...@@ -419,10 +413,10 @@ export class PDFIntegration extends TinyEmitter {
) { ) {
// NB: There is logic within the setter for `currentScaleValue` // NB: There is logic within the setter for `currentScaleValue`
// Setting this scale value will prompt PDF.js to recalculate viewport // Setting this scale value will prompt PDF.js to recalculate viewport
this.pdfViewer.currentScaleValue = currentScaleValue; this._pdfViewer.currentScaleValue = currentScaleValue;
} }
// This will cause PDF pages to re-render if their scaling has changed // This will cause PDF pages to re-render if their scaling has changed
this.pdfViewer.update(); this._pdfViewer.update();
return active; return active;
} }
...@@ -436,10 +430,8 @@ export class PDFIntegration extends TinyEmitter { ...@@ -436,10 +430,8 @@ export class PDFIntegration extends TinyEmitter {
* then `scrollToAnchor` waits until the page's text layer is rendered and * then `scrollToAnchor` waits until the page's text layer is rendered and
* the annotation is re-anchored in the fully rendered page. Then it scrolls * the annotation is re-anchored in the fully rendered page. Then it scrolls
* again to the final location. * again to the final location.
*
* @param {Anchor} anchor
*/ */
async scrollToAnchor(anchor) { async scrollToAnchor(anchor: Anchor) {
const annotation = anchor.annotation; const annotation = anchor.annotation;
const inPlaceholder = anchorIsInPlaceholder(anchor); const inPlaceholder = anchorIsInPlaceholder(anchor);
const offset = this._anchorOffset(anchor); const offset = this._anchorOffset(anchor);
...@@ -470,18 +462,17 @@ export class PDFIntegration extends TinyEmitter { ...@@ -470,18 +462,17 @@ export class PDFIntegration extends TinyEmitter {
/** /**
* Wait for an annotation to be anchored in a rendered page. * Wait for an annotation to be anchored in a rendered page.
*
* @param {AnnotationData} annotation
* @param {number} maxWait
* @return {Promise<Anchor|null>}
*/ */
async _waitForAnnotationToBeAnchored(annotation, maxWait) { async _waitForAnnotationToBeAnchored(
annotation: AnnotationData,
maxWait: number
): Promise<Anchor | null> {
const start = Date.now(); const start = Date.now();
let anchor; let anchor;
do { do {
// nb. Re-anchoring might result in a different anchor object for the // nb. Re-anchoring might result in a different anchor object for the
// same annotation. // same annotation.
anchor = this.annotator.anchors.find(a => a.annotation === annotation); anchor = this._annotator.anchors.find(a => a.annotation === annotation);
if (!anchor || anchorIsInPlaceholder(anchor)) { if (!anchor || anchorIsInPlaceholder(anchor)) {
anchor = null; anchor = null;
...@@ -497,10 +488,9 @@ export class PDFIntegration extends TinyEmitter { ...@@ -497,10 +488,9 @@ export class PDFIntegration extends TinyEmitter {
* Return the offset that the PDF content container would need to be scrolled * Return the offset that the PDF content container would need to be scrolled
* to, in order to make an anchor visible. * to, in order to make an anchor visible.
* *
* @param {Anchor} anchor * @return - Target offset or `null` if this anchor was not resolved
* @return {number|null} - Target offset or `null` if this anchor was not resolved
*/ */
_anchorOffset(anchor) { _anchorOffset(anchor: Anchor): number | null {
if (!anchor.highlights) { if (!anchor.highlights) {
// This anchor was not resolved to a location in the document. // This anchor was not resolved to a location in the document.
return null; return null;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment