Commit bcd65da1 authored by Robert Knight's avatar Robert Knight

Replace the "plugin" functionality in the Guest

Replace the "plugin" functionality in the Guest that indirectly
instantiates the `DocumentMeta`, `PDF` and `CrossFrame` classes with
direct instantiation of those classes.

The annotator part of the Hypothesis client is no longer a pluggable
toolbox which can be arbitrarily extended by user-provided plugins.
Removing the "plugins" logic and replacing it with direct construction
of the necessary classes removes a lot of indirection that made the code
harder to follow and also makes it easier for TypeScript to statically
check the code. It will also enable the `config` object that is passed
to `Guest` to be paired down to just the fields that are actually needed
and properly typed.

In future we will need some kind of plugin/integration facility to
support different document viewers. For we will build a more specific
document type integration facility when the time comes.

 - Remove `this.plugins` field from `Guest` class and `pluginClasses`
   config and replace with direct construction of `PDFIntegration`,
   `DocumentMeta` and `CrossFrame` classes

 - Update the `AnchoringImpl` typedef to correctly reflect that some anchoring
   implementations (HTML) have a synchronous `describe` method

 - Remove unnecessary `config` argument to `PDF` constructor

 - Replace the `PDF: {}` content in the guest config with `documentType:
   'pdf'` as a more obvious way for the annotator entry point to
   configure which document viewer integration is loaded. In future we
   can generalize this to support different types of viewer integration.
parent 435a9d30
...@@ -33,9 +33,7 @@ export function createSidebarConfig(config) { ...@@ -33,9 +33,7 @@ export function createSidebarConfig(config) {
// nb. We don't currently strip all the annotator-only properties here. // nb. We don't currently strip all the annotator-only properties here.
// That's OK because validation / filtering happens in the sidebar app itself. // That's OK because validation / filtering happens in the sidebar app itself.
// It just results in unnecessary content in the sidebar iframe's URL string. // It just results in unnecessary content in the sidebar iframe's URL string.
['notebookAppUrl', 'sidebarAppUrl', 'pluginClasses'].forEach( ['notebookAppUrl', 'sidebarAppUrl'].forEach(key => delete sidebarConfig[key]);
key => delete sidebarConfig[key]
);
return sidebarConfig; return sidebarConfig;
} }
...@@ -2,6 +2,9 @@ import scrollIntoView from 'scroll-into-view'; ...@@ -2,6 +2,9 @@ import scrollIntoView from 'scroll-into-view';
import Delegator from './delegator'; import Delegator from './delegator';
import { Adder } from './adder'; import { Adder } from './adder';
import CrossFrame from './plugin/cross-frame';
import DocumentMeta from './plugin/document';
import PDFIntegration from './plugin/pdf';
import * as htmlAnchoring from './anchoring/html'; import * as htmlAnchoring from './anchoring/html';
import { TextRange } from './anchoring/text-range'; import { TextRange } from './anchoring/text-range';
...@@ -106,16 +109,11 @@ export default class Guest extends Delegator { ...@@ -106,16 +109,11 @@ export default class Guest extends Delegator {
* @param {HTMLElement} element - * @param {HTMLElement} element -
* The root element in which the `Guest` instance should be able to anchor * The root element in which the `Guest` instance should be able to anchor
* or create annotations. In an ordinary web page this typically `document.body`. * or create annotations. In an ordinary web page this typically `document.body`.
* @param {Record<string, any>} config * @param {Record<string, any>} [config]
* @param {typeof htmlAnchoring} anchoring - Anchoring implementation * @param {typeof htmlAnchoring} [anchoring] - Anchoring implementation
*/ */
constructor(element, config, anchoring = htmlAnchoring) { constructor(element, config = {}, anchoring = htmlAnchoring) {
const defaultConfig = { super(element, config);
// This causes the `Document` plugin to be initialized.
Document: {},
};
super(element, { ...defaultConfig, ...config });
this.visibleHighlights = false; this.visibleHighlights = false;
...@@ -146,7 +144,14 @@ export default class Guest extends Delegator { ...@@ -146,7 +144,14 @@ export default class Guest extends Delegator {
} }
}); });
this.plugins = {}; // Set the initial anchoring module. Note that for PDFs this is replaced by
// `PDFIntegration` below.
this.anchoring = anchoring;
this.documentMeta = new DocumentMeta(this.element);
if (config.documentType === 'pdf') {
this.pdfIntegration = new PDFIntegration(this.element, this);
}
/** @type {Anchor[]} */ /** @type {Anchor[]} */
this.anchors = []; this.anchors = [];
...@@ -155,8 +160,6 @@ export default class Guest extends Delegator { ...@@ -155,8 +160,6 @@ export default class Guest extends Delegator {
// The "top" guest instance will have this as null since it's in a top frame not a sub frame // The "top" guest instance will have this as null since it's in a top frame not a sub frame
this.frameIdentifier = config.subFrameIdentifier || null; this.frameIdentifier = config.subFrameIdentifier || null;
this.anchoring = anchoring;
const cfOptions = { const cfOptions = {
config, config,
on: (event, handler) => { on: (event, handler) => {
...@@ -167,8 +170,7 @@ export default class Guest extends Delegator { ...@@ -167,8 +170,7 @@ export default class Guest extends Delegator {
}, },
}; };
this.addPlugin('CrossFrame', cfOptions); this.crossframe = new CrossFrame(this.element, cfOptions);
this.crossframe = this.plugins.CrossFrame;
this.crossframe.onConnect(() => this._setupInitialState(config)); this.crossframe.onConnect(() => this._setupInitialState(config));
// Whether clicks on non-highlighted text should close the sidebar // Whether clicks on non-highlighted text should close the sidebar
...@@ -176,14 +178,6 @@ export default class Guest extends Delegator { ...@@ -176,14 +178,6 @@ export default class Guest extends Delegator {
this._connectAnnotationSync(); this._connectAnnotationSync();
this._connectAnnotationUISync(this.crossframe); this._connectAnnotationUISync(this.crossframe);
// Load plugins
for (let name of Object.keys(this.options)) {
const opts = this.options[name];
if (!this.plugins[name] && this.options.pluginClasses[name]) {
this.addPlugin(name, opts);
}
}
// Setup event handlers on the root element // Setup event handlers on the root element
this._elementEventListeners = []; this._elementEventListeners = [];
this._setupElementEvents(); this._setupElementEvents();
...@@ -247,26 +241,19 @@ export default class Guest extends Delegator { ...@@ -247,26 +241,19 @@ export default class Guest extends Delegator {
}); });
} }
addPlugin(name, options) {
const Klass = this.options.pluginClasses[name];
this.plugins[name] = new Klass(this.element, options, this);
}
/** /**
* Retrieve metadata for the current document. * Retrieve metadata for the current document.
*/ */
getDocumentInfo() { getDocumentInfo() {
let metadataPromise; let metadataPromise;
let uriPromise; let uriPromise;
if (this.plugins.PDF) {
metadataPromise = Promise.resolve(this.plugins.PDF.getMetadata()); if (this.pdfIntegration) {
uriPromise = Promise.resolve(this.plugins.PDF.uri()); metadataPromise = this.pdfIntegration.getMetadata();
} else if (this.plugins.Document) { uriPromise = this.pdfIntegration.uri();
uriPromise = Promise.resolve(this.plugins.Document.uri());
metadataPromise = Promise.resolve(this.plugins.Document.metadata);
} else { } else {
uriPromise = Promise.reject(); uriPromise = Promise.resolve(this.documentMeta.uri());
metadataPromise = Promise.reject(); metadataPromise = Promise.resolve(this.documentMeta.metadata);
} }
uriPromise = uriPromise.catch(() => uriPromise = uriPromise.catch(() =>
...@@ -355,9 +342,8 @@ export default class Guest extends Delegator { ...@@ -355,9 +342,8 @@ export default class Guest extends Delegator {
removeAllHighlights(this.element); removeAllHighlights(this.element);
for (let name of Object.keys(this.plugins)) { this.documentMeta.destroy();
this.plugins[name].destroy(); this.pdfIntegration?.destroy();
}
super.destroy(); super.destroy();
} }
...@@ -478,8 +464,8 @@ export default class Guest extends Delegator { ...@@ -478,8 +464,8 @@ export default class Guest extends Delegator {
// Add the anchors for this annotation to instance storage. // Add the anchors for this annotation to instance storage.
this._updateAnchors(this.anchors.concat(anchors)); this._updateAnchors(this.anchors.concat(anchors));
// Let plugins know about the new information. // Let the sidebar know about the new annotation.
this.plugins.CrossFrame?.sync([annotation]); this.crossframe.sync([annotation]);
return anchors; return anchors;
}; };
......
...@@ -18,23 +18,11 @@ import iconSet from './icons'; ...@@ -18,23 +18,11 @@ import iconSet from './icons';
registerIcons(iconSet); registerIcons(iconSet);
import configFrom from './config/index'; import configFrom from './config/index';
import CrossFramePlugin from './plugin/cross-frame';
import DocumentPlugin from './plugin/document';
import Guest from './guest'; import Guest from './guest';
import Notebook from './notebook'; import Notebook from './notebook';
import PDFPlugin from './plugin/pdf';
import PdfSidebar from './pdf-sidebar'; import PdfSidebar from './pdf-sidebar';
import Sidebar from './sidebar'; import Sidebar from './sidebar';
const pluginClasses = {
// Document type plugins
PDF: PDFPlugin,
Document: DocumentPlugin,
// Cross-frame communication
CrossFrame: CrossFramePlugin,
};
const window_ = /** @type {HypothesisWindow} */ (window); const window_ = /** @type {HypothesisWindow} */ (window);
// Look up the URL of the sidebar. This element is added to the page by the // Look up the URL of the sidebar. This element is added to the page by the
...@@ -60,12 +48,8 @@ function init() { ...@@ -60,12 +48,8 @@ function init() {
window_.__hypothesis_frame = true; window_.__hypothesis_frame = true;
} }
config.pluginClasses = pluginClasses;
// Load the PDF anchoring/metadata integration. // Load the PDF anchoring/metadata integration.
if (isPDF) { config.documentType = isPDF ? 'pdf' : 'html';
config.PDF = {};
}
const guest = new Guest(document.body, config); const guest = new Guest(document.body, config);
const sidebar = SidebarClass const sidebar = SidebarClass
......
...@@ -63,7 +63,7 @@ function createMetadata() { ...@@ -63,7 +63,7 @@ function createMetadata() {
* populates the `document` property of new annotations. * populates the `document` property of new annotations.
*/ */
export default class DocumentMeta extends Delegator { export default class DocumentMeta extends Delegator {
constructor(element, options) { constructor(element, options = {}) {
super(element, options); super(element, options);
this.metadata = createMetadata(); this.metadata = createMetadata();
......
...@@ -46,8 +46,8 @@ export default class PDF extends Delegator { ...@@ -46,8 +46,8 @@ export default class PDF extends Delegator {
/** /**
* @param {Annotator} annotator * @param {Annotator} annotator
*/ */
constructor(element, config, annotator) { constructor(element, annotator) {
super(element, config); super(element);
this.annotator = annotator; this.annotator = annotator;
annotator.anchoring = pdfAnchoring; annotator.anchoring = pdfAnchoring;
......
...@@ -23,7 +23,7 @@ describe('annotator/plugin/pdf', () => { ...@@ -23,7 +23,7 @@ describe('annotator/plugin/pdf', () => {
let pdfPlugin; let pdfPlugin;
function createPDFPlugin() { function createPDFPlugin() {
return new PDF(document.body, {}, fakeAnnotator); return new PDF(document.body, fakeAnnotator);
} }
beforeEach(() => { beforeEach(() => {
......
...@@ -15,15 +15,6 @@ class FakeAdder { ...@@ -15,15 +15,6 @@ class FakeAdder {
} }
FakeAdder.instance = null; FakeAdder.instance = null;
class FakePlugin extends Delegator {
constructor(element, config, annotator) {
super(element, config);
this.annotator = annotator;
FakePlugin.instance = this;
}
}
FakePlugin.instance = null;
class FakeTextRange { class FakeTextRange {
constructor(range) { constructor(range) {
this.range = range; this.range = range;
...@@ -59,7 +50,7 @@ describe('Guest', () => { ...@@ -59,7 +50,7 @@ describe('Guest', () => {
beforeEach(() => { beforeEach(() => {
FakeAdder.instance = null; FakeAdder.instance = null;
guestConfig = { pluginClasses: {} }; guestConfig = {};
highlighter = { highlighter = {
highlightRange: sinon.stub().returns([]), highlightRange: sinon.stub().returns([]),
removeHighlights: sinon.stub(), removeHighlights: sinon.stub(),
...@@ -93,7 +84,6 @@ describe('Guest', () => { ...@@ -93,7 +84,6 @@ describe('Guest', () => {
} }
CrossFrame = sandbox.stub().returns(fakeCrossFrame); CrossFrame = sandbox.stub().returns(fakeCrossFrame);
guestConfig.pluginClasses.CrossFrame = CrossFrame;
$imports.$mock({ $imports.$mock({
'./adder': { Adder: FakeAdder }, './adder': { Adder: FakeAdder },
...@@ -103,6 +93,7 @@ describe('Guest', () => { ...@@ -103,6 +93,7 @@ describe('Guest', () => {
}, },
'./highlighter': highlighter, './highlighter': highlighter,
'./range-util': rangeUtil, './range-util': rangeUtil,
'./plugin/cross-frame': CrossFrame,
'./selection-observer': { './selection-observer': {
SelectionObserver: FakeSelectionObserver, SelectionObserver: FakeSelectionObserver,
}, },
...@@ -116,28 +107,6 @@ describe('Guest', () => { ...@@ -116,28 +107,6 @@ describe('Guest', () => {
$imports.$restore(); $imports.$restore();
}); });
describe('plugins', () => {
let fakePlugin;
let guest;
beforeEach(() => {
FakePlugin.instance = null;
guestConfig.pluginClasses.FakePlugin = FakePlugin;
guest = createGuest({ FakePlugin: {} });
fakePlugin = FakePlugin.instance;
});
it('passes guest reference to constructor', () => {
assert.equal(fakePlugin.annotator, guest);
});
it('calls `destroy` method of plugins when guest is destroyed', () => {
sandbox.spy(fakePlugin, 'destroy');
guest.destroy();
assert.called(fakePlugin.destroy);
});
});
describe('cross frame', () => { describe('cross frame', () => {
it('provides an event bus for the annotation sync module', () => { it('provides an event bus for the annotation sync module', () => {
createGuest(); createGuest();
...@@ -361,9 +330,9 @@ describe('Guest', () => { ...@@ -361,9 +330,9 @@ describe('Guest', () => {
beforeEach(() => { beforeEach(() => {
document.title = 'hi'; document.title = 'hi';
guest = createGuest(); guest = createGuest();
guest.plugins.PDF = { guest.pdfIntegration = {
uri: sandbox.stub().returns(window.location.href), uri: sinon.stub().resolves(window.location.href),
getMetadata: sandbox.stub(), getMetadata: sinon.stub().resolves({}),
}; };
}); });
...@@ -384,7 +353,7 @@ describe('Guest', () => { ...@@ -384,7 +353,7 @@ describe('Guest', () => {
}; };
const promise = Promise.resolve(metadata); const promise = Promise.resolve(metadata);
guest.plugins.PDF.getMetadata.returns(promise); guest.pdfIntegration.getMetadata.returns(promise);
emitGuestEvent('getDocumentInfo', assertComplete); emitGuestEvent('getDocumentInfo', assertComplete);
}); });
...@@ -405,7 +374,7 @@ describe('Guest', () => { ...@@ -405,7 +374,7 @@ describe('Guest', () => {
}; };
const promise = Promise.reject(new Error('Not a PDF document')); const promise = Promise.reject(new Error('Not a PDF document'));
guest.plugins.PDF.getMetadata.returns(promise); guest.pdfIntegration.getMetadata.returns(promise);
emitGuestEvent('getDocumentInfo', assertComplete); emitGuestEvent('getDocumentInfo', assertComplete);
}); });
...@@ -450,7 +419,7 @@ describe('Guest', () => { ...@@ -450,7 +419,7 @@ describe('Guest', () => {
it('hides sidebar when the user taps or clicks in the page', () => { it('hides sidebar when the user taps or clicks in the page', () => {
for (let event of ['click', 'touchstart']) { for (let event of ['click', 'touchstart']) {
rootElement.dispatchEvent(new Event(event)); rootElement.dispatchEvent(new Event(event));
assert.calledWith(guest.plugins.CrossFrame.call, 'closeSidebar'); assert.calledWith(guest.crossframe.call, 'closeSidebar');
} }
}); });
...@@ -458,7 +427,7 @@ describe('Guest', () => { ...@@ -458,7 +427,7 @@ describe('Guest', () => {
for (let event of ['click', 'touchstart']) { for (let event of ['click', 'touchstart']) {
guest.closeSidebarOnDocumentClick = false; guest.closeSidebarOnDocumentClick = false;
rootElement.dispatchEvent(new Event(event)); rootElement.dispatchEvent(new Event(event));
assert.notCalled(guest.plugins.CrossFrame.call); assert.notCalled(guest.crossframe.call);
} }
}); });
...@@ -469,7 +438,7 @@ describe('Guest', () => { ...@@ -469,7 +438,7 @@ describe('Guest', () => {
for (let event of ['click', 'touchstart']) { for (let event of ['click', 'touchstart']) {
fakeSidebarFrame.dispatchEvent(new Event(event, { bubbles: true })); fakeSidebarFrame.dispatchEvent(new Event(event, { bubbles: true }));
assert.notCalled(guest.plugins.CrossFrame.call); assert.notCalled(guest.crossframe.call);
} }
}); });
}); });
...@@ -574,17 +543,15 @@ describe('Guest', () => { ...@@ -574,17 +543,15 @@ describe('Guest', () => {
beforeEach(() => { beforeEach(() => {
guest = createGuest(); guest = createGuest();
guest.plugins.PDF = { guest.pdfIntegration = {
uri() { uri: sandbox.stub().resolves('urn:x-pdf:001122'),
return 'urn:x-pdf:001122'; getMetadata: sandbox.stub().resolves({}),
},
getMetadata: sandbox.stub(),
}; };
}); });
it('preserves the components of the URI other than the fragment', () => { it('preserves the components of the URI other than the fragment', () => {
guest.plugins.PDF = null; guest.pdfIntegration = null;
guest.plugins.Document = { guest.documentMeta = {
uri() { uri() {
return 'http://foobar.com/things?id=42'; return 'http://foobar.com/things?id=42';
}, },
...@@ -596,7 +563,7 @@ describe('Guest', () => { ...@@ -596,7 +563,7 @@ describe('Guest', () => {
}); });
it('removes the fragment identifier from URIs', () => { it('removes the fragment identifier from URIs', () => {
guest.plugins.PDF.uri = () => 'urn:x-pdf:aabbcc#ignoreme'; guest.pdfIntegration.uri.resolves('urn:x-pdf:aabbcc#ignoreme');
return guest return guest
.getDocumentInfo() .getDocumentInfo()
.then(({ uri }) => assert.equal(uri, 'urn:x-pdf:aabbcc')); .then(({ uri }) => assert.equal(uri, 'urn:x-pdf:aabbcc'));
...@@ -605,13 +572,18 @@ describe('Guest', () => { ...@@ -605,13 +572,18 @@ describe('Guest', () => {
describe('#createAnnotation', () => { describe('#createAnnotation', () => {
it('adds metadata to the annotation object', () => { it('adds metadata to the annotation object', () => {
class FakeDocumentMeta {
get metadata() {
return { title: 'hello' };
}
uri() {
return 'http://example.com';
}
}
$imports.$mock({ './plugin/document': FakeDocumentMeta });
const guest = createGuest(); const guest = createGuest();
sinon.stub(guest, 'getDocumentInfo').returns(
Promise.resolve({
metadata: { title: 'hello' },
uri: 'http://example.com/',
})
);
const annotation = {}; const annotation = {};
guest.createAnnotation(annotation); guest.createAnnotation(annotation);
...@@ -769,12 +741,12 @@ describe('Guest', () => { ...@@ -769,12 +741,12 @@ describe('Guest', () => {
.then(() => assert.notCalled(htmlAnchoring.anchor)); .then(() => assert.notCalled(htmlAnchoring.anchor));
}); });
it('updates the cross frame plugin', () => { it('syncs annotations to the sidebar', () => {
const guest = createGuest(); const guest = createGuest();
guest.plugins.CrossFrame = { sync: sinon.stub() }; guest.crossframe = { sync: sinon.stub() };
const annotation = {}; const annotation = {};
return guest.anchor(annotation).then(() => { return guest.anchor(annotation).then(() => {
assert.called(guest.plugins.CrossFrame.sync); assert.called(guest.crossframe.sync);
}); });
}); });
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
// with various combinations of selector are anchored. // with various combinations of selector are anchored.
import Guest from '../../guest'; import Guest from '../../guest';
import { $imports as guestImports } from '../../guest';
function quoteSelector(quote) { function quoteSelector(quote) {
return { return {
...@@ -47,22 +48,27 @@ function FakeCrossFrame() { ...@@ -47,22 +48,27 @@ function FakeCrossFrame() {
describe('anchoring', function () { describe('anchoring', function () {
let guest; let guest;
let guestConfig;
let container; let container;
before(function () { before(() => {
guestConfig = { pluginClasses: { CrossFrame: FakeCrossFrame } }; guestImports.$mock({
'./plugin/cross-frame': FakeCrossFrame,
});
});
after(() => {
guestImports.$restore({ './plugins/cross-frame': true });
}); });
beforeEach(function () { beforeEach(() => {
sinon.stub(console, 'warn'); sinon.stub(console, 'warn');
container = document.createElement('div'); container = document.createElement('div');
container.innerHTML = require('./test-page.html'); container.innerHTML = require('./test-page.html');
document.body.appendChild(container); document.body.appendChild(container);
guest = new Guest(container, guestConfig); guest = new Guest(container);
}); });
afterEach(function () { afterEach(() => {
guest.destroy(); guest.destroy();
container.parentNode.removeChild(container); container.parentNode.removeChild(container);
console.warn.restore(); console.warn.restore();
......
...@@ -61,7 +61,7 @@ ...@@ -61,7 +61,7 @@
* *
* @typedef AnchoringImpl * @typedef AnchoringImpl
* @prop {(root: HTMLElement, selectors: Selector[], options: any) => Promise<Range>} anchor * @prop {(root: HTMLElement, selectors: Selector[], options: any) => Promise<Range>} anchor
* @prop {(root: HTMLElement, range: Range, options: any) => Promise<Selector[]>} describe * @prop {(root: HTMLElement, range: Range, options: any) => Selector[]|Promise<Selector[]>} describe
*/ */
/** /**
......
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