Commit 75694446 authored by Robert Knight's avatar Robert Knight

Add option to show content partner banner in PDF integration

Add an option to show a banner at the top of the PDF viewer indicating
where the content came from. This is a contractual requirement for
working with certain content providers.
parent e70287cf
/**
* Render banners at the top of a document in a stacked column.
*
* @param {object} props
* @param {import("preact").ComponentChildren} props.children
*/
export default function Banners({ children }) {
return <div className="flex flex-col">{children}</div>;
}
/**
* @typedef {import('../../types/annotator').ContentPartner} ContentPartner
*/
/**
* A banner that informs the user about the provider of the document.
*
* @param {object} props
* @param {ContentPartner} props.provider
* @param {() => void} props.onClose
*/
export default function ContentPartnerBanner({ provider, onClose }) {
return (
<div className="bg-white p-2">
{provider === 'jstor' && 'Content provided by JSTOR'}
<button onClick={onClose}>Close</button>
</div>
);
}
...@@ -10,6 +10,8 @@ import { ...@@ -10,6 +10,8 @@ import {
documentHasText, documentHasText,
} from '../anchoring/pdf'; } from '../anchoring/pdf';
import { isInPlaceholder, removePlaceholder } from '../anchoring/placeholder'; import { isInPlaceholder, removePlaceholder } from '../anchoring/placeholder';
import Banners from '../components/Banners';
import ContentPartnerBanner from '../components/ContentPartnerBanner';
import WarningBanner from '../components/WarningBanner'; import WarningBanner from '../components/WarningBanner';
import { createShadowRoot } from '../util/shadow-root'; import { createShadowRoot } from '../util/shadow-root';
import { offsetRelativeTo, scrollElement } from '../util/scroll'; import { offsetRelativeTo, scrollElement } from '../util/scroll';
...@@ -20,6 +22,7 @@ import { PDFMetadata } from './pdf-metadata'; ...@@ -20,6 +22,7 @@ import { PDFMetadata } from './pdf-metadata';
* @typedef {import('../../types/annotator').Anchor} Anchor * @typedef {import('../../types/annotator').Anchor} Anchor
* @typedef {import('../../types/annotator').AnnotationData} AnnotationData * @typedef {import('../../types/annotator').AnnotationData} AnnotationData
* @typedef {import('../../types/annotator').Annotator} Annotator * @typedef {import('../../types/annotator').Annotator} Annotator
* @typedef {import('../../types/annotator').ContentPartner} ContentPartner
* @typedef {import('../../types/annotator').HypothesisWindow} HypothesisWindow * @typedef {import('../../types/annotator').HypothesisWindow} HypothesisWindow
* @typedef {import('../../types/annotator').Integration} Integration * @typedef {import('../../types/annotator').Integration} Integration
* @typedef {import('../../types/annotator').SidebarLayout} SidebarLayout * @typedef {import('../../types/annotator').SidebarLayout} SidebarLayout
...@@ -63,6 +66,8 @@ export class PDFIntegration { ...@@ -63,6 +66,8 @@ export class PDFIntegration {
/** /**
* @param {Annotator} annotator * @param {Annotator} annotator
* @param {object} options * @param {object} options
* @param {ContentPartner} [options.contentPartner] - If set, show branding
* for the given content partner in a banner above the PDF viewer.
* @param {number} [options.reanchoringMaxWait] - Max time to wait for * @param {number} [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.
*/ */
...@@ -100,12 +105,24 @@ export class PDFIntegration { ...@@ -100,12 +105,24 @@ export class PDFIntegration {
this._reanchoringMaxWait = options.reanchoringMaxWait ?? 3000; this._reanchoringMaxWait = options.reanchoringMaxWait ?? 3000;
/** /**
* A banner shown at the top of the PDF viewer warning the user if the PDF * Banners shown at the top of the PDF viewer.
* is not suitable for use with Hypothesis.
* *
* @type {HTMLElement|null} * @type {HTMLElement|null}
*/ */
this._warningBanner = null; this._banner = null;
/** State indicating which banners to show above the PDF viewer. */
this._bannerState = {
/**
* Branding for a content provider.
*
* @type {ContentPartner|null}
*/
contentPartner: options.contentPartner ?? null,
/** Warning that the current PDF does not have selectable text. */
noTextWarning: false,
};
this._updateBannerState(this._bannerState);
this._checkForSelectableText(); this._checkForSelectableText();
// Hide annotation layer when the user is making a selection. The annotation // Hide annotation layer when the user is making a selection. The annotation
...@@ -138,7 +155,7 @@ export class PDFIntegration { ...@@ -138,7 +155,7 @@ export class PDFIntegration {
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._warningBanner?.remove(); this._banner?.remove();
this._destroyed = true; this._destroyed = true;
} }
...@@ -210,7 +227,7 @@ export class PDFIntegration { ...@@ -210,7 +227,7 @@ export class PDFIntegration {
try { try {
const hasText = await documentHasText(); const hasText = await documentHasText();
this._toggleNoSelectableTextWarning(!hasText); this._updateBannerState({ noTextWarning: !hasText });
} catch (err) { } catch (err) {
/* istanbul ignore next */ /* istanbul ignore next */
console.warn('Unable to check for text in PDF:', err); console.warn('Unable to check for text in PDF:', err);
...@@ -218,21 +235,25 @@ export class PDFIntegration { ...@@ -218,21 +235,25 @@ export class PDFIntegration {
} }
/** /**
* Set whether the warning about a PDF's suitability for use with Hypothesis * Update banners shown above the PDF viewer.
* is shown.
* *
* @param {boolean} showWarning * @param {Partial<typeof PDFIntegration.prototype._bannerState>} state
*/ */
_toggleNoSelectableTextWarning(showWarning) { _updateBannerState(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 = /** @type {HTMLElement} */ (
document.querySelector('#outerContainer') document.querySelector('#outerContainer')
); );
if (!showWarning) { const showBanner =
this._warningBanner?.remove(); this._bannerState.contentPartner || this._bannerState.noTextWarning;
this._warningBanner = null;
if (!showBanner) {
this._banner?.remove();
this._banner = null;
// Undo inline styles applied when the banner is shown. The banner will // Undo inline styles applied when the banner is shown. The banner will
// then gets its normal 100% height set by PDF.js's CSS. // then gets its normal 100% height set by PDF.js's CSS.
...@@ -241,13 +262,26 @@ export class PDFIntegration { ...@@ -241,13 +262,26 @@ export class PDFIntegration {
return; return;
} }
this._warningBanner = document.createElement('hypothesis-banner'); if (!this._banner) {
document.body.prepend(this._warningBanner); this._banner = document.createElement('hypothesis-banner');
document.body.prepend(this._banner);
createShadowRoot(this._banner);
}
const warningBannerContent = createShadowRoot(this._warningBanner); render(
render(<WarningBanner />, warningBannerContent); <Banners>
{this._bannerState.contentPartner && (
<ContentPartnerBanner
provider={this._bannerState.contentPartner}
onClose={() => this._updateBannerState({ contentPartner: null })}
/>
)}
{this._bannerState.noTextWarning && <WarningBanner />}
</Banners>,
/** @type {ShadowRoot} */ (this._banner.shadowRoot)
);
const bannerHeight = this._warningBanner.getBoundingClientRect().height; const bannerHeight = this._banner.getBoundingClientRect().height;
// The `#outerContainer` element normally has height set to 100% of the body. // The `#outerContainer` element normally has height set to 100% of the body.
// //
......
...@@ -165,9 +165,6 @@ ...@@ -165,9 +165,6 @@
* @typedef {Window & Globals} HypothesisWindow * @typedef {Window & Globals} HypothesisWindow
*/ */
// Make TypeScript treat this file as a module.
export const unused = {};
/** /**
* Destroyable classes implement the `destroy` method to properly remove all * Destroyable classes implement the `destroy` method to properly remove all
* event handlers and other resources. * event handlers and other resources.
...@@ -175,3 +172,12 @@ export const unused = {}; ...@@ -175,3 +172,12 @@ export const unused = {};
* @typedef Destroyable * @typedef Destroyable
* @prop {VoidFunction} destroy * @prop {VoidFunction} destroy
*/ */
/**
* Name of a content partner to show branding for.
*
* @typedef {'jstor'} ContentPartner
*/
// Make TypeScript treat this file as a module.
export const unused = {};
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