Commit 49e1f96a authored by Robert Knight's avatar Robert Knight

Add initial VitalSource integration

This adds a new integration for the VitalSource Bookshelf reader. It is
comprised of two separate sub-integrations, one for the "container"
frame into which the client is initially loaded, and a separate one for
the "content" frame that displays the book content.

The container frame integration is responsible for injecting the client
into the content frame and re-injecting after a navigation. All the
annotation-related functionality is a no-op in this frame. The logic for
injecting the client depends on a stub method in the Guest class which
will be implemented separately.

The content frame integration mostly delegates to the HTML integration,
but it customizes the document metadata saved with annotations and also
prevents the VitalSource viewer's native selection controls from
interfering with Hypothesis's ones.
parent af5bdd9b
......@@ -240,6 +240,17 @@ export default class Guest {
this._listeners.add(window, 'resize', () => this._repositionAdder());
}
/**
* Inject the Hypothesis client into a guest frame.
*
* @param {HTMLIFrameElement} frame
*/
// eslint-disable-next-line no-unused-vars
async injectClient(frame) {
/* istanbul ignore next */
console.warn('Guest#injectClient is not yet implemented.');
}
/**
* Retrieve metadata for the current document.
*/
......
import { HTMLIntegration } from './html';
import { PDFIntegration, isPDF } from './pdf';
import {
VitalSourceContentIntegration,
VitalSourceContainerIntegration,
vitalSourceFrameRole,
} from './vitalsource';
/**
* @typedef {import('../../types/annotator').Annotator} Annotator
......@@ -16,7 +21,14 @@ import { PDFIntegration, isPDF } from './pdf';
export function createIntegration(annotator) {
if (isPDF()) {
return new PDFIntegration(annotator);
} else {
return new HTMLIntegration();
}
const vsFrameRole = vitalSourceFrameRole();
if (vsFrameRole === 'container') {
return new VitalSourceContainerIntegration(annotator);
} else if (vsFrameRole === 'content') {
return new VitalSourceContentIntegration();
}
return new HTMLIntegration();
}
......@@ -3,16 +3,28 @@ import { createIntegration, $imports } from '../index';
describe('createIntegration', () => {
let FakeHTMLIntegration;
let FakePDFIntegration;
let FakeVitalSourceContainerIntegration;
let FakeVitalSourceContentIntegration;
let fakeIsPDF;
let fakeVitalSourceFrameRole;
beforeEach(() => {
FakeHTMLIntegration = sinon.stub();
FakePDFIntegration = sinon.stub();
fakeIsPDF = sinon.stub().returns(false);
fakeVitalSourceFrameRole = sinon.stub().returns(null);
FakeVitalSourceContainerIntegration = sinon.stub();
FakeVitalSourceContentIntegration = sinon.stub();
$imports.$mock({
'./html': { HTMLIntegration: FakeHTMLIntegration },
'./pdf': { PDFIntegration: FakePDFIntegration, isPDF: fakeIsPDF },
'./vitalsource': {
VitalSourceContainerIntegration: FakeVitalSourceContainerIntegration,
VitalSourceContentIntegration: FakeVitalSourceContentIntegration,
vitalSourceFrameRole: fakeVitalSourceFrameRole,
},
});
});
......@@ -30,6 +42,26 @@ describe('createIntegration', () => {
assert.instanceOf(integration, FakePDFIntegration);
});
it('creates VitalSource container integration in the VS Bookshelf reader', () => {
const annotator = {};
fakeVitalSourceFrameRole.returns('container');
const integration = createIntegration(annotator);
assert.calledWith(FakeVitalSourceContainerIntegration, annotator);
assert.instanceOf(integration, FakeVitalSourceContainerIntegration);
});
it('creates VitalSource content integration in the VS Bookshelf reader', () => {
const annotator = {};
fakeVitalSourceFrameRole.returns('content');
const integration = createIntegration(annotator);
assert.calledWith(FakeVitalSourceContentIntegration);
assert.instanceOf(integration, FakeVitalSourceContentIntegration);
});
it('creates HTML integration in web pages', () => {
const annotator = {};
......
import { delay } from '../../../test-util/wait';
import {
VitalSourceContainerIntegration,
VitalSourceContentIntegration,
vitalSourceFrameRole,
$imports,
} from '../vitalsource';
class FakeVitalSourceViewer {
constructor() {
this.bookElement = document.createElement('mosaic-book');
this.bookElement.attachShadow({ mode: 'open' });
this.contentFrame = document.createElement('iframe');
this.bookElement.shadowRoot.append(this.contentFrame);
document.body.append(this.bookElement);
}
destroy() {
this.bookElement.remove();
}
/** Simulate navigation to a different chapter of the book. */
loadNextChapter() {
this.contentFrame.remove();
// VS handles navigations by removing the frame and creating a new one,
// rather than navigating the existing frame.
this.contentFrame = document.createElement('iframe');
this.bookElement.shadowRoot.append(this.contentFrame);
}
}
describe('annotator/integrations/vitalsource', () => {
let fakeViewer;
let FakeHTMLIntegration;
let fakeHTMLIntegration;
beforeEach(() => {
fakeViewer = new FakeVitalSourceViewer();
fakeHTMLIntegration = {
anchor: sinon.stub(),
contentContainer: sinon.stub(),
describe: sinon.stub(),
destroy: sinon.stub(),
scrollToAnchor: sinon.stub(),
};
FakeHTMLIntegration = sinon.stub().returns(fakeHTMLIntegration);
$imports.$mock({
'./html': { HTMLIntegration: FakeHTMLIntegration },
});
});
afterEach(() => {
fakeViewer.destroy();
$imports.$restore();
});
describe('vitalSourceFrameRole', () => {
it('returns "container" if book container element is found', () => {
assert.equal(vitalSourceFrameRole(), 'container');
});
it('returns "content" if the book container element is found in the parent document', () => {
assert.equal(
vitalSourceFrameRole(fakeViewer.contentFrame.contentWindow),
'content'
);
});
it('returns `null` if the book container element is not found', () => {
fakeViewer.destroy();
assert.isNull(vitalSourceFrameRole());
});
});
describe('VitalSourceContainerIntegration', () => {
let fakeGuest;
let integration;
beforeEach(() => {
fakeGuest = {
injectClient: sinon.stub(),
};
integration = new VitalSourceContainerIntegration(fakeGuest);
});
afterEach(() => {
integration.destroy();
});
it('throws if constructed outside the VitalSource book reader', () => {
fakeViewer.destroy();
assert.throws(() => {
new VitalSourceContainerIntegration(fakeGuest);
}, 'Book container element not found');
});
it('injects client into content frame', () => {
assert.calledWith(fakeGuest.injectClient, fakeViewer.contentFrame);
});
it('re-injects client when content frame is changed', async () => {
fakeGuest.injectClient.resetHistory();
fakeViewer.loadNextChapter();
await delay(0);
assert.calledWith(fakeGuest.injectClient, fakeViewer.contentFrame);
});
it('does not allow annotation in the container frame', async () => {
assert.equal(integration.canAnnotate(), false);
// Briefly check the results of the stub methods.
assert.instanceOf(await integration.anchor(), Range);
assert.throws(() => integration.describe());
assert.equal(integration.fitSideBySide(), false);
assert.deepEqual(await integration.getMetadata(), {
title: '',
link: [],
});
assert.equal(await integration.uri(), document.location.href);
assert.equal(integration.contentContainer(), document.body);
await integration.scrollToAnchor({}); // nb. No assert, this does nothing.
});
});
describe('VitalSourceContentIntegration', () => {
let integration;
beforeEach(() => {
integration = new VitalSourceContentIntegration();
});
afterEach(() => {
integration.destroy();
});
it('allows annotation', () => {
assert.equal(integration.canAnnotate(), true);
});
it('does not support side-by-side mode', () => {
assert.equal(integration.fitSideBySide(), false);
});
it('stops mouse events from propagating to parent frame', () => {
const events = ['mousedown', 'mouseup', 'mouseout'];
for (let eventName of events) {
const listener = sinon.stub();
document.addEventListener(eventName, listener);
const event = new Event(eventName, { bubbles: true });
document.body.dispatchEvent(event);
assert.notCalled(listener);
document.removeEventListener(eventName, listener);
}
});
it('delegates to HTML integration for anchoring', async () => {
await integration.contentContainer();
assert.calledWith(fakeHTMLIntegration.contentContainer);
const range = new Range();
await integration.describe(range);
assert.calledWith(fakeHTMLIntegration.describe, range);
const selectors = [{ type: 'TextQuoteSelector', exact: 'foobar' }];
await integration.anchor(selectors);
assert.calledWith(fakeHTMLIntegration.anchor, selectors);
const anchor = {};
await integration.scrollToAnchor(anchor);
assert.calledWith(fakeHTMLIntegration.scrollToAnchor, anchor);
});
describe('#getMetadata', () => {
it('returns book metadata', async () => {
const metadata = await integration.getMetadata();
assert.equal(metadata.title, document.title);
assert.deepEqual(metadata.link, []);
});
});
describe('#uri', () => {
beforeEach(() => {
const bookURI =
'/books/abc/epub/OPS/xhtml/chapter_001.html?ignoreme#cfi=/foo/bar';
history.pushState({}, '', bookURI);
});
afterEach(() => {
history.back();
});
it('returns book URL excluding query string', async () => {
const uri = await integration.uri();
const parsedURL = new URL(uri);
assert.equal(parsedURL.hostname, document.location.hostname);
assert.equal(
parsedURL.pathname,
'/books/abc/epub/OPS/xhtml/chapter_001.html'
);
assert.equal(parsedURL.search, '');
});
});
});
});
import { ListenerCollection } from '../../shared/listener-collection';
import { HTMLIntegration } from './html';
/**
* @typedef {import('../../types/annotator').Anchor} Anchor
* @typedef {import('../../types/annotator').Annotator} Annotator
* @typedef {import('../../types/annotator').Integration} Integration
* @typedef {import('../../types/annotator').Selector} Selector
*/
/**
* Return the custom DOM element that contains the book content iframe.
*/
function findBookElement(document_ = document) {
return document_.querySelector('mosaic-book');
}
/**
* Return the role of the current frame in the VitalSource Bookshelf reader or
* `null` if the frame is not part of Bookshelf.
*
* @return {'container'|'content'|null} - `container` if this is the parent of
* the content frame, `content` if this is the frame that contains the book
* content or `null` if the document is not part of the Bookshelf reader.
*/
export function vitalSourceFrameRole(window_ = window) {
if (findBookElement(window_.document)) {
return 'container';
}
const parentDoc = window_.frameElement?.ownerDocument;
if (parentDoc && findBookElement(parentDoc)) {
return 'content';
}
return null;
}
/**
* Integration for the container frame in VitalSource's Bookshelf ebook reader.
*
* This frame cannot be annotated directly. This integration serves only to
* load the client into the frame that contains the book content.
*
* @implements {Integration}
*/
export class VitalSourceContainerIntegration {
/**
* @param {Annotator} annotator
*/
constructor(annotator) {
const bookElement = findBookElement();
if (!bookElement) {
throw new Error('Book container element not found');
}
const shadowRoot = /** @type {ShadowRoot} */ (bookElement.shadowRoot);
const injectClientIntoContentFrame = () => {
const frame = shadowRoot.querySelector('iframe');
if (frame) {
annotator.injectClient(frame);
}
};
this._frameObserver = new MutationObserver(injectClientIntoContentFrame);
this._frameObserver.observe(shadowRoot, { childList: true, subtree: true });
injectClientIntoContentFrame();
}
destroy() {
this._frameObserver.disconnect();
}
canAnnotate() {
// No part of the container frame can be annotated.
return false;
}
// The methods below are all stubs. Creating annotations is not supported
// in the container frame.
async anchor() {
return new Range();
}
/** @return {Selector[]} */
describe() {
throw new Error('This frame cannot be annotated');
}
contentContainer() {
return document.body;
}
fitSideBySide() {
return false;
}
async getMetadata() {
return { title: '', link: [] };
}
async uri() {
return document.location.href;
}
async scrollToAnchor() {}
}
/**
* Integration for the content frame in VitalSource's Bookshelf ebook reader.
*
* This integration delegates to the standard HTML integration for most
* functionality, but it adds logic to:
*
* - Customize the document URI and metadata that is associated with annotations
* - Prevent VitalSource's built-in selection menu from getting in the way
* of the adder.
*
* @implements {Integration}
*/
export class VitalSourceContentIntegration {
/**
* @param {HTMLElement} container
*/
constructor(container = document.body) {
this._htmlIntegration = new HTMLIntegration(container);
this._listeners = new ListenerCollection();
// Prevent mouse events from reaching the window. This prevents VitalSource
// from showing its native selection menu, which obscures the client's
// annotation toolbar.
//
// VitalSource only checks the selection on the `mouseup` and `mouseout` events,
// but we also need to stop `mousedown` to prevent the client's `SelectionObserver`
// from thinking that the mouse is held down when a selection change occurs.
// This has the unwanted side effect of allowing the adder to appear while
// dragging the mouse.
const stopEvents = ['mousedown', 'mouseup', 'mouseout'];
for (let event of stopEvents) {
this._listeners.add(document.documentElement, event, e => {
e.stopPropagation();
});
}
}
canAnnotate() {
return true;
}
destroy() {
this._listeners.removeAll();
this._htmlIntegration.destroy();
}
/**
* @param {HTMLElement} root
* @param {Selector[]} selectors
*/
anchor(root, selectors) {
return this._htmlIntegration.anchor(root, selectors);
}
/**
* @param {HTMLElement} root
* @param {Range} range
*/
describe(root, range) {
return this._htmlIntegration.describe(root, range);
}
contentContainer() {
return this._htmlIntegration.contentContainer();
}
fitSideBySide() {
// Not yet implemented
return false;
}
async getMetadata() {
// Return minimal metadata which includes only the information we really
// want to include.
return {
title: document.title,
link: [],
};
}
async uri() {
// An example of a typical URL for the chapter content in the Bookshelf reader is:
//
// https://jigsaw.vitalsource.com/books/9781848317703/epub/OPS/xhtml/chapter_001.html#cfi=/6/10%5B;vnd.vst.idref=chap001%5D!/4
//
// Where "9781848317703" is the VitalSource book ID ("vbid"), "chapter_001.html"
// is the location of the HTML page for the current chapter within the book
// and the `#cfi` fragment identifies the scroll location.
//
// Note that this URL is typically different than what is displayed in the
// iframe's `src` attribute.
// Strip off search parameters and fragments.
const uri = new URL(document.location.href);
uri.search = '';
return uri.toString();
}
/**
* @param {Anchor} anchor
*/
async scrollToAnchor(anchor) {
return this._htmlIntegration.scrollToAnchor(anchor);
}
}
......@@ -70,6 +70,7 @@
* @typedef Annotator
* @prop {Anchor[]} anchors
* @prop {(ann: AnnotationData) => Promise<Anchor[]>} anchor
* @prop {(frame: HTMLIFrameElement) => void} injectClient
*/
/**
......
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