Commit 9ff3954d authored by Robert Knight's avatar Robert Knight

Rewrite the `AnnotationSync` class in the annotator

The `AnnotationSync` class was an ES5-style class that had a ton of
ES5-isms, odd constructions probably resulting from a historical
conversion from CoffeeScript and unnecessary abstractions. It was also
lacking in documentation.

This commit rewrites the class using modern syntax and removing unused
code and unnecessary abstractions (eg. the `_eventListeners` and
`_channelListeners` properties).

The API and behavior should be unchanged. An additional test was added
for the `sync` method.
parent ea59ddce
// AnnotationSync listens for messages from the sidebar app indicating that /**
// annotations have been added or removed and relays them to Guest. * @typedef {import('../shared/bridge').default} Bridge
// */
// It also listens for events from Guest when new annotations are created or
// annotations successfully anchor and relays these to the sidebar app. /**
export default function AnnotationSync(bridge, options) { * @callback RpcCallback
const self = this; * @param {Error|null} error
* @param {any} result
this.bridge = bridge; */
if (!options.on) { /**
throw new Error('options.on unspecified for AnnotationSync.'); * AnnotationSync listens for messages from the sidebar app indicating that
} * annotations have been added or removed and relays them to Guest.
*
if (!options.emit) { * It also listens for events from Guest when new annotations are created or
throw new Error('options.emit unspecified for AnnotationSync.'); * annotations successfully anchor and relays these to the sidebar app.
} */
export default class AnnotationSync {
this.cache = {}; /**
* @param {Bridge} bridge
this._on = options.on; * @param {Object} options
this._emit = options.emit; * @param {(event: string, callback: (data: any, callback: RpcCallback) => void) => void} options.on -
* Function that registers a listener for an event from the rest of the
// Listen locally for interesting events * annotator
Object.keys(this._eventListeners).forEach(function (eventName) { * @param {(event: string, ...args: any[]) => void} options.emit -
const listener = self._eventListeners[eventName]; * Function that publishes an event to the rest of the annotator
self._on(eventName, function (annotation) { */
listener.apply(self, [annotation]); constructor(bridge, options) {
this.bridge = bridge;
/**
* Mapping from annotation tags to annotation objects for annotations which
* have been sent to or received from the sidebar.
*
* @type {{ [tag: string]: Object }}
*/
this.cache = {};
this._on = options.on;
this._emit = options.emit;
// Relay events from the sidebar to the rest of the annotator.
this.bridge.on('deleteAnnotation', (body, callback) => {
const annotation = this._parse(body);
delete this.cache[annotation.$tag];
this._emit('annotationDeleted', annotation);
callback(null, this._format(annotation));
}); });
});
// Register remotely invokable methods this.bridge.on('loadAnnotations', (bodies, callback) => {
Object.keys(this._channelListeners).forEach(function (eventName) { const annotations = bodies.map(body => this._parse(body));
self.bridge.on(eventName, function (data, callbackFunction) { this._emit('annotationsLoaded', annotations);
const listener = self._channelListeners[eventName]; callback(null, annotations);
listener.apply(self, [data, callbackFunction]);
}); });
});
}
// Cache of annotations which have crossed the bridge for fast, encapsulated
// association of annotations received in arguments to window-local copies.
AnnotationSync.prototype.cache = null;
AnnotationSync.prototype.sync = function (annotations) {
annotations = function () {
let i;
const formattedAnnotations = [];
for (i = 0; i < annotations.length; i++) {
formattedAnnotations.push(this._format(annotations[i]));
}
return formattedAnnotations;
}.call(this);
this.bridge.call(
'sync',
annotations,
(function (_this) {
return function (err, annotations) {
let i;
const parsedAnnotations = [];
annotations = annotations || [];
for (i = 0; i < annotations.length; i++) {
parsedAnnotations.push(_this._parse(annotations[i]));
}
return parsedAnnotations;
};
})(this)
);
return this;
};
// Handlers for messages arriving through a channel
AnnotationSync.prototype._channelListeners = {
deleteAnnotation: function (body, cb) {
const annotation = this._parse(body);
delete this.cache[annotation.$tag];
this._emit('annotationDeleted', annotation);
cb(null, this._format(annotation));
},
loadAnnotations: function (bodies, cb) {
const annotations = function () {
let i;
const parsedAnnotations = [];
for (i = 0; i < bodies.length; i++) { // Relay events from annotator to sidebar.
parsedAnnotations.push(this._parse(bodies[i])); this._on('beforeAnnotationCreated', annotation => {
if (annotation.$tag) {
return;
} }
return parsedAnnotations; this.bridge.call('beforeCreateAnnotation', this._format(annotation));
}.call(this); });
this._emit('annotationsLoaded', annotations); }
return cb(null, annotations);
},
};
// Handlers for events coming from this frame, to send them across the channel /**
AnnotationSync.prototype._eventListeners = { * Relay updated annotations from the annotator to the sidebar.
beforeAnnotationCreated: function (annotation) { *
if (annotation.$tag) { * This is called for example after annotations are anchored to notify the
return undefined; * sidebar about the current anchoring status.
} */
return this._mkCallRemotelyAndParseResults('beforeCreateAnnotation')( sync(annotations) {
annotation this.bridge.call(
'sync',
annotations.map(ann => this._format(ann))
); );
}, }
};
AnnotationSync.prototype._mkCallRemotelyAndParseResults = function (
method,
callBack
) {
return (function (_this) {
return function (annotation) {
// Wrap the callback function to first parse returned items
const wrappedCallback = function (failure, results) {
if (failure === null) {
_this._parseResults(results);
}
if (typeof callBack === 'function') {
callBack(failure, results);
}
};
// Call the remote method
_this.bridge.call(method, _this._format(annotation), wrappedCallback);
};
})(this);
};
// Parse returned message bodies to update cache with any changes made remotely
AnnotationSync.prototype._parseResults = function (results) {
let bodies;
let body;
let i;
let j;
for (i = 0; i < results.length; i++) { /**
bodies = results[i]; * Assign a non-enumerable tag to objects which cross the bridge.
bodies = [].concat(bodies); * This tag is used to identify the objects between message.
for (j = 0; j < bodies.length; j++) { *
body = bodies[j]; * @param {string} [tag]
if (body !== null) { */
this._parse(body); _tag(ann, tag) {
} if (ann.$tag) {
return ann;
} }
tag = tag || window.btoa(Math.random().toString());
Object.defineProperty(ann, '$tag', {
value: tag,
});
this.cache[tag] = ann;
return ann;
} }
};
// Assign a non-enumerable tag to objects which cross the bridge. /**
// This tag is used to identify the objects between message. * Copy annotation data from an RPC message into a local copy (in `this.cache`)
AnnotationSync.prototype._tag = function (ann, tag) { * and return the local copy.
if (ann.$tag) { */
return ann; _parse(body) {
const merged = Object.assign(this.cache[body.tag] || {}, body.msg);
return this._tag(merged, body.tag);
} }
tag = tag || window.btoa(Math.random().toString());
Object.defineProperty(ann, '$tag', {
value: tag,
});
this.cache[tag] = ann;
return ann;
};
// Parse a message body from a RPC call with the provided parser. /**
AnnotationSync.prototype._parse = function (body) { * Format an annotation into an RPC message body.
const merged = Object.assign(this.cache[body.tag] || {}, body.msg); */
return this._tag(merged, body.tag); _format(ann) {
}; this._tag(ann);
// Format an annotation into an RPC message body with the provided formatter. return {
AnnotationSync.prototype._format = function (ann) { tag: ann.$tag,
this._tag(ann); msg: ann,
return { };
tag: ann.$tag, }
msg: ann, }
};
};
...@@ -120,12 +120,10 @@ describe('AnnotationSync', function () { ...@@ -120,12 +120,10 @@ describe('AnnotationSync', function () {
options.emit('beforeAnnotationCreated', ann); options.emit('beforeAnnotationCreated', ann);
assert.called(fakeBridge.call); assert.called(fakeBridge.call);
assert.calledWith( assert.calledWith(fakeBridge.call, 'beforeCreateAnnotation', {
fakeBridge.call, msg: ann,
'beforeCreateAnnotation', tag: ann.$tag,
{ msg: ann, tag: ann.$tag }, });
sinon.match.func
);
}); });
context('if the annotation has a $tag', function () { context('if the annotation has a $tag', function () {
...@@ -140,4 +138,15 @@ describe('AnnotationSync', function () { ...@@ -140,4 +138,15 @@ describe('AnnotationSync', function () {
}); });
}); });
}); });
describe('#sync', () => {
it('calls "sync" method of the bridge', () => {
const ann = { id: 1 };
const annotationSync = createAnnotationSync();
annotationSync.sync([ann]);
assert.calledWith(fakeBridge.call, 'sync', [{ msg: ann, tag: ann.$tag }]);
});
});
}); });
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