Commit 05fea7b5 authored by Eduardo Sanz García's avatar Eduardo Sanz García Committed by Eduardo

Replace inheritance from Delegator

Delegator class was a pub/sub event emitter for communication between
different parts of the application in the host page.

Instead of inherit this functionality, we have replaced by composition.

Some arguments send by this system were unnecessarily wrapped in an
array. We have unwrapped these arguments.
parent f8be43e5
import { TinyEmitter as EventEmitter } from 'tiny-emitter';
// Adapted from:
// https://github.com/openannotation/annotator/blob/v1.2.x/src/class.coffee
//
// Annotator v1.2.10
// https://github.com/openannotation/annotator
//
// Copyright 2015, the Annotator project contributors.
// Dual licensed under the MIT and GPLv3 licenses.
// https://github.com/openannotation/annotator/blob/master/LICENSE
/**
* `Delegator` is a base class for many objects in the annotator.
*
* It provides:
*
* - An event bus, attached to the DOM element passed to the constructor.
* When an event is published, all `Delegator` instances that share the same
* root element will be able to receive the event.
* - Configuration storage (via `this.options`)
* - A mechanism to clean up event listeners and other resources added to
* the page by implementing a `destroy` method, which will be called when
* the annotator is removed from the page.
*/
export default class Delegator {
/**
* Construct the `Delegator` instance.
*
* @param {HTMLElement} element
* @param {Record<string, any>} [config]
*/
constructor(element, config) {
this.options = { ...config };
this.element = element;
const el = /** @type {any} */ (element);
/** @type {EventEmitter} */
let eventBus = el._hypothesisEventBus;
if (!eventBus) {
eventBus = new EventEmitter();
el._hypothesisEventBus = eventBus;
}
this._eventBus = eventBus;
/** @type {[event: string, callback: Function][]} */
this._subscriptions = [];
}
/**
* Clean up event listeners and other resources.
*
* Sub-classes should override this to clean up their resources and then call
* the base implementation.
*/
destroy() {
for (let [event, callback] of this._subscriptions) {
this._eventBus.off(event, callback);
}
}
/**
* Fire an event.
*
* This and other `Delegator` instances which share the same root element will
* be able to observe it.
*
* @param {string} event
* @param {any[]} [args]
*/
publish(event, args = []) {
this._eventBus.emit(event, ...args);
}
/**
* Register an event handler.
*
* @param {string} event
* @param {Function} callback
*/
subscribe(event, callback) {
this._eventBus.on(event, callback);
this._subscriptions.push([event, callback]);
}
/**
* Remove an event handler.
*
* @param {string} event
* @param {Function} callback
*/
unsubscribe(event, callback) {
this._eventBus.off(event, callback);
this._subscriptions = this._subscriptions.filter(
([subEvent, subCallback]) =>
subEvent !== event || subCallback !== callback
);
}
}
import scrollIntoView from 'scroll-into-view';
import Delegator from './delegator';
import { Adder } from './adder';
import CrossFrame from './plugin/cross-frame';
import DocumentMeta from './plugin/document';
......@@ -21,6 +20,7 @@ import { SelectionObserver } from './selection-observer';
import { normalizeURI } from './util/url';
/**
* @typedef {import('./util/emitter').EventBus} EventBus
* @typedef {import('../types/annotator').AnnotationData} AnnotationData
* @typedef {import('../types/annotator').Anchor} Anchor
* @typedef {import('../types/api').Target} Target
......@@ -102,21 +102,20 @@ function resolveAnchor(anchor) {
* The anchoring implementation defaults to a generic one for HTML documents and
* can be overridden to handle different document types.
*/
export default class Guest extends Delegator {
export default class Guest {
/**
* Initialize the Guest.
*
* @param {HTMLElement} element -
* 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`.
* @param {EventBus} eventBus -
* Enables communication between components sharing the same eventBus
* @param {Record<string, any>} [config]
* @param {typeof htmlAnchoring} [anchoring] - Anchoring implementation
*/
constructor(element, config = {}, anchoring = htmlAnchoring) {
super(element, config);
constructor(element, eventBus, config = {}, anchoring = htmlAnchoring) {
this.element = element;
this._emitter = eventBus.createEmitter();
this.visibleHighlights = false;
this.adderToolbar = document.createElement('hypothesis-adder');
this.adderToolbar.style.display = 'none';
this.element.appendChild(this.adderToolbar);
......@@ -156,9 +155,9 @@ export default class Guest extends Delegator {
// nb. The `anchoring` field defaults to HTML anchoring and in PDFs is replaced
// by `PDFIntegration` below.
this.anchoring = anchoring;
this.documentMeta = new DocumentMeta(this.element);
this.documentMeta = new DocumentMeta();
if (config.documentType === 'pdf') {
this.pdfIntegration = new PDFIntegration(this.element, this);
this.pdfIntegration = new PDFIntegration(this);
}
// Set the frame identifier if it's available.
......@@ -167,8 +166,8 @@ export default class Guest extends Delegator {
this.crossframe = new CrossFrame(this.element, {
config,
on: (event, handler) => this.subscribe(event, handler),
emit: (event, ...args) => this.publish(event, args),
on: (event, handler) => this._emitter.subscribe(event, handler),
emit: (event, ...args) => this._emitter.publish(event, ...args),
});
this.crossframe.onConnect(() => this._setupInitialState(config));
......@@ -199,7 +198,7 @@ export default class Guest extends Delegator {
// the sidebar doesn't overlap the content.
return;
}
this.crossframe?.call('closeSidebar');
this.crossframe.call('closeSidebar');
};
addListener('click', event => {
......@@ -275,16 +274,16 @@ export default class Guest extends Delegator {
}
_setupInitialState(config) {
this.publish('panelReady');
this._emitter.publish('panelReady');
this.setVisibleHighlights(config.showHighlights === 'always');
}
_connectAnnotationSync() {
this.subscribe('annotationDeleted', annotation => {
this._emitter.subscribe('annotationDeleted', annotation => {
this.detach(annotation);
});
this.subscribe('annotationsLoaded', annotations => {
this._emitter.subscribe('annotationsLoaded', annotations => {
annotations.map(annotation => this.anchor(annotation));
});
}
......@@ -341,10 +340,8 @@ export default class Guest extends Delegator {
removeAllHighlights(this.element);
this.documentMeta.destroy();
this.pdfIntegration?.destroy();
super.destroy();
this._emitter.destroy();
}
/**
......@@ -525,7 +522,7 @@ export default class Guest extends Delegator {
_updateAnchors(anchors) {
this.anchors = anchors;
this.publish('anchorsChanged', [this.anchors]);
this._emitter.publish('anchorsChanged', this.anchors);
}
/**
......@@ -566,11 +563,11 @@ export default class Guest extends Delegator {
$tag: '',
};
this.publish('beforeAnnotationCreated', [annotation]);
this._emitter.publish('beforeAnnotationCreated', annotation);
this.anchor(annotation);
if (!annotation.$highlight) {
this.crossframe?.call('openSidebar');
this.crossframe.call('openSidebar');
}
return annotation;
......@@ -584,8 +581,8 @@ export default class Guest extends Delegator {
*/
showAnnotations(annotations) {
const tags = annotations.map(a => a.$tag);
this.crossframe?.call('showAnnotations', tags);
this.crossframe?.call('openSidebar');
this.crossframe.call('showAnnotations', tags);
this.crossframe.call('openSidebar');
}
/**
......@@ -596,7 +593,7 @@ export default class Guest extends Delegator {
*/
toggleAnnotationSelection(annotations) {
const tags = annotations.map(a => a.$tag);
this.crossframe?.call('toggleAnnotationSelection', tags);
this.crossframe.call('toggleAnnotationSelection', tags);
}
/**
......@@ -607,7 +604,7 @@ export default class Guest extends Delegator {
*/
focusAnnotations(annotations) {
const tags = annotations.map(a => a.$tag);
this.crossframe?.call('focusAnnotations', tags);
this.crossframe.call('focusAnnotations', tags);
}
/**
......@@ -626,7 +623,7 @@ export default class Guest extends Delegator {
}
this.selectedRanges = [range];
this.publish('hasSelectionChanged', [true]);
this._emitter.publish('hasSelectionChanged', true);
this.adderCtrl.annotationsForSelection = annotationsForSelection();
this.adderCtrl.show(focusRect, isBackwards);
......@@ -635,7 +632,7 @@ export default class Guest extends Delegator {
_onClearSelection() {
this.adderCtrl.hide();
this.selectedRanges = [];
this.publish('hasSelectionChanged', [false]);
this._emitter.publish('hasSelectionChanged', false);
}
/**
......@@ -672,6 +669,6 @@ export default class Guest extends Delegator {
setVisibleHighlights(shouldShowHighlights) {
setHighlightsVisible(this.element, shouldShowHighlights);
this.visibleHighlights = shouldShowHighlights;
this.publish('highlightsVisibleChanged', [shouldShowHighlights]);
this._emitter.publish('highlightsVisibleChanged', shouldShowHighlights);
}
}
......@@ -22,6 +22,7 @@ import Guest from './guest';
import Notebook from './notebook';
import PdfSidebar from './pdf-sidebar';
import Sidebar from './sidebar';
import { EventBus } from './util/emitter';
const window_ = /** @type {HypothesisWindow} */ (window);
......@@ -36,7 +37,7 @@ const config = configFrom(window);
function init() {
const isPDF = typeof window_.PDFViewerApplication !== 'undefined';
/** @type {(new (e: HTMLElement, guest: Guest, config: Record<string,any>) => Sidebar)|null} */
/** @type {typeof Sidebar|null} */
let SidebarClass = isPDF ? PdfSidebar : Sidebar;
if (config.subFrameIdentifier) {
......@@ -51,11 +52,12 @@ function init() {
// Load the PDF anchoring/metadata integration.
config.documentType = isPDF ? 'pdf' : 'html';
const guest = new Guest(document.body, config);
const eventBus = new EventBus();
const guest = new Guest(document.body, eventBus, config);
const sidebar = SidebarClass
? new SidebarClass(document.body, guest, config)
? new SidebarClass(document.body, eventBus, guest, config)
: null;
const notebook = new Notebook(document.body, config);
const notebook = new Notebook(document.body, eventBus, config);
appLinkEl.addEventListener('destroy', () => {
sidebar?.destroy();
......
import Delegator from './delegator';
import { createSidebarConfig } from './config/sidebar';
import { createShadowRoot } from './util/shadow-root';
import { render } from 'preact';
......@@ -31,9 +30,17 @@ function createNotebookFrame(config, groupId) {
return notebookFrame;
}
export default class Notebook extends Delegator {
constructor(element, config) {
super(element, config);
export default class Notebook {
/**
* @param {HTMLElement} element
* @param {import('./util/emitter').EventBus} eventBus -
* Enables communication between components sharing the same eventBus
* @param {Record<string, any>} config
*/
constructor(element, eventBus, config = {}) {
this.element = element;
this._emitter = eventBus.createEmitter();
this.options = config;
this.frame = null;
/** @type {null|string} */
......@@ -57,7 +64,7 @@ export default class Notebook extends Delegator {
*/
this.container = null;
this.subscribe('openNotebook', groupId => {
this._emitter.subscribe('openNotebook', groupId => {
this._groupId = groupId;
this.open();
});
......@@ -103,6 +110,7 @@ export default class Notebook extends Delegator {
destroy() {
this._outerContainer.remove();
this._emitter.destroy();
}
_initContainer() {
......@@ -118,7 +126,7 @@ export default class Notebook extends Delegator {
const onClose = () => {
this.close();
this.publish('closeNotebook');
this._emitter.publish('closeNotebook');
};
render(
......
......@@ -14,13 +14,15 @@ const MIN_PDF_WIDTH = 680;
export default class PdfSidebar extends Sidebar {
/**
* @param {HTMLElement} element
* @param {import('./util/emitter').EventBus} eventBus -
* Enables communication between components sharing the same eventBus
* @param {Guest} guest
* @param {Record<string, any>} [config]
*/
constructor(element, guest, config = {}) {
constructor(element, eventBus, guest, config = {}) {
const contentContainer = document.querySelector('#viewerContainer');
super(element, guest, { contentContainer, ...config });
super(element, eventBus, guest, { contentContainer, ...config });
this._lastSidebarLayoutState = {
expanded: false,
......@@ -35,7 +37,9 @@ export default class PdfSidebar extends Sidebar {
// Is the PDF currently displayed side-by-side with the sidebar?
this.sideBySideActive = false;
this.subscribe('sidebarLayoutChanged', state => this.fitSideBySide(state));
this._emitter.subscribe('sidebarLayoutChanged', state =>
this.fitSideBySide(state)
);
this._listeners.add(window, 'resize', () => this.fitSideBySide());
}
......
import AnnotationSync from '../annotation-sync';
import Bridge from '../../shared/bridge';
import Delegator from '../delegator';
import Discovery from '../../shared/discovery';
import * as frameUtil from '../util/frame-util';
import FrameObserver from '../frame-observer';
......@@ -18,10 +17,16 @@ import FrameObserver from '../frame-observer';
* are added to the page if they have the `enable-annotation` attribute set
* and are same-origin with the current document.
*/
export default class CrossFrame extends Delegator {
export default class CrossFrame {
/**
* @param {Element} element
* @param {object} options
* @param {Record<string, any>} options.config,
* @param {boolean} [options.server]
* @param {(event: string, ...args: any[]) => void} options.on
* @param {(event: string, ...args: any[]) => void } options.emit
*/
constructor(element, options) {
super(element, options);
const { config, server, on, emit } = options;
const discovery = new Discovery(window, { server });
const bridge = new Bridge();
......@@ -69,8 +74,6 @@ export default class CrossFrame extends Delegator {
bridge.destroy();
discovery.stopDiscovery();
frameObserver.disconnect();
super.destroy();
};
/**
......
......@@ -17,8 +17,6 @@
* @typedef {import('../../types/annotator').DocumentMetadata} Metadata
*/
import Delegator from '../delegator';
import { normalizeURI } from '../util/url';
/**
......@@ -62,14 +60,18 @@ function createMetadata() {
* DocumentMeta reads metadata/links from the current HTML document and
* populates the `document` property of new annotations.
*/
export default class DocumentMeta extends Delegator {
constructor(element, options = {}) {
super(element, options);
export default class DocumentMeta {
/**
* @param {object} [options]
* @param {Document} [options.document]
* @param {string} [options.baseURI]
* @param {normalizeURI} [options.normalizeURI]
*/
constructor(options = {}) {
this.metadata = createMetadata();
this.baseURI = options.baseURI || document.baseURI;
this.document = options.document || document;
this.baseURI = options.baseURI || this.document.baseURI;
this.normalizeURI = options.normalizeURI || normalizeURI;
this.getDocumentMetadata();
......
......@@ -3,7 +3,6 @@ import { render } from 'preact';
import * as pdfAnchoring from '../anchoring/pdf';
import WarningBanner from '../components/WarningBanner';
import Delegator from '../delegator';
import RenderingStates from '../pdfjs-rendering-states';
import { createShadowRoot } from '../util/shadow-root';
import { ListenerCollection } from '../util/listener-collection';
......@@ -16,13 +15,11 @@ import PDFMetadata from './pdf-metadata';
* @typedef {import('../../types/annotator').HypothesisWindow} HypothesisWindow
*/
export default class PDF extends Delegator {
export default class PDF {
/**
* @param {Annotator} annotator
*/
constructor(element, annotator) {
super(element);
constructor(annotator) {
this.annotator = annotator;
annotator.anchoring = pdfAnchoring;
......
......@@ -29,20 +29,12 @@ describe('DocumentMeta', function () {
return normalizeURI(url, base);
});
// Root element to use for the `Delegator` event bus. This can be different
// than the document from which metadata is gathered.
const rootElement = document.body;
testDocument = new DocumentMeta(rootElement, {
testDocument = new DocumentMeta({
document: tempDocument,
normalizeURI: fakeNormalizeURI,
});
});
afterEach(() => {
testDocument.destroy();
});
describe('annotation should have some metadata', function () {
let metadata = null;
......@@ -266,8 +258,7 @@ describe('DocumentMeta', function () {
href,
},
};
const divEl = document.createElement('div');
const doc = new DocumentMeta(divEl, {
const doc = new DocumentMeta({
document: fakeDocument,
baseURI,
});
......
......@@ -28,7 +28,7 @@ describe('annotator/plugin/pdf', () => {
let pdfPlugin;
function createPDFPlugin() {
return new PDF(document.body, fakeAnnotator);
return new PDF(fakeAnnotator);
}
beforeEach(() => {
......
......@@ -6,7 +6,6 @@ import { createSidebarConfig } from './config/sidebar';
import events from '../shared/bridge-events';
import features from './features';
import Delegator from './delegator';
import { ToolbarController } from './toolbar';
import { createShadowRoot } from './util/shadow-root';
import BucketBar from './bucket-bar';
......@@ -48,24 +47,24 @@ function createSidebarIframe(config) {
}
/**
* The `Sidebar` class creates the sidebar application iframe and its container,
* as well as the adjacent controls.
* The `Sidebar` class creates (1) the sidebar application iframe, (2) its container,
* as well as (3) the adjacent controls.
*/
export default class Sidebar extends Delegator {
export default class Sidebar {
/**
* Create the sidebar iframe, its container and adjacent controls.
*
* @param {HTMLElement} element
* @param {import('./util/emitter').EventBus} eventBus -
* Enables communication between components sharing the same eventBus
* @param {Guest} guest -
* The `Guest` instance for the current frame. It is currently assumed that
* it is always possible to annotate in the frame where the sidebar is
* displayed.
* @param {Record<string, any>} [config]
*/
constructor(element, guest, config = {}) {
super(element, config);
constructor(element, eventBus, guest, config = {}) {
this._emitter = eventBus.createEmitter();
this.iframe = createSidebarIframe(config);
this.options = config;
/** @type {BucketBar|null} */
this.bucketBar = null;
......@@ -86,7 +85,7 @@ export default class Sidebar extends Delegator {
const bucketBar = new BucketBar(this.iframeContainer, guest, {
contentContainer: config.contentContainer,
});
guest.subscribe('anchorsChanged', () => bucketBar.update());
this._emitter.subscribe('anchorsChanged', () => bucketBar.update());
this.bucketBar = bucketBar;
}
......@@ -104,14 +103,14 @@ export default class Sidebar extends Delegator {
this._listeners = new ListenerCollection();
this.subscribe('panelReady', () => {
this._emitter.subscribe('panelReady', () => {
// Show the UI
if (this.iframeContainer) {
this.iframeContainer.style.display = '';
}
});
this.subscribe('beforeAnnotationCreated', annotation => {
this._emitter.subscribe('beforeAnnotationCreated', annotation => {
// When a new non-highlight annotation is created, focus
// the sidebar so that the text editor can be focused as
// soon as the annotation card appears
......@@ -126,7 +125,7 @@ export default class Sidebar extends Delegator {
config.query ||
config.group
) {
this.subscribe('panelReady', () => this.open());
this._emitter.subscribe('panelReady', () => this.open());
}
// Set up the toolbar on the left edge of the sidebar.
......@@ -143,10 +142,10 @@ export default class Sidebar extends Delegator {
this.toolbar.useMinimalControls = false;
}
this.subscribe('highlightsVisibleChanged', visible => {
this._emitter.subscribe('highlightsVisibleChanged', visible => {
this.toolbar.highlightsVisible = visible;
});
this.subscribe('hasSelectionChanged', hasSelection => {
this._emitter.subscribe('hasSelectionChanged', hasSelection => {
this.toolbar.newAnnotationType = hasSelection ? 'annotation' : 'note';
});
......@@ -198,7 +197,7 @@ export default class Sidebar extends Delegator {
} else {
this.iframe.remove();
}
super.destroy();
this._emitter.destroy();
}
_setupSidebarEvents() {
......@@ -215,9 +214,9 @@ export default class Sidebar extends Delegator {
/** @type {string} */ groupId
) => {
this.hide();
this.publish('openNotebook', [groupId]);
this._emitter.publish('openNotebook', groupId);
});
this.subscribe('closeNotebook', () => {
this._emitter.subscribe('closeNotebook', () => {
this.show();
});
......@@ -336,7 +335,7 @@ export default class Sidebar extends Delegator {
if (this.onLayoutChange) {
this.onLayoutChange(layoutState);
}
this.publish('sidebarLayoutChanged', [layoutState]);
this._emitter.publish('sidebarLayoutChanged', layoutState);
}
/**
......@@ -407,7 +406,7 @@ export default class Sidebar extends Delegator {
open() {
this.guest.crossframe.call('sidebarOpened');
this.publish('sidebarOpened');
this._emitter.publish('sidebarOpened');
if (this.iframeContainer) {
const width = this.iframeContainer.getBoundingClientRect().width;
......
import Delegator from '../delegator';
describe('Delegator', () => {
it('constructor sets `element` and `options` properties', () => {
const el = document.createElement('div');
const config = { foo: 'bar' };
const delegator = new Delegator(el, config);
assert.equal(delegator.element, el);
assert.deepEqual(delegator.options, config);
});
it('supports publishing and subscribing to events', () => {
const element = document.createElement('div');
const delegatorA = new Delegator(element);
const delegatorB = new Delegator(element);
const callback = sinon.stub();
delegatorB.subscribe('someEvent', callback);
delegatorA.publish('someEvent', ['foo', 'bar']);
assert.calledOnce(callback);
assert.calledWith(callback, 'foo', 'bar');
delegatorB.unsubscribe('someEvent', callback);
delegatorA.publish('someEvent', ['foo', 'bar']);
assert.calledOnce(callback);
});
describe('#destroy', () => {
it('removes all event subscriptions created by current instance', () => {
const element = document.createElement('div');
const delegator = new Delegator(element);
const callback = sinon.stub();
delegator.subscribe('someEvent', callback);
delegator.publish('someEvent');
assert.calledOnce(callback);
delegator.destroy();
delegator.publish('someEvent');
assert.calledOnce(callback);
});
});
});
import Delegator from '../delegator';
import Guest from '../guest';
import { EventBus } from '../util/emitter';
import { $imports } from '../guest';
const scrollIntoView = sinon.stub();
......@@ -48,7 +48,8 @@ describe('Guest', () => {
const createGuest = (config = {}) => {
const element = document.createElement('div');
return new Guest(element, { ...guestConfig, ...config });
const eventBus = new EventBus();
return new Guest(element, eventBus, { ...guestConfig, ...config });
};
beforeEach(() => {
......@@ -118,7 +119,6 @@ describe('Guest', () => {
'./selection-observer': {
SelectionObserver: FakeSelectionObserver,
},
'./delegator': Delegator,
'scroll-into-view': scrollIntoView,
});
});
......@@ -139,7 +139,7 @@ describe('Guest', () => {
it('publishes the "panelReady" event when a connection is established', () => {
const handler = sandbox.stub();
const guest = createGuest();
guest.subscribe('panelReady', handler);
guest._emitter.subscribe('panelReady', handler);
fakeCrossFrame.onConnect.yield();
assert.called(handler);
});
......@@ -160,8 +160,8 @@ describe('Guest', () => {
options.on('foo', fooHandler);
options.on('bar', barHandler);
guest.publish('foo', ['1', '2']);
guest.publish('bar', ['1', '2']);
guest._emitter.publish('foo', '1', '2');
guest._emitter.publish('bar', '1', '2');
assert.calledWith(fooHandler, '1', '2');
assert.calledWith(barHandler, '1', '2');
......@@ -199,8 +199,8 @@ describe('Guest', () => {
const fooHandler = sandbox.stub();
const barHandler = sandbox.stub();
guest.subscribe('foo', fooHandler);
guest.subscribe('bar', barHandler);
guest._emitter.subscribe('foo', fooHandler);
guest._emitter.subscribe('bar', barHandler);
options.emit('foo', '1', '2');
options.emit('bar', '1', '2');
......@@ -553,7 +553,7 @@ describe('Guest', () => {
it('emits `hasSelectionChanged` event with argument `true` if selection is non-empty', () => {
const guest = createGuest();
const callback = sinon.stub();
guest.subscribe('hasSelectionChanged', callback);
guest._emitter.subscribe('hasSelectionChanged', callback);
simulateSelectionWithText();
......@@ -563,7 +563,7 @@ describe('Guest', () => {
it('emits `hasSelectionChanged` event with argument `false` if selection is empty', () => {
const guest = createGuest();
const callback = sinon.stub();
guest.subscribe('hasSelectionChanged', callback);
guest._emitter.subscribe('hasSelectionChanged', callback);
simulateSelectionWithoutText();
......@@ -577,7 +577,7 @@ describe('Guest', () => {
it('creates a new annotation if "Annotate" is clicked', async () => {
const guest = createGuest();
const callback = sinon.stub();
guest.subscribe('beforeAnnotationCreated', callback);
guest._emitter.subscribe('beforeAnnotationCreated', callback);
await FakeAdder.instance.options.onAnnotate();
......@@ -587,7 +587,7 @@ describe('Guest', () => {
it('creates a new highlight if "Highlight" is clicked', async () => {
const guest = createGuest();
const callback = sinon.stub();
guest.subscribe('beforeAnnotationCreated', callback);
guest._emitter.subscribe('beforeAnnotationCreated', callback);
await FakeAdder.instance.options.onHighlight();
......@@ -690,7 +690,7 @@ describe('Guest', () => {
it('triggers a "beforeAnnotationCreated" event', async () => {
const guest = createGuest();
const callback = sinon.stub();
guest.subscribe('beforeAnnotationCreated', callback);
guest._emitter.subscribe('beforeAnnotationCreated', callback);
const annotation = await guest.createAnnotation();
......@@ -844,7 +844,7 @@ describe('Guest', () => {
const guest = createGuest();
const annotation = {};
const anchorsChanged = sinon.stub();
guest.subscribe('anchorsChanged', anchorsChanged);
guest._emitter.subscribe('anchorsChanged', anchorsChanged);
await guest.anchor(annotation);
......@@ -928,7 +928,7 @@ describe('Guest', () => {
const annotation = {};
guest.anchors.push({ annotation });
const anchorsChanged = sinon.stub();
guest.subscribe('anchorsChanged', anchorsChanged);
guest._emitter.subscribe('anchorsChanged', anchorsChanged);
guest.detach(annotation);
......
......@@ -3,6 +3,7 @@
import Guest from '../../guest';
import { $imports as guestImports } from '../../guest';
import { EventBus } from '../../util/emitter';
function quoteSelector(quote) {
return {
......@@ -65,7 +66,8 @@ describe('anchoring', function () {
container = document.createElement('div');
container.innerHTML = require('./test-page.html');
document.body.appendChild(container);
guest = new Guest(container);
const eventBus = new EventBus();
guest = new Guest(container, eventBus);
});
afterEach(() => {
......
import Notebook from '../notebook';
import { EventBus } from '../util/emitter';
describe('Notebook', () => {
// `Notebook` instances created by current test
......@@ -7,7 +8,8 @@ describe('Notebook', () => {
const createNotebook = (config = {}) => {
config = { notebookAppUrl: '/base/annotator/test/empty.html', ...config };
const element = document.createElement('div');
const notebook = new Notebook(element, config);
const eventBus = new EventBus();
const notebook = new Notebook(element, eventBus, config);
notebooks.push(notebook);
......@@ -88,11 +90,11 @@ describe('Notebook', () => {
notebook._groupId = 'mygroup';
// The first opening will create a new iFrame
notebook.publish('openNotebook', ['myGroup']);
notebook._emitter.publish('openNotebook', 'myGroup');
const removeSpy = sinon.spy(notebook.frame, 'remove');
// Open it again — the group hasn't changed so the iframe won't be
// replaced
notebook.publish('openNotebook', ['myGroup']);
notebook._emitter.publish('openNotebook', 'myGroup');
assert.notCalled(removeSpy);
});
......@@ -102,11 +104,11 @@ describe('Notebook', () => {
notebook._groupId = 'mygroup';
// First open: creates an iframe
notebook.publish('openNotebook', ['myGroup']);
notebook._emitter.publish('openNotebook', 'myGroup');
const removeSpy = sinon.spy(notebook.frame, 'remove');
// Open again with another group
notebook.publish('openNotebook', ['anotherGroup']);
notebook._emitter.publish('openNotebook', 'anotherGroup');
// Open again, which will remove the first iframe and create a new one
notebook.open();
......@@ -132,7 +134,7 @@ describe('Notebook', () => {
it('opens on `openNotebook`', () => {
const notebook = createNotebook();
notebook.publish('openNotebook');
notebook._emitter.publish('openNotebook');
assert.equal(notebook.container.style.display, '');
});
......
import PdfSidebar from '../pdf-sidebar';
import Delegator from '../delegator';
import { mockBaseClass } from '../../test-util/mock-base';
import { ListenerCollection } from '../util/listener-collection';
import { EventBus } from '../util/emitter';
class FakeSidebar extends Delegator {
constructor(element, guest, config) {
super(element, config);
class FakeSidebar {
constructor(element, eventBus, guest, config) {
this._emitter = eventBus.createEmitter();
this.guest = guest;
this.options = config;
this._listeners = new ListenerCollection();
}
}
......@@ -22,7 +23,8 @@ describe('PdfSidebar', () => {
const createPdfSidebar = config => {
const element = document.createElement('div');
return new PdfSidebar(element, fakeGuest, config);
const eventBus = new EventBus();
return new PdfSidebar(element, eventBus, fakeGuest, config);
};
let unmockSidebar;
......@@ -139,9 +141,11 @@ describe('PdfSidebar', () => {
sandbox.stub(window, 'innerWidth').value(1350);
const sidebar = createPdfSidebar();
sidebar.publish('sidebarLayoutChanged', [
{ expanded: true, width: 428, height: 728 },
]);
sidebar._emitter.publish('sidebarLayoutChanged', {
expanded: true,
width: 428,
height: 728,
});
assert.isTrue(sidebar.sideBySideActive);
assert.calledOnce(fakePDFViewerUpdate);
......@@ -161,9 +165,11 @@ describe('PdfSidebar', () => {
sandbox.stub(window, 'innerWidth').value(1350);
const sidebar = createPdfSidebar();
sidebar.publish('sidebarLayoutChanged', [
{ expanded: true, width: 428, height: 728 },
]);
sidebar._emitter.publish('sidebarLayoutChanged', {
expanded: true,
width: 428,
height: 728,
});
assert.isTrue(sidebar.sideBySideActive);
assert.calledOnce(fakePDFViewerUpdate);
......@@ -175,9 +181,11 @@ describe('PdfSidebar', () => {
sandbox.stub(window, 'innerWidth').value(1350);
const sidebar = createPdfSidebar();
sidebar.publish('sidebarLayoutChanged', [
{ expanded: false, width: 428, height: 728 },
]);
sidebar._emitter.publish('sidebarLayoutChanged', {
expanded: false,
width: 428,
height: 728,
});
assert.isFalse(sidebar.sideBySideActive);
assert.equal(fakePDFContainer.style.width, 'auto');
......@@ -187,9 +195,11 @@ describe('PdfSidebar', () => {
sandbox.stub(window, 'innerWidth').value(800);
const sidebar = createPdfSidebar();
sidebar.publish('sidebarLayoutChanged', [
{ expanded: true, width: 428, height: 728 },
]);
sidebar._emitter.publish('sidebarLayoutChanged', {
expanded: true,
width: 428,
height: 728,
});
assert.isFalse(sidebar.sideBySideActive);
assert.calledOnce(fakePDFViewerUpdate);
......
import events from '../../shared/bridge-events';
import Delegator from '../delegator';
import Sidebar, { MIN_RESIZE } from '../sidebar';
import { $imports } from '../sidebar';
import { EventBus } from '../util/emitter';
const DEFAULT_WIDTH = 350;
const DEFAULT_HEIGHT = 600;
......@@ -43,7 +43,8 @@ describe('Sidebar', () => {
document.body.appendChild(container);
containers.push(container);
const sidebar = new Sidebar(container, fakeGuest, config);
const eventBus = new EventBus();
const sidebar = new Sidebar(container, eventBus, fakeGuest, config);
sidebars.push(sidebar);
return sidebar;
......@@ -69,11 +70,9 @@ describe('Sidebar', () => {
call: sandbox.stub(),
};
class FakeGuest extends Delegator {
class FakeGuest {
constructor() {
const element = document.createElement('div');
super(element, {});
this.element = document.createElement('div');
this.createAnnotation = sinon.stub();
this.crossframe = fakeCrossFrame;
this.setVisibleHighlights = sinon.stub();
......@@ -139,7 +138,7 @@ describe('Sidebar', () => {
it('becomes visible when the "panelReady" event fires', () => {
const sidebar = createSidebar();
sidebar.publish('panelReady');
sidebar._emitter.publish('panelReady');
assert.equal(sidebar.iframeContainer.style.display, '');
});
});
......@@ -176,11 +175,9 @@ describe('Sidebar', () => {
const sidebar = createSidebar();
const iframe = stubIframeWindow(sidebar);
sidebar.publish('beforeAnnotationCreated', [
{
$highlight: false,
},
]);
sidebar._emitter.publish('beforeAnnotationCreated', {
$highlight: false,
});
assert.called(iframe.contentWindow.focus);
});
......@@ -189,11 +186,9 @@ describe('Sidebar', () => {
const sidebar = createSidebar();
const iframe = stubIframeWindow(sidebar);
sidebar.publish('beforeAnnotationCreated', [
{
$highlight: true,
},
]);
sidebar._emitter.publish('beforeAnnotationCreated', {
$highlight: true,
});
assert.notCalled(iframe.contentWindow.focus);
});
......@@ -237,7 +232,7 @@ describe('Sidebar', () => {
// nb. This event is normally published by the Guest, but the sidebar
// doesn't care about that.
sidebar.publish('hasSelectionChanged', [true]);
sidebar._emitter.publish('hasSelectionChanged', true);
assert.equal(sidebar.toolbar.newAnnotationType, 'annotation');
});
......@@ -247,7 +242,7 @@ describe('Sidebar', () => {
// nb. This event is normally published by the Guest, but the sidebar
// doesn't care about that.
sidebar.publish('hasSelectionChanged', [false]);
sidebar._emitter.publish('hasSelectionChanged', false);
assert.equal(sidebar.toolbar.newAnnotationType, 'note');
});
......@@ -284,13 +279,9 @@ describe('Sidebar', () => {
it('hides the sidebar', () => {
const sidebar = createSidebar();
sinon.stub(sidebar, 'hide').callThrough();
sinon.stub(sidebar, 'publish');
sinon.stub(sidebar._emitter, 'publish');
emitEvent('openNotebook', 'mygroup');
assert.calledWith(
sidebar.publish,
'openNotebook',
sinon.match(['mygroup'])
);
assert.calledWith(sidebar._emitter.publish, 'openNotebook', 'mygroup');
assert.calledOnce(sidebar.hide);
assert.notEqual(sidebar.iframeContainer.style.visibility, 'hidden');
});
......@@ -300,7 +291,7 @@ describe('Sidebar', () => {
it('shows the sidebar', () => {
const sidebar = createSidebar();
sinon.stub(sidebar, 'show').callThrough();
sidebar.publish('closeNotebook');
sidebar._emitter.publish('closeNotebook');
assert.calledOnce(sidebar.show);
assert.equal(sidebar.iframeContainer.style.visibility, '');
});
......@@ -483,7 +474,7 @@ describe('Sidebar', () => {
annotations: 'ann-id',
});
const open = sandbox.stub(sidebar, 'open');
sidebar.publish('panelReady');
sidebar._emitter.publish('panelReady');
assert.calledOnce(open);
});
......@@ -492,7 +483,7 @@ describe('Sidebar', () => {
group: 'group-id',
});
const open = sandbox.stub(sidebar, 'open');
sidebar.publish('panelReady');
sidebar._emitter.publish('panelReady');
assert.calledOnce(open);
});
......@@ -501,7 +492,7 @@ describe('Sidebar', () => {
query: 'tag:foo',
});
const open = sandbox.stub(sidebar, 'open');
sidebar.publish('panelReady');
sidebar._emitter.publish('panelReady');
assert.calledOnce(open);
});
......@@ -510,14 +501,14 @@ describe('Sidebar', () => {
openSidebar: true,
});
const open = sandbox.stub(sidebar, 'open');
sidebar.publish('panelReady');
sidebar._emitter.publish('panelReady');
assert.calledOnce(open);
});
it('does not open the sidebar if not configured to.', () => {
const sidebar = createSidebar();
const open = sandbox.stub(sidebar, 'open');
sidebar.publish('panelReady');
sidebar._emitter.publish('panelReady');
assert.notCalled(open);
});
});
......@@ -600,7 +591,7 @@ describe('Sidebar', () => {
describe('window resize events', () => {
it('hides the sidebar if window width is < MIN_RESIZE', () => {
const sidebar = createSidebar({ openSidebar: true });
sidebar.publish('panelReady');
sidebar._emitter.publish('panelReady');
window.innerWidth = MIN_RESIZE - 1;
window.dispatchEvent(new Event('resize'));
......@@ -610,7 +601,7 @@ describe('Sidebar', () => {
it('invokes the "open" method when window is resized', () => {
// Calling the 'open' methods adjust the marginLeft at different screen sizes
const sidebar = createSidebar({ openSidebar: true });
sidebar.publish('panelReady');
sidebar._emitter.publish('panelReady');
sinon.stub(sidebar, 'open');
// Make the window very small
......@@ -677,23 +668,23 @@ describe('Sidebar', () => {
});
it('notifies when sidebar changes expanded state', () => {
sinon.stub(sidebar, 'publish');
sinon.stub(sidebar._emitter, 'publish');
sidebar.open();
assert.calledOnce(layoutChangeHandlerSpy);
assert.calledWith(
sidebar.publish,
sidebar._emitter.publish,
'sidebarLayoutChanged',
sinon.match.any
);
assert.calledWith(sidebar.publish, 'sidebarOpened');
assert.calledTwice(sidebar.publish);
assert.calledWith(sidebar._emitter.publish, 'sidebarOpened');
assert.calledTwice(sidebar._emitter.publish);
assertLayoutValues(layoutChangeHandlerSpy.lastCall.args[0], {
expanded: true,
});
sidebar.close();
assert.calledTwice(layoutChangeHandlerSpy);
assert.calledThrice(sidebar.publish);
assert.calledThrice(sidebar._emitter.publish);
assertLayoutValues(layoutChangeHandlerSpy.lastCall.args[0], {
expanded: false,
width: fakeToolbar.getWidth(),
......@@ -846,9 +837,7 @@ describe('Sidebar', () => {
it('updates the bucket bar when an `anchorsChanged` event is received', () => {
const sidebar = createSidebar();
fakeGuest.publish('anchorsChanged');
sidebar._emitter.publish('anchorsChanged');
assert.calledOnce(sidebar.bucketBar.update);
});
});
......
import { TinyEmitter } from 'tiny-emitter';
/**
* Emitter is a communication class that implements the publisher/subscriber
* pattern. It allows sending and listening events through a shared EventBus.
* The different elements of the application can communicate with each other
* without being tightly coupled.
*/
class Emitter {
/**
* @param {TinyEmitter} emitter
*/
constructor(emitter) {
this._emitter = emitter;
/** @type {[event: string, callback: Function][]} */
this._subscriptions = [];
}
/**
* Fire an event.
*
* @param {string} event
* @param {any[]} args
*/
publish(event, ...args) {
this._emitter.emit(event, ...args);
}
/**
* Register an event listener.
*
* @param {string} event
* @param {Function} callback
*/
subscribe(event, callback) {
this._emitter.on(event, callback);
this._subscriptions.push([event, callback]);
}
/**
* Remove an event listener.
*
* @param {string} event
* @param {Function} callback
*/
unsubscribe(event, callback) {
this._emitter.off(event, callback);
this._subscriptions = this._subscriptions.filter(
([subEvent, subCallback]) =>
subEvent !== event || subCallback !== callback
);
}
/**
* Remove all event listeners.
*/
destroy() {
for (let [event, callback] of this._subscriptions) {
this._emitter.off(event, callback);
}
this._subscriptions = [];
}
}
export class EventBus {
constructor() {
this._emitter = new TinyEmitter();
}
createEmitter() {
return new Emitter(this._emitter);
}
}
import { EventBus } from '../emitter';
describe('Emitter', () => {
it('subscribes and unsubscribes listeners and publishes events', () => {
const eventBus = new EventBus();
const emitterA = eventBus.createEmitter();
const emitterB = eventBus.createEmitter();
const callback = sinon.stub();
emitterB.subscribe('someEvent', callback);
emitterA.publish('someEvent', 'foo', 'bar');
assert.calledOnce(callback);
assert.calledWith(callback, 'foo', 'bar');
emitterB.unsubscribe('someEvent', callback);
emitterA.publish('someEvent', 'foo', 'bar');
assert.calledOnce(callback);
});
it('fires events only to emitters using the same EventBus', () => {
const emitterA = new EventBus().createEmitter();
const emitterB = new EventBus().createEmitter();
const callbackA = sinon.stub();
const callbackB = sinon.stub();
emitterA.subscribe('someEvent', callbackA);
emitterB.subscribe('someEvent', callbackB);
emitterA.publish('someEvent', 'foo', 'bar');
emitterB.publish('someEvent', 1, 2);
assert.calledOnce(callbackA);
assert.calledWith(callbackA, 'foo', 'bar');
assert.calledOnce(callbackB);
assert.calledWith(callbackB, 1, 2);
});
it('removes all event listeners', () => {
const emitter = new EventBus().createEmitter();
const callback = sinon.stub();
emitter.subscribe('someEvent', callback);
emitter.publish('someEvent');
assert.calledOnce(callback);
emitter.destroy();
emitter.publish('someEvent');
assert.calledOnce(callback);
});
});
......@@ -35,7 +35,9 @@ import { ListenerCollection } from '../annotator/util/listener-collection';
export default class Discovery {
/**
* @param {Window} target
* @param {Object} options
* @param {object} [options]
* @param {boolean} [options.server]
* @param {string} [options.origin]
*/
constructor(target, options = {}) {
/** The window to send and listen for messages with. */
......
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