| @@ -0,0 +1,67 @@ | ||
| # Uses a channel between the sidebar and the attached providers to ensure | ||
| # the interface remains in sync. | ||
| class AnnotationUISync | ||
| ###* | ||
| # @name AnnotationUISync | ||
| # @param {$window} $window An Angular window service. | ||
| # @param {CrossFrameBridge} bridge | ||
| # @param {AnnotationSync} annotationSync | ||
| # @param {AnnotationUI} annotationUI An instance of the AnnotatonUI service | ||
| # @description | ||
| # Listens for incoming events over the bridge concerning the annotation | ||
| # interface and updates the applications internal state. It also ensures | ||
| # that the messages are broadcast out to other frames. | ||
| ### | ||
| constructor: ($window, bridge, annotationSync, annotationUI) -> | ||
| # Retrieves annotations from the annotationSync cache. | ||
| getAnnotationsByTags = (tags) -> | ||
| tags.map(annotationSync.getAnnotationForTag, annotationSync) | ||
| # Sends a message to the host frame only. | ||
| notifyHost = (message) -> | ||
| for {channel, window} in bridge.links when window is $window.parent | ||
| channel.notify(message) | ||
| break | ||
| # Send messages to host to hide/show sidebar iframe. | ||
| hide = notifyHost.bind(null, method: 'hideFrame') | ||
| show = notifyHost.bind(null, method: 'showFrame') | ||
| channelListeners = | ||
| back: hide | ||
| open: show | ||
| showEditor: show | ||
| showAnnotations: (ctx, tags=[]) -> | ||
| show() | ||
| annotations = getAnnotationsByTags(tags) | ||
| annotationUI.xorSelectedAnnotations(annotations) | ||
| focusAnnotations: (ctx, tags=[]) -> | ||
| annotations = getAnnotationsByTags(tags) | ||
| annotationUI.focusAnnotations(annotations) | ||
| toggleAnnotationSelection: (ctx, tags=[]) -> | ||
| annotations = getAnnotationsByTags(tags) | ||
| annotationUI.selectAnnotations(annotations) | ||
| setTool: (ctx, name) -> | ||
| annotationUI.tool = name | ||
| bridge.notify(method: 'setTool', params: name) | ||
| setVisibleHighlights: (ctx, state) -> | ||
| annotationUI.visibleHighlights = Boolean(state) | ||
| bridge.notify(method: 'setVisibleHighlights', params: state) | ||
| for own channel, listener of channelListeners | ||
| bridge.on(channel, listener) | ||
| onConnect = (channel, source) -> | ||
| # Allow the host to define its own state | ||
| unless source is $window.parent | ||
| channel.notify | ||
| method: 'setTool' | ||
| params: annotationUI.tool | ||
| channel.notify | ||
| method: 'setVisibleHighlights' | ||
| params: annotationUI.visibleHighlights | ||
| bridge.onConnect(onConnect) | ||
| angular.module('h').value('AnnotationUISync', AnnotationUISync) |
| @@ -1,316 +1,42 @@ | ||
| $ = Annotator.$ | ||
| class Annotator.Plugin.Bridge extends Annotator.Plugin | ||
| # These events maintain the awareness of annotations between the two | ||
| # communicating annotators. | ||
| events: | ||
| 'beforeAnnotationCreated': 'beforeAnnotationCreated' | ||
| 'annotationCreated': 'annotationCreated' | ||
| 'annotationUpdated': 'annotationUpdated' | ||
| 'annotationDeleted': 'annotationDeleted' | ||
| 'annotationsLoaded': 'annotationsLoaded' | ||
| # Plugin configuration | ||
| options: | ||
| # Origins allowed to communicate on the channel | ||
| origin: '*' | ||
| # Scope identifier to distinguish this channel from any others | ||
| scope: 'annotator:bridge' | ||
| # When this is true, this bridge will act as a gateway and, similar to DHCP, | ||
| # offer to connect to bridges in other frames it discovers. | ||
| gateway: false | ||
| # A callback to invoke when a connection is established. The function is | ||
| # passed two arguments, the source window and origin of the other frame. | ||
| onConnect: -> true | ||
| # Formats an annotation for sending across the bridge | ||
| formatter: (annotation) -> annotation | ||
| # Parses an annotation received from the bridge | ||
| parser: (annotation) -> annotation | ||
| # Merge function. If specified, it will be called with the local copy of | ||
| # an annotation and a parsed copy received as an argument to an RPC call | ||
| # to reconcile any differences. The default behavior is to merge all | ||
| # keys of the remote object into the local copy | ||
| merge: (local, remote) -> | ||
| for k, v of remote | ||
| local[k] = v | ||
| local | ||
| # Cache of annotations which have crossed the bridge for fast, encapsulated | ||
| # association of annotations received in arguments to window-local copies. | ||
| cache: null | ||
| # Connected bridge links | ||
| links: null | ||
| # Annotations currently being updated -- used to avoid event callback loops | ||
| updating: null | ||
| # Extracts individual keys from an object and returns a new one. | ||
| extract = extract = (obj, keys...) -> | ||
| ret = {} | ||
| ret[key] = obj[key] for key in keys when obj.hasOwnProperty(key) | ||
| ret | ||
| Bridge = class Annotator.Plugin.Bridge extends Annotator.Plugin | ||
| constructor: (elem, options) -> | ||
| if options.window? | ||
| # Pull the option out and restore it after the super constructor is | ||
| # called. Unfortunately, Delegator uses a jQuery function which | ||
| # inspects this too closely and causes security errors. | ||
| window = options.window | ||
| delete options.window | ||
| super elem, options | ||
| @options.window = window | ||
| else | ||
| super | ||
| @cache = {} | ||
| @links = [] | ||
| @updating = {} | ||
| pluginInit: -> | ||
| $(window).on 'message', this._onMessage | ||
| this._beacon() | ||
| destroy: -> | ||
| super | ||
| $(window).off 'message', this._onMessage | ||
| # Assign a non-enumerable tag to objects which cross the bridge. | ||
| # This tag is used to identify the objects between message. | ||
| _tag: (msg, tag) -> | ||
| return msg if msg.$$tag | ||
| tag = tag or (window.btoa Math.random()) | ||
| Object.defineProperty msg, '$$tag', value: tag | ||
| @cache[tag] = msg | ||
| msg | ||
| # Parse an annotation from a RPC with the configured parser | ||
| _parse: ({tag, msg}) -> | ||
| local = @cache[tag] | ||
| remote = @options.parser msg | ||
| if local? | ||
| merged = @options.merge local, remote | ||
| else | ||
| merged = remote | ||
| this._tag merged, tag | ||
| # Format an annotation for RPC with the configured formatter | ||
| _format: (annotation) -> | ||
| this._tag annotation | ||
| msg = @options.formatter annotation | ||
| tag: annotation.$$tag | ||
| msg: msg | ||
| # Construct a channel to another frame | ||
| _build: (options) -> | ||
| # jschannel chokes on FF and Chrome extension origins. | ||
| if (options.origin.match /^chrome-extension:\/\//) or | ||
| (options.origin.match /^resource:\/\//) | ||
| options.origin = '*' | ||
| channel = Channel.build(options) | ||
| ## Remote method call bindings | ||
| .bind('beforeCreateAnnotation', (txn, annotation) => | ||
| annotation = this._parse annotation | ||
| delete @cache[annotation.$$tag] | ||
| @annotator.publish 'beforeAnnotationCreated', annotation | ||
| @cache[annotation.$$tag] = annotation | ||
| this._format annotation | ||
| ) | ||
| .bind('createAnnotation', (txn, annotation) => | ||
| annotation = this._parse annotation | ||
| delete @cache[annotation.$$tag] | ||
| @annotator.publish 'annotationCreated', annotation | ||
| @cache[annotation.$$tag] = annotation | ||
| this._format annotation | ||
| ) | ||
| .bind('updateAnnotation', (txn, annotation) => | ||
| annotation = this._parse annotation | ||
| delete @cache[annotation.$$tag] | ||
| annotation = @annotator.updateAnnotation annotation | ||
| @cache[annotation.$$tag] = annotation | ||
| this._format annotation | ||
| ) | ||
| .bind('deleteAnnotation', (txn, annotation) => | ||
| annotation = this._parse annotation | ||
| delete @cache[annotation.$$tag] | ||
| annotation = @annotator.deleteAnnotation annotation | ||
| res = this._format annotation | ||
| delete @cache[annotation.$$tag] | ||
| res | ||
| ) | ||
| .bind('sync', (ctx, annotations) => | ||
| (this._format (this._parse a) for a in annotations) | ||
| ) | ||
| ## Notifications | ||
| .bind('loadAnnotations', (txn, annotations) => | ||
| annotations = (this._parse a for a in annotations) | ||
| @annotator.loadAnnotations annotations | ||
| ) | ||
| # Send out a beacon to let other frames know to connect to us | ||
| _beacon: -> | ||
| queue = [window.top] | ||
| while queue.length | ||
| parent = queue.shift() | ||
| if parent isnt window | ||
| parent.postMessage '__annotator_dhcp_discovery', @options.origin | ||
| for child in parent.frames | ||
| queue.push child | ||
| # Make a method call on all links | ||
| _call: (options) -> | ||
| _makeDestroyFn = (c) => | ||
| (error, reason) => | ||
| c.destroy() | ||
| @links = (l for l in @links when l.channel isnt c) | ||
| deferreds = @links.map (l) -> | ||
| d = $.Deferred().fail (_makeDestroyFn l.channel) | ||
| options = $.extend {}, options, | ||
| success: (result) -> d.resolve result | ||
| error: (error, reason) -> | ||
| if error isnt 'timeout_error' | ||
| d.reject error, reason | ||
| else | ||
| d.resolve null | ||
| timeout: 1000 | ||
| l.channel.call options | ||
| d.promise() | ||
| $.when(deferreds...) | ||
| .then (results...) => | ||
| if Array.isArray(results[0]) | ||
| acc = [] | ||
| foldFn = (_, cur) => | ||
| (this._parse(a) for a in cur) | ||
| else | ||
| acc = {} | ||
| foldFn = (_, cur) => | ||
| this._parse(cur) | ||
| options.callback? null, results.reduce(foldFn, acc) | ||
| .fail (failure) => | ||
| options.callback? failure | ||
| # Publish a notification to all links | ||
| _notify: (options) -> | ||
| for l in @links | ||
| l.channel.notify options | ||
| _onMessage: (e) => | ||
| {source, origin, data} = e.originalEvent | ||
| match = data.match? /^__annotator_dhcp_(discovery|ack|offer)(:\d+)?$/ | ||
| return unless match | ||
| if match[1] is 'discovery' | ||
| if @options.gateway | ||
| scope = ':' + ('' + Math.random()).replace(/\D/g, '') | ||
| source.postMessage '__annotator_dhcp_offer' + scope, origin | ||
| else | ||
| source.postMessage '__annotator_dhcp_ack', origin | ||
| return | ||
| else if match[1] is 'ack' | ||
| if @options.gateway | ||
| scope = ':' + ('' + Math.random()).replace(/\D/g, '') | ||
| source.postMessage '__annotator_dhcp_offer' + scope, origin | ||
| else | ||
| return | ||
| else if match[1] is 'offer' | ||
| if @options.gateway | ||
| return | ||
| else | ||
| scope = match[2] | ||
| scope = @options.scope + scope | ||
| options = $.extend {}, @options, | ||
| window: source | ||
| origin: origin | ||
| scope: scope | ||
| onReady: => | ||
| options.onConnect.call @annotator, source, origin, scope | ||
| annotations = (this._format a for t, a of @cache) | ||
| if annotations.length | ||
| channel.notify | ||
| method: 'loadAnnotations' | ||
| params: annotations | ||
| channel = this._build options | ||
| @links.push | ||
| channel: channel | ||
| window: source | ||
| beforeAnnotationCreated: (annotation) => | ||
| return if annotation.$$tag? | ||
| this.beforeCreateAnnotation annotation | ||
| this | ||
| annotationCreated: (annotation) => | ||
| return unless annotation.$$tag? and @cache[annotation.$$tag] | ||
| this.createAnnotation annotation | ||
| this | ||
| opts = extract(options, 'server') | ||
| discovery = new Bridge.CrossFrameDiscovery(window, opts) | ||
| annotationUpdated: (annotation) => | ||
| return unless annotation.$$tag? and @cache[annotation.$$tag] | ||
| this.updateAnnotation annotation | ||
| this | ||
| opts = extract(options, 'scope') | ||
| bridge = new Bridge.CrossFrameBridge(opts) | ||
| annotationDeleted: (annotation) => | ||
| return unless annotation.$$tag? and @cache[annotation.$$tag] | ||
| this.deleteAnnotation annotation, (err) => | ||
| if err then @annotator.setupAnnotation annotation | ||
| else delete @cache[annotation.$$tag] | ||
| this | ||
| opts = extract(options, 'on', 'emit', 'formatter', 'parser') | ||
| annotationSync = new Bridge.AnnotationSync(bridge, opts) | ||
| annotationsLoaded: (annotations) => | ||
| annotations = (this._format a for a in annotations when not a.$$tag) | ||
| return unless annotations.length | ||
| this._notify | ||
| method: 'loadAnnotations' | ||
| params: annotations | ||
| this | ||
| this.pluginInit = -> | ||
| onDiscoveryCallback = (source, origin, token) -> | ||
| bridge.createChannel(source, origin, token) | ||
| discovery.startDiscovery(onDiscoveryCallback) | ||
| beforeCreateAnnotation: (annotation, cb) -> | ||
| this._call | ||
| method: 'beforeCreateAnnotation' | ||
| params: this._format annotation | ||
| callback: cb | ||
| annotation | ||
| this.destroy = -> | ||
| # super doesnt work here :( | ||
| Annotator.Plugin::destroy.apply(this, arguments) | ||
| discovery.stopDiscovery() | ||
| createAnnotation: (annotation, cb) -> | ||
| this._call | ||
| method: 'createAnnotation' | ||
| params: this._format annotation | ||
| callback: cb | ||
| annotation | ||
| this.sync = (annotations, cb) -> | ||
| annotationSync.sync(annotations, cb) | ||
| updateAnnotation: (annotation, cb) -> | ||
| this._call | ||
| method: 'updateAnnotation' | ||
| params: this._format annotation | ||
| callback: cb | ||
| annotation | ||
| this.on = (event, fn) -> | ||
| bridge.on(event, fn) | ||
| deleteAnnotation: (annotation, cb) -> | ||
| this._call | ||
| method: 'deleteAnnotation' | ||
| params: this._format annotation | ||
| callback: cb | ||
| annotation | ||
| this.notify = (message) -> | ||
| bridge.notify(message) | ||
| sync: (annotations, cb) -> | ||
| annotations = (this._format a for a in annotations) | ||
| this._call | ||
| method: 'sync' | ||
| params: annotations | ||
| callback: cb | ||
| this | ||
| this.onConnect = (fn) -> | ||
| bridge.onConnect(fn) |
| @@ -0,0 +1,121 @@ | ||
| class CrossFrameBridge | ||
| options: | ||
| # Scope identifier to distinguish this channel from any others | ||
| scope: 'crossFrameBridge' | ||
| # Callback to invoke when a connection is established. The function is | ||
| # passed: | ||
| # - the newly created channel object | ||
| # - the window just connected to | ||
| onConnect: null | ||
| # Any callbacks for messages on the channel. Max one callback per method. | ||
| channelListeners: null | ||
| # Connected links to other frames | ||
| links: null | ||
| channelListeners: null | ||
| onConnectListeners: null | ||
| constructor: (options) -> | ||
| @options = $.extend(true, {}, @options, options) | ||
| @links = [] | ||
| @channelListeners = @options.channelListeners || {} | ||
| @onConnectListeners = [] | ||
| if typeof @options.onConnect == 'function' | ||
| @onConnectListeners.push(@options.onConnect) | ||
| createChannel: (source, origin, token) -> | ||
| # Set up a channel | ||
| scope = @options.scope + ':' + token | ||
| channelOptions = | ||
| window: source | ||
| origin: origin | ||
| scope: scope | ||
| onReady: (channel) => | ||
| for callback in @onConnectListeners | ||
| callback.call(this, channel, source) | ||
| channel = this._buildChannel channelOptions | ||
| # Attach channel message listeners | ||
| for own method, callback of @channelListeners | ||
| channel.bind method, callback | ||
| # Store the newly created channel in our collection | ||
| @links.push | ||
| channel: channel | ||
| window: source | ||
| channel | ||
| # Make a method call on all links, collect the results and pass them to a | ||
| # callback when all results are collected. Parameters: | ||
| # - options.method (required): name of remote method to call | ||
| # - options.params: parameters to pass to remote method | ||
| # - options.callback: called with array of results | ||
| call: (options) -> | ||
| _makeDestroyFn = (c) => | ||
| (error, reason) => | ||
| c.destroy() | ||
| @links = (l for l in @links when l.channel isnt c) | ||
| deferreds = @links.map (l) -> | ||
| d = $.Deferred().fail(_makeDestroyFn l.channel) | ||
| callOptions = { | ||
| method: options.method | ||
| params: options.params | ||
| success: (result) -> d.resolve result | ||
| error: (error, reason) -> | ||
| if error isnt 'timeout_error' | ||
| d.reject error, reason | ||
| else | ||
| d.resolve null | ||
| timeout: 1000 | ||
| } | ||
| l.channel.call callOptions | ||
| d.promise() | ||
| $.when(deferreds...) | ||
| .then (results...) => | ||
| options.callback? null, results | ||
| .fail (failure) => | ||
| options.callback? failure | ||
| # Publish a notification to all links | ||
| notify: (options) -> | ||
| for l in @links | ||
| l.channel.notify options | ||
| return | ||
| on: (method, callback) -> | ||
| if @channelListeners[method] | ||
| throw new Error("Listener '#{method}' already bound in CrossFrameBridge") | ||
| @channelListeners[method] = callback | ||
| for l in @links | ||
| l.channel.bind method, callback | ||
| return this | ||
| off: (method) -> | ||
| for l in @links | ||
| l.channel.unbind method | ||
| delete @channelListeners[method] | ||
| return this | ||
| # Add a function to be called upon a new connection | ||
| onConnect: (callback) -> | ||
| @onConnectListeners.push(callback) | ||
| this | ||
| # Construct a channel to another frame | ||
| _buildChannel: (options) -> | ||
| # jschannel chokes on FF and Chrome extension origins. | ||
| if (options.origin.match /^chrome-extension:\/\//) or | ||
| (options.origin.match /^resource:\/\//) | ||
| options = $.extend {}, options, {origin: '*'} | ||
| channel = Channel.build(options) | ||
| if angular? | ||
| angular.module('h').value('CrossFrameBridge', CrossFrameBridge) | ||
| else | ||
| Annotator.Plugin.Bridge.CrossFrameBridge = CrossFrameBridge |
| @@ -0,0 +1,148 @@ | ||
| # A module for establishing connections between multiple frames in the same | ||
| # document. This model requires one frame (and only one) to be designated the | ||
| # server (created with options.server: true) which can then connect to as | ||
| # many clients as required. Once a handshake between two frames has been | ||
| # completed the onDiscovery callback will be called with information about | ||
| # both frames. | ||
| # | ||
| # Example: | ||
| # | ||
| # // host.html | ||
| # var server = new CrossFrameDiscovery(window, {server: true}) | ||
| # server.startDiscovery(function (window, source, token) { | ||
| # // Establish a message bus to the new client window. | ||
| # server.stopDiscovery(); | ||
| # } | ||
| # | ||
| # // client.html | ||
| # var client = new CrossFrameDiscovery(window) | ||
| # client.startDiscovery(function (window, source, token) { | ||
| # // Establish a message bus to the new server window. | ||
| # server.stopDiscovery(); | ||
| # } | ||
| class CrossFrameDiscovery | ||
| # Origins allowed to communicate on the channel | ||
| server: false | ||
| # When this is true, this bridge will act as a server and, similar to DHCP, | ||
| # offer to connect to bridges in other frames it discovers. | ||
| origin: '*' | ||
| onDiscovery: null | ||
| requestInProgress: false | ||
| # Accepts a target window and an object of options. The window provided will | ||
| # act as a starting point for discovering other windows. | ||
| constructor: (@target, options={}) -> | ||
| @server = options.server if options.server | ||
| @origin = options.origin if options.origin | ||
| startDiscovery: (onDiscovery) -> | ||
| if @onDiscovery | ||
| throw new Error('CrossFrameDiscovery is already in progress, call .stopDiscovery() first') | ||
| # Find other frames that run the same discovery mechanism. Sends a beacon | ||
| # and listens for beacons. | ||
| # | ||
| # Parameters: | ||
| # onDiscovery: (source, origin, token) -> () | ||
| # When two frames discover each other, onDiscovery will be called on both | ||
| # sides with the same token string. | ||
| @onDiscovery = onDiscovery | ||
| # Listen to discovery messages from other frames | ||
| @target.addEventListener('message', this._onMessage, false) | ||
| # Send a discovery message to other frames to create channels | ||
| this._beacon() | ||
| return | ||
| stopDiscovery: => | ||
| # Remove the listener for discovery messages | ||
| @onDiscovery = null | ||
| @target.removeEventListener('message', this._onMessage) | ||
| return | ||
| # Send out a beacon to discover frames to connect with | ||
| _beacon: -> | ||
| beaconMessage = if @server | ||
| '__cross_frame_dhcp_offer' | ||
| else | ||
| '__cross_frame_dhcp_discovery' | ||
| # Starting at the top window, walk through all frames, and ping each frame | ||
| # that is not our own. | ||
| queue = [@target.top] | ||
| while queue.length | ||
| parent = queue.shift() | ||
| if parent isnt @target | ||
| parent.postMessage(beaconMessage, @origin) | ||
| for child in parent.frames | ||
| queue.push(child) | ||
| return | ||
| _onMessage: (event) => | ||
| {source, origin, data} = event | ||
| # Check if the message is at all related to our discovery mechanism | ||
| match = data.match? /^__cross_frame_dhcp_(discovery|offer|request|ack)(?::(\d+))?$/ | ||
| return unless match | ||
| # Read message type and optional token from message data | ||
| messageType = match[1] | ||
| token = match[2] | ||
| # Process the received message | ||
| {reply, discovered, token} = this._processMessage(messageType, token, origin) | ||
| if reply | ||
| source.postMessage '__cross_frame_dhcp_' + reply, origin | ||
| if discovered | ||
| @onDiscovery.call(null, source, origin, token) | ||
| return | ||
| _processMessage: (messageType, token, origin) -> | ||
| # Process an incoming message, returns: | ||
| # - a reply message | ||
| # - whether the discovery has completed | ||
| reply = null | ||
| discovered = false | ||
| if @server # We are configured as server | ||
| if messageType is 'discovery' | ||
| # A client joined the party. Offer it to connect. | ||
| reply = 'offer' | ||
| else if messageType is 'request' | ||
| # Create a channel with random identifier | ||
| token = this._generateToken() | ||
| reply = 'ack' + ':' + token | ||
| discovered = true | ||
| else if messageType is 'offer' or messageType is 'ack' | ||
| throw new Error(""" | ||
| A second CrossFrameDiscovery server has been detected at #{origin}. | ||
| This is unsupported and will cause unexpected behaviour.""") | ||
| else # We are configured as a client | ||
| if messageType is 'offer' | ||
| # The server joined the party, or replied to our discovery message. | ||
| # Request it to set up a channel if we did not already do so. | ||
| unless @requestInProgress | ||
| @requestInProgress = true # prevent creating two channels | ||
| reply = 'request' | ||
| else if messageType is 'ack' | ||
| # The other side opened a channel to us. We note its scope and create | ||
| # a matching channel end on this side. | ||
| @requestInProgress = false # value should not actually matter anymore. | ||
| discovered = true | ||
| return {reply: reply, discovered: discovered, token: token} | ||
| _generateToken: -> | ||
| ('' + Math.random()).replace(/\D/g, '') | ||
| if angular? | ||
| angular.module('h').value('CrossFrameDiscovery', CrossFrameDiscovery) | ||
| else | ||
| Annotator.Plugin.Bridge.CrossFrameDiscovery = CrossFrameDiscovery |
| @@ -0,0 +1,78 @@ | ||
| # Instantiates all objects used for cross frame discovery and communication. | ||
| class CrossFrameService | ||
| providers: null | ||
| this.inject = [ | ||
| '$rootScope', '$document', '$window', 'store', 'annotationUI' | ||
| 'CrossFrameDiscovery', 'CrossFrameBridge', | ||
| 'AnnotationSync', 'AnnotationUISync' | ||
| ] | ||
| constructor: ( | ||
| $rootScope, $document, $window, store, annotationUI | ||
| CrossFrameDiscovery, CrossFrameBridge, | ||
| AnnotationSync, AnnotationUISync | ||
| ) -> | ||
| @providers = [] | ||
| createDiscovery = -> | ||
| options = | ||
| server: true | ||
| new CrossFrameDiscovery($window, options) | ||
| # Set up the bridge plugin, which bridges the main annotation methods | ||
| # between the host page and the panel widget. | ||
| createBridge = -> | ||
| options = | ||
| scope: 'annotator:bridge' | ||
| new CrossFrameBridge(options) | ||
| createAnnotationSync = (bridge) -> | ||
| whitelist = ['target', 'document', 'uri'] | ||
| options = | ||
| formatter: (annotation) -> | ||
| formatted = {} | ||
| for k, v of annotation when k in whitelist | ||
| formatted[k] = v | ||
| formatted | ||
| parser: (annotation) -> | ||
| parsed = new store.AnnotationResource() | ||
| for k, v of annotation when k in whitelist | ||
| parsed[k] = v | ||
| parsed | ||
| emit: (args...) -> | ||
| $rootScope.$emit.call($rootScope, args...) | ||
| on: (event, handler) -> | ||
| $rootScope.$on(event, (event, args...) -> handler.apply(this, args)) | ||
| new AnnotationSync(bridge, options) | ||
| createAnnotationUISync = (bridge, annotationSync) -> | ||
| new AnnotationUISync($window, bridge, annotationSync, annotationUI) | ||
| addProvider = (channel) => | ||
| provider = {channel: channel, entities: []} | ||
| channel.call | ||
| method: 'getDocumentInfo' | ||
| success: (info) => | ||
| provider.entities = (link.href for link in info.metadata.link) | ||
| @providers.push(provider) | ||
| $rootScope.$emit('getDocumentInfo') | ||
| this.connect = -> | ||
| discovery = createDiscovery() | ||
| bridge = createBridge() | ||
| bridge.onConnect(addProvider) | ||
| annotationSync = createAnnotationSync(bridge) | ||
| annotationUISync = createAnnotationUISync(bridge, annotationSync) | ||
| onDiscoveryCallback = (source, origin, token) -> | ||
| bridge.createChannel(source, origin, token) | ||
| discovery.startDiscovery(onDiscoveryCallback) | ||
| this.notify = bridge.notify.bind(bridge) | ||
| this.notify = -> throw new Error('connect() must be called before notify()') | ||
| angular.module('h').service('crossframe', CrossFrameService) |
| @@ -0,0 +1,121 @@ | ||
| assert = chai.assert | ||
| sinon.assert.expose(assert, prefix: '') | ||
| describe 'AnnotationMapperService', -> | ||
| sandbox = sinon.sandbox.create() | ||
| $rootScope = null | ||
| fakeStore = null | ||
| fakeThreading = null | ||
| annotationMapper = null | ||
| beforeEach module('h') | ||
| beforeEach module ($provide) -> | ||
| fakeStore = | ||
| AnnotationResource: sandbox.stub().returns({}) | ||
| fakeThreading = | ||
| idTable: {} | ||
| $provide.value('store', fakeStore) | ||
| $provide.value('threading', fakeThreading) | ||
| return | ||
| beforeEach inject (_annotationMapper_, _$rootScope_) -> | ||
| $rootScope = _$rootScope_ | ||
| annotationMapper = _annotationMapper_ | ||
| afterEach: -> sandbox.restore() | ||
| describe '.loadAnnotations()', -> | ||
| it 'triggers the annotationLoaded event', -> | ||
| sandbox.stub($rootScope, '$emit') | ||
| annotations = [{id: 1}, {id: 2}, {id: 3}] | ||
| annotationMapper.loadAnnotations(annotations) | ||
| assert.called($rootScope.$emit) | ||
| assert.calledWith($rootScope.$emit, 'annotationsLoaded', [{}, {}, {}]) | ||
| it 'triggers the annotationUpdated event for each annotation in the threading cache', -> | ||
| sandbox.stub($rootScope, '$emit') | ||
| annotations = [{id: 1}, {id: 2}, {id: 3}] | ||
| cached = {message: {id: 1, $$tag: 'tag1'}} | ||
| fakeThreading.idTable[1] = cached | ||
| annotationMapper.loadAnnotations(annotations) | ||
| assert.called($rootScope.$emit) | ||
| assert.calledWith($rootScope.$emit, 'annotationUpdated', cached.message) | ||
| it 'replaces the properties on the cached annotation with those from the loaded one', -> | ||
| sandbox.stub($rootScope, '$emit') | ||
| annotations = [{id: 1, url: 'http://example.com'}] | ||
| cached = {message: {id: 1, $$tag: 'tag1'}} | ||
| fakeThreading.idTable[1] = cached | ||
| annotationMapper.loadAnnotations(annotations) | ||
| assert.called($rootScope.$emit) | ||
| assert.calledWith($rootScope.$emit, 'annotationUpdated', { | ||
| id: 1 | ||
| url: 'http://example.com' | ||
| }) | ||
| it 'excludes cached annotations from the annotationLoaded event', -> | ||
| sandbox.stub($rootScope, '$emit') | ||
| annotations = [{id: 1, url: 'http://example.com'}] | ||
| cached = {message: {id: 1, $$tag: 'tag1'}} | ||
| fakeThreading.idTable[1] = cached | ||
| annotationMapper.loadAnnotations(annotations) | ||
| assert.called($rootScope.$emit) | ||
| assert.calledWith($rootScope.$emit, 'annotationsLoaded', []) | ||
| describe '.createAnnotation()', -> | ||
| it 'creates a new annotaton resource', -> | ||
| ann = {} | ||
| fakeStore.AnnotationResource.returns(ann) | ||
| ret = annotationMapper.createAnnotation(ann) | ||
| assert.equal(ret, ann) | ||
| it 'creates a new resource with the new keyword', -> | ||
| ann = {} | ||
| fakeStore.AnnotationResource.returns(ann) | ||
| ret = annotationMapper.createAnnotation() | ||
| assert.calledWithNew(fakeStore.AnnotationResource) | ||
| it 'emits the "beforeAnnotationCreated" event', -> | ||
| sandbox.stub($rootScope, '$emit') | ||
| ann = {} | ||
| fakeStore.AnnotationResource.returns(ann) | ||
| ret = annotationMapper.createAnnotation() | ||
| assert.calledWith($rootScope.$emit, 'beforeAnnotationCreated', ann) | ||
| describe '.deleteAnnotation()', -> | ||
| it 'deletes the annotation on the server', -> | ||
| p = Promise.resolve() | ||
| ann = {$delete: sandbox.stub().returns(p)} | ||
| annotationMapper.deleteAnnotation(ann) | ||
| assert.called(ann.$delete) | ||
| it 'triggers the "annotationDeleted" event on success', -> | ||
| sandbox.stub($rootScope, '$emit') | ||
| p = Promise.resolve() | ||
| ann = {$delete: sandbox.stub().returns(p)} | ||
| annotationMapper.deleteAnnotation(ann) | ||
| p.then -> | ||
| assert.called($rootScope.$emit) | ||
| assert.calledWith($rootScope.$emit, 'annotationDeleted', ann) | ||
| it 'does nothing on error', -> | ||
| sandbox.stub($rootScope, '$emit') | ||
| p = Promise.reject() | ||
| ann = {$delete: sandbox.stub().returns(p)} | ||
| annotationMapper.deleteAnnotation(ann) | ||
| p.catch -> | ||
| assert.notCalled($rootScope.$emit) | ||
| it 'returns the annotation', -> | ||
| p = Promise.resolve() | ||
| ann = {$delete: sandbox.stub().returns(p)} | ||
| assert.equal(annotationMapper.deleteAnnotation(ann), ann) | ||
| @@ -0,0 +1,352 @@ | ||
| assert = chai.assert | ||
| sinon.assert.expose(assert, prefix: '') | ||
| describe 'AnnotationSync', -> | ||
| sandbox = sinon.sandbox.create() | ||
| publish = null | ||
| fakeBridge = null | ||
| createAnnotationSync = null | ||
| createChannel = -> {notify: sandbox.stub()} | ||
| options = null | ||
| PARENT_WINDOW = 'PARENT_WINDOW' | ||
| beforeEach module('h') | ||
| beforeEach inject (AnnotationSync, $rootScope) -> | ||
| listeners = {} | ||
| publish = ({method, params}) -> listeners[method]('ctx', params) | ||
| fakeWindow = parent: PARENT_WINDOW | ||
| fakeBridge = | ||
| on: sandbox.spy((method, fn) -> listeners[method] = fn) | ||
| call: sandbox.stub() | ||
| notify: sandbox.stub() | ||
| onConnect: sandbox.stub() | ||
| links: [ | ||
| {window: PARENT_WINDOW, channel: createChannel()} | ||
| {window: 'ANOTHER_WINDOW', channel: createChannel()} | ||
| {window: 'THIRD_WINDOW', channel: createChannel()} | ||
| ] | ||
| # TODO: Fix this hack to remove pre-existing bound listeners. | ||
| $rootScope.$$listeners = [] | ||
| options = | ||
| on: sandbox.spy (event, fn) -> | ||
| $rootScope.$on(event, (evt, args...) -> fn(args...)) | ||
| emit: sandbox.spy($rootScope.$emit.bind($rootScope)) | ||
| createAnnotationSync = -> | ||
| new AnnotationSync(fakeBridge, options) | ||
| afterEach: -> sandbox.restore() | ||
| describe 'the bridge connection', -> | ||
| it 'sends over the current annotation cache', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache['tag1'] = ann | ||
| channel = createChannel() | ||
| fakeBridge.onConnect.yield(channel) | ||
| assert.called(channel.notify) | ||
| assert.calledWith(channel.notify, { | ||
| method: 'loadAnnotations' | ||
| params: [tag: 'tag1', msg: ann] | ||
| }) | ||
| it 'does nothing if the cache is empty', -> | ||
| annSync = createAnnotationSync() | ||
| channel = createChannel() | ||
| fakeBridge.onConnect.yield(channel) | ||
| assert.notCalled(channel.notify) | ||
| describe '.getAnnotationForTag', -> | ||
| it 'returns the annotation if present in the cache', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache['tag1'] = ann | ||
| cached = annSync.getAnnotationForTag('tag1') | ||
| assert.equal(cached, ann) | ||
| it 'returns null if not present in the cache', -> | ||
| annSync = createAnnotationSync() | ||
| cached = annSync.getAnnotationForTag('tag1') | ||
| assert.isNull(cached) | ||
| describe 'channel event handlers', -> | ||
| assertBroadcast = (channelEvent, publishEvent) -> | ||
| it 'broadcasts the "' + publishEvent + '" event over the local event bus', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| publish(method: channelEvent, params: {msg: ann}) | ||
| assert.called(options.emit) | ||
| assert.calledWith(options.emit, publishEvent, ann) | ||
| assertReturnValue = (channelEvent) -> | ||
| it 'returns a formatted annotation to be sent to the calling frame', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| ret = publish(method: channelEvent, params: {msg: ann}) | ||
| assert.deepEqual(ret, {tag: 'tag1', msg: ann}) | ||
| assertCacheState = (channelEvent) -> | ||
| it 'removes an existing entry from the cache before the event is triggered', -> | ||
| options.emit = -> assert(!annSync.cache['tag1']) | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache['tag1'] = ann | ||
| publish(method: channelEvent, params: {msg: ann}) | ||
| it 'ensures the annotation is inserted in the cache', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| publish(method: channelEvent, params: {msg: ann}) | ||
| assert.equal(annSync.cache['tag1'], ann) | ||
| describe 'the "beforeCreateAnnotation" event', -> | ||
| assertBroadcast('beforeCreateAnnotation', 'beforeAnnotationCreated') | ||
| assertReturnValue('beforeCreateAnnotation') | ||
| assertCacheState('beforeCreateAnnotation') | ||
| describe 'the "createAnnotation" event', -> | ||
| assertBroadcast('createAnnotation', 'annotationCreated') | ||
| assertReturnValue('createAnnotation') | ||
| assertCacheState('createAnnotation') | ||
| describe 'the "updateAnnotation" event', -> | ||
| assertBroadcast('updateAnnotation', 'annotationUpdated') | ||
| assertBroadcast('updateAnnotation', 'beforeAnnotationUpdated') | ||
| assertReturnValue('updateAnnotation') | ||
| assertCacheState('updateAnnotation') | ||
| describe 'the "deleteAnnotation" event', -> | ||
| assertBroadcast('deleteAnnotation', 'annotationDeleted') | ||
| assertReturnValue('deleteAnnotation') | ||
| it 'removes an existing entry from the cache before the event is triggered', -> | ||
| options.emit = -> assert(!annSync.cache['tag1']) | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache['tag1'] = ann | ||
| publish(method: 'deleteAnnotation', params: {msg: ann}) | ||
| it 'removes the annotation from the cache', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| publish(method: 'deleteAnnotation', params: {msg: ann}) | ||
| assert(!annSync.cache['tag1']) | ||
| describe 'the "sync" event', -> | ||
| it 'returns an array of parsed and formatted annotations', -> | ||
| options.parser = sinon.spy((x) -> x) | ||
| options.formatter = sinon.spy((x) -> x) | ||
| annSync = createAnnotationSync() | ||
| annotations = [{id: 1, $$tag: 'tag1'}, {id: 2, $$tag: 'tag2'}, {id: 3, $$tag: 'tag3'}] | ||
| bodies = ({msg: ann, tag: ann.$$tag} for ann in annotations) | ||
| ret = publish(method: 'sync', params: bodies) | ||
| assert.deepEqual(ret, ret) | ||
| assert.called(options.parser) | ||
| assert.called(options.formatter) | ||
| describe 'the "loadAnnotations" event', -> | ||
| it 'publishes the "loadAnnotations" event with parsed annotations', -> | ||
| options.parser = sinon.spy((x) -> x) | ||
| annSync = createAnnotationSync() | ||
| annotations = [{id: 1, $$tag: 'tag1'}, {id: 2, $$tag: 'tag2'}, {id: 3, $$tag: 'tag3'}] | ||
| bodies = ({msg: ann, tag: ann.$$tag} for ann in annotations) | ||
| ret = publish(method: 'loadAnnotations', params: bodies) | ||
| assert.called(options.parser) | ||
| assert.calledWith(options.emit, 'loadAnnotations', annotations) | ||
| describe 'application event handlers', -> | ||
| describe 'the "beforeAnnotationCreated" event', -> | ||
| it 'proxies the event over the bridge', -> | ||
| ann = {id: 1} | ||
| annSync = createAnnotationSync() | ||
| options.emit('beforeAnnotationCreated', ann) | ||
| assert.called(fakeBridge.call) | ||
| assert.calledWith(fakeBridge.call, { | ||
| method: 'beforeCreateAnnotation', | ||
| params: {msg: ann, tag: ann.$$tag}, | ||
| callback: sinon.match.func | ||
| }) | ||
| it 'returns early if the annotation has a tag', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| options.emit('beforeAnnotationCreated', ann) | ||
| assert.notCalled(fakeBridge.call) | ||
| describe 'the "annotationCreated" event', -> | ||
| it 'proxies the event over the bridge', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache.tag1 = ann | ||
| options.emit('annotationCreated', ann) | ||
| assert.called(fakeBridge.call) | ||
| assert.calledWith(fakeBridge.call, { | ||
| method: 'createAnnotation', | ||
| params: {msg: ann, tag: ann.$$tag}, | ||
| callback: sinon.match.func | ||
| }) | ||
| it 'returns early if the annotation has a tag but is not cached', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationCreated', ann) | ||
| assert.notCalled(fakeBridge.call) | ||
| it 'returns early if the annotation has no tag', -> | ||
| ann = {id: 1} | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationCreated', ann) | ||
| assert.notCalled(fakeBridge.call) | ||
| describe 'the "annotationUpdated" event', -> | ||
| it 'proxies the event over the bridge', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache.tag1 = ann | ||
| options.emit('annotationUpdated', ann) | ||
| assert.called(fakeBridge.call) | ||
| assert.calledWith(fakeBridge.call, { | ||
| method: 'updateAnnotation', | ||
| params: {msg: ann, tag: ann.$$tag}, | ||
| callback: sinon.match.func | ||
| }) | ||
| it 'returns early if the annotation has a tag but is not cached', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationUpdated', ann) | ||
| assert.notCalled(fakeBridge.call) | ||
| it 'returns early if the annotation has no tag', -> | ||
| ann = {id: 1} | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationUpdated', ann) | ||
| assert.notCalled(fakeBridge.call) | ||
| describe 'the "annotationDeleted" event', -> | ||
| it 'proxies the event over the bridge', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache.tag1 = ann | ||
| options.emit('annotationDeleted', ann) | ||
| assert.called(fakeBridge.call) | ||
| assert.calledWith(fakeBridge.call, { | ||
| method: 'deleteAnnotation', | ||
| params: {msg: ann, tag: ann.$$tag}, | ||
| callback: sinon.match.func | ||
| }) | ||
| it 'parses the result returned by the call', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| options.parser = sinon.spy((x) -> x) | ||
| annSync = createAnnotationSync() | ||
| annSync.cache.tag1 = ann | ||
| options.emit('annotationDeleted', ann) | ||
| body = {msg: {}, tag: 'tag1'} | ||
| fakeBridge.call.yieldTo('callback', null, [body]) | ||
| assert.called(options.parser) | ||
| assert.calledWith(options.parser, {}) | ||
| it 'removes the annotation from the cache on success', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache.tag1 = ann | ||
| options.emit('annotationDeleted', ann) | ||
| fakeBridge.call.yieldTo('callback', null, []) | ||
| assert.isUndefined(annSync.cache.tag1) | ||
| it 'does not remove the annotation from the cache if an error occurs', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| annSync.cache.tag1 = ann | ||
| options.emit('annotationDeleted', ann) | ||
| fakeBridge.call.yieldTo('callback', new Error('Error'), []) | ||
| assert.equal(annSync.cache.tag1, ann) | ||
| it 'returns early if the annotation has a tag but is not cached', -> | ||
| ann = {id: 1, $$tag: 'tag1'} | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationDeleted', ann) | ||
| assert.notCalled(fakeBridge.call) | ||
| it 'returns early if the annotation has no tag', -> | ||
| ann = {id: 1} | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationDeleted', ann) | ||
| assert.notCalled(fakeBridge.call) | ||
| describe 'the "annotationsLoaded" event', -> | ||
| it 'formats the provided annotations', -> | ||
| annotations = [{id: 1}, {id: 2}, {id: 3}] | ||
| options.formatter = sinon.spy((x) -> x) | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationsLoaded', annotations) | ||
| assert.calledWith(options.formatter, {id: 1}) | ||
| assert.calledWith(options.formatter, {id: 2}) | ||
| assert.calledWith(options.formatter, {id: 3}) | ||
| it 'sends the annotations over the bridge', -> | ||
| annotations = [{id: 1}, {id: 2}, {id: 3}] | ||
| options.formatter = sinon.spy((x) -> x) | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationsLoaded', annotations) | ||
| assert.called(fakeBridge.notify) | ||
| assert.calledWith(fakeBridge.notify, { | ||
| method: 'loadAnnotations', | ||
| params: {msg: a, tag: a.$$tag} for a in annotations | ||
| }) | ||
| it 'does not send annotations that have already been tagged', -> | ||
| annotations = [{id: 1, $$tag: 'tag1'}, {id: 2, $$tag: 'tag2'}, {id: 3}] | ||
| options.formatter = sinon.spy((x) -> x) | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationsLoaded', annotations) | ||
| assert.called(fakeBridge.notify) | ||
| assert.calledWith(fakeBridge.notify, { | ||
| method: 'loadAnnotations', | ||
| params: [{msg: annotations[2], tag: annotations[2].$$tag}] | ||
| }) | ||
| it 'returns early if no annotations are loaded', -> | ||
| annSync = createAnnotationSync() | ||
| options.emit('annotationsLoaded', []) | ||
| assert.notCalled(fakeBridge.notify) |
| @@ -0,0 +1,57 @@ | ||
| assert = chai.assert | ||
| sinon.assert.expose(assert, prefix: '') | ||
| describe 'AnnotationUI', -> | ||
| annotationUI = null | ||
| beforeEach module('h') | ||
| beforeEach inject (_annotationUI_) -> | ||
| annotationUI = _annotationUI_ | ||
| describe '.focusAnnotations()', -> | ||
| it 'adds the passed annotations to the focusedAnnotationMap', -> | ||
| annotationUI.focusAnnotations([{id: 1}, {id: 2}, {id: 3}]) | ||
| assert.deepEqual(annotationUI.focusedAnnotationMap, { | ||
| 1: true, 2: true, 3: true | ||
| }) | ||
| it 'replaces any annotations originally in the map', -> | ||
| annotationUI.focusedAnnotationMap = {1: true} | ||
| annotationUI.focusAnnotations([{id: 2}, {id: 3}]) | ||
| assert.deepEqual(annotationUI.focusedAnnotationMap, { | ||
| 2: true, 3: true | ||
| }) | ||
| describe '.selectAnnotations()', -> | ||
| it 'adds the passed annotations to the selectedAnnotationMap', -> | ||
| annotationUI.selectAnnotations([{id: 1}, {id: 2}, {id: 3}]) | ||
| assert.deepEqual(annotationUI.selectedAnnotationMap, { | ||
| 1: true, 2: true, 3: true | ||
| }) | ||
| it 'replaces any annotations originally in the map', -> | ||
| annotationUI.selectedAnnotationMap = {1: true} | ||
| annotationUI.selectAnnotations([{id: 2}, {id: 3}]) | ||
| assert.deepEqual(annotationUI.selectedAnnotationMap, { | ||
| 2: true, 3: true | ||
| }) | ||
| describe '.xorSelectedAnnotations()', -> | ||
| it 'adds annotations missing from the selectedAnnotationMap', -> | ||
| annotationUI.selectedAnnotationMap = {1: true, 2: true} | ||
| annotationUI.xorSelectedAnnotations([{id: 3}, {id: 4}]) | ||
| assert.deepEqual(annotationUI.selectedAnnotationMap, { | ||
| 1: true, 2: true, 3: true, 4: true | ||
| }) | ||
| it 'removes annotations already in the selectedAnnotationMap', -> | ||
| annotationUI.selectedAnnotationMap = {1: true, 2: true} | ||
| annotationUI.xorSelectedAnnotations([{id: 1}, {id: 2}]) | ||
| assert.deepEqual(annotationUI.selectedAnnotationMap, {}) | ||
| describe '.removeSelectedAnnotation', -> | ||
| it 'removes an annotation from the selectedAnnotationMap', -> | ||
| annotationUI.selectedAnnotationMap = {1: true, 2: true, 3: true} | ||
| annotationUI.removeSelectedAnnotation(id: 2) | ||
| assert.deepEqual(annotationUI.selectedAnnotationMap, { | ||
| 1: true, 3: true | ||
| }) |
| @@ -0,0 +1,179 @@ | ||
| assert = chai.assert | ||
| sinon.assert.expose(assert, prefix: '') | ||
| describe 'AnnotationUISync', -> | ||
| sandbox = sinon.sandbox.create() | ||
| uiSync = null | ||
| publish = null | ||
| fakeBridge = null | ||
| fakeAnnotationUI = null | ||
| fakeAnnotationSync = null | ||
| createAnnotationUISync = null | ||
| createChannel = -> {notify: sandbox.stub()} | ||
| PARENT_WINDOW = 'PARENT_WINDOW' | ||
| beforeEach module('h') | ||
| beforeEach inject (AnnotationUISync) -> | ||
| listeners = {} | ||
| publish = ({method, params}) -> listeners[method]('ctx', params) | ||
| fakeWindow = parent: PARENT_WINDOW | ||
| fakeBridge = | ||
| on: sandbox.spy((method, fn) -> listeners[method] = fn) | ||
| notify: sandbox.stub() | ||
| onConnect: sandbox.stub() | ||
| links: [ | ||
| {window: PARENT_WINDOW, channel: createChannel()} | ||
| {window: 'ANOTHER_WINDOW', channel: createChannel()} | ||
| {window: 'THIRD_WINDOW', channel: createChannel()} | ||
| ] | ||
| fakeAnnotationSync = | ||
| getAnnotationForTag: (tag) -> {id: Number(tag.replace('tag', ''))} | ||
| fakeAnnotationUI = | ||
| focusAnnotations: sandbox.stub() | ||
| selectAnnotations: sandbox.stub() | ||
| xorSelectedAnnotations: sandbox.stub() | ||
| tool: 'comment' | ||
| visibleHighlights: false | ||
| createAnnotationUISync = -> | ||
| new AnnotationUISync( | ||
| fakeWindow, fakeBridge, fakeAnnotationSync, fakeAnnotationUI) | ||
| afterEach: -> sandbox.restore() | ||
| describe 'on bridge connection', -> | ||
| describe 'when the source is not the parent window', -> | ||
| it 'broadcasts the tool/visibility settings to the channel', -> | ||
| channel = createChannel() | ||
| fakeBridge.onConnect.callsArgWith(0, channel, {}) | ||
| createAnnotationUISync() | ||
| assert.calledWith(channel.notify, { | ||
| method: 'setTool' | ||
| params: 'comment' | ||
| }) | ||
| assert.calledWith(channel.notify, { | ||
| method: 'setVisibleHighlights' | ||
| params: false | ||
| }) | ||
| describe 'when the source is the parent window', -> | ||
| it 'does nothing', -> | ||
| channel = notify: sandbox.stub() | ||
| fakeBridge.onConnect.callsArgWith(0, channel, PARENT_WINDOW) | ||
| createAnnotationUISync() | ||
| assert.notCalled(channel.notify) | ||
| describe 'on "back" event', -> | ||
| it 'sends the "hideFrame" message to the host only', -> | ||
| createAnnotationUISync() | ||
| publish({method: 'back'}) | ||
| assert.calledWith(fakeBridge.links[0].channel.notify, method: 'hideFrame') | ||
| assert.notCalled(fakeBridge.links[1].channel.notify) | ||
| assert.notCalled(fakeBridge.links[2].channel.notify) | ||
| describe 'on "open" event', -> | ||
| it 'sends the "showFrame" message to the host only', -> | ||
| createAnnotationUISync() | ||
| publish({method: 'open'}) | ||
| assert.calledWith(fakeBridge.links[0].channel.notify, method: 'showFrame') | ||
| assert.notCalled(fakeBridge.links[1].channel.notify) | ||
| assert.notCalled(fakeBridge.links[2].channel.notify) | ||
| describe 'on "showEditor" event', -> | ||
| it 'sends the "showFrame" message to the host only', -> | ||
| createAnnotationUISync() | ||
| publish({method: 'showEditor'}) | ||
| assert.calledWith(fakeBridge.links[0].channel.notify, method: 'showFrame') | ||
| assert.notCalled(fakeBridge.links[1].channel.notify) | ||
| assert.notCalled(fakeBridge.links[2].channel.notify) | ||
| describe 'on "showAnnotations" event', -> | ||
| it 'sends the "showFrame" message to the host only', -> | ||
| createAnnotationUISync() | ||
| publish({ | ||
| method: 'showAnnotations', | ||
| params: ['tag1', 'tag2', 'tag3'] | ||
| }) | ||
| assert.calledWith(fakeBridge.links[0].channel.notify, method: 'showFrame') | ||
| assert.notCalled(fakeBridge.links[1].channel.notify) | ||
| assert.notCalled(fakeBridge.links[2].channel.notify) | ||
| it 'updates the annotationUI to include the shown annotations', -> | ||
| createAnnotationUISync() | ||
| publish({ | ||
| method: 'showAnnotations', | ||
| params: ['tag1', 'tag2', 'tag3'] | ||
| }) | ||
| assert.called(fakeAnnotationUI.xorSelectedAnnotations) | ||
| assert.calledWith(fakeAnnotationUI.xorSelectedAnnotations, [ | ||
| {id: 1}, {id: 2}, {id: 3} | ||
| ]) | ||
| describe 'on "focusAnnotations" event', -> | ||
| it 'updates the annotationUI to show the provided annotations', -> | ||
| createAnnotationUISync() | ||
| publish({ | ||
| method: 'focusAnnotations', | ||
| params: ['tag1', 'tag2', 'tag3'] | ||
| }) | ||
| assert.called(fakeAnnotationUI.focusAnnotations) | ||
| assert.calledWith(fakeAnnotationUI.focusAnnotations, [ | ||
| {id: 1}, {id: 2}, {id: 3} | ||
| ]) | ||
| describe 'on "toggleAnnotationSelection" event', -> | ||
| it 'updates the annotationUI to show the provided annotations', -> | ||
| createAnnotationUISync() | ||
| publish({ | ||
| method: 'toggleAnnotationSelection', | ||
| params: ['tag1', 'tag2', 'tag3'] | ||
| }) | ||
| assert.called(fakeAnnotationUI.selectAnnotations) | ||
| assert.calledWith(fakeAnnotationUI.selectAnnotations, [ | ||
| {id: 1}, {id: 2}, {id: 3} | ||
| ]) | ||
| describe 'on "setTool" event', -> | ||
| it 'updates the annotationUI with the new tool', -> | ||
| createAnnotationUISync() | ||
| publish({ | ||
| method: 'setTool', | ||
| params: 'highlighter' | ||
| }) | ||
| assert.equal(fakeAnnotationUI.tool, 'highlighter') | ||
| it 'notifies the other frames of the change', -> | ||
| createAnnotationUISync() | ||
| publish({ | ||
| method: 'setTool', | ||
| params: 'highlighter' | ||
| }) | ||
| assert.calledWith(fakeBridge.notify, { | ||
| method: 'setTool' | ||
| params: 'highlighter' | ||
| }) | ||
| describe 'on "setVisibleHighlights" event', -> | ||
| it 'updates the annotationUI with the new value', -> | ||
| createAnnotationUISync() | ||
| publish({ | ||
| method: 'setVisibleHighlights', | ||
| params: true | ||
| }) | ||
| assert.equal(fakeAnnotationUI.visibleHighlights, true) | ||
| it 'notifies the other frames of the change', -> | ||
| createAnnotationUISync() | ||
| publish({ | ||
| method: 'setVisibleHighlights', | ||
| params: true | ||
| }) | ||
| assert.calledWith(fakeBridge.notify, { | ||
| method: 'setVisibleHighlights' | ||
| params: true | ||
| }) |
| @@ -0,0 +1,117 @@ | ||
| assert = chai.assert | ||
| sinon.assert.expose(assert, prefix: '') | ||
| describe 'Annotator.Plugin.Bridge', -> | ||
| Bridge = null | ||
| fakeCFDiscovery = null | ||
| fakeCFBridge = null | ||
| fakeAnnotationSync = null | ||
| sandbox = sinon.sandbox.create() | ||
| createBridge = (options) -> | ||
| defaults = | ||
| on: sandbox.stub() | ||
| emit: sandbox.stub() | ||
| element = document.createElement('div') | ||
| return new Annotator.Plugin.Bridge(element, $.extend({}, defaults, options)) | ||
| beforeEach -> | ||
| fakeCFDiscovery = | ||
| startDiscovery: sandbox.stub() | ||
| stopDiscovery: sandbox.stub() | ||
| fakeCFBridge = | ||
| createChannel: sandbox.stub() | ||
| onConnect: sandbox.stub() | ||
| notify: sandbox.stub() | ||
| on: sandbox.stub() | ||
| fakeAnnotationSync = | ||
| sync: sandbox.stub() | ||
| Bridge = Annotator.Plugin.Bridge | ||
| sandbox.stub(Bridge, 'AnnotationSync').returns(fakeAnnotationSync) | ||
| sandbox.stub(Bridge, 'CrossFrameDiscovery').returns(fakeCFDiscovery) | ||
| sandbox.stub(Bridge, 'CrossFrameBridge').returns(fakeCFBridge) | ||
| afterEach -> | ||
| sandbox.restore() | ||
| describe 'constructor', -> | ||
| it 'instantiates the CrossFrameDiscovery component', -> | ||
| createBridge() | ||
| assert.called(Bridge.CrossFrameDiscovery) | ||
| assert.calledWith(Bridge.CrossFrameDiscovery, window) | ||
| it 'passes the options along to the bridge', -> | ||
| createBridge(server: true) | ||
| assert.called(Bridge.CrossFrameDiscovery) | ||
| assert.calledWith(Bridge.CrossFrameDiscovery, window, server: true) | ||
| it 'instantiates the CrossFrameBridge component', -> | ||
| createBridge() | ||
| assert.called(Bridge.CrossFrameBridge) | ||
| assert.calledWith(Bridge.CrossFrameDiscovery) | ||
| it 'passes the options along to the bridge', -> | ||
| createBridge(scope: 'myscope') | ||
| assert.called(Bridge.CrossFrameBridge) | ||
| assert.calledWith(Bridge.CrossFrameBridge, scope: 'myscope') | ||
| it 'instantiates the AnnotationSync component', -> | ||
| createBridge() | ||
| assert.called(Bridge.AnnotationSync) | ||
| it 'passes along options to AnnotationSync', -> | ||
| formatter = (x) -> x | ||
| createBridge(formatter: formatter) | ||
| assert.called(Bridge.AnnotationSync) | ||
| assert.calledWith(Bridge.AnnotationSync, fakeCFBridge, { | ||
| on: sinon.match.func | ||
| emit: sinon.match.func | ||
| formatter: formatter | ||
| }) | ||
| describe '.pluginInit', -> | ||
| it 'starts the discovery of new channels', -> | ||
| bridge = createBridge() | ||
| bridge.pluginInit() | ||
| assert.called(fakeCFDiscovery.startDiscovery) | ||
| it 'creates a channel when a new frame is discovered', -> | ||
| bridge = createBridge() | ||
| bridge.pluginInit() | ||
| fakeCFDiscovery.startDiscovery.yield('SOURCE', 'ORIGIN', 'TOKEN') | ||
| assert.called(fakeCFBridge.createChannel) | ||
| assert.calledWith(fakeCFBridge.createChannel, 'SOURCE', 'ORIGIN', 'TOKEN') | ||
| describe '.destroy', -> | ||
| it 'stops the discovery of new frames', -> | ||
| bridge = createBridge() | ||
| bridge.destroy() | ||
| assert.called(fakeCFDiscovery.stopDiscovery) | ||
| describe '.sync', -> | ||
| it 'syncs the annotations with the other frame', -> | ||
| bridge = createBridge() | ||
| bridge.sync() | ||
| assert.called(fakeAnnotationSync.sync) | ||
| describe '.on', -> | ||
| it 'proxies the call to the bridge', -> | ||
| bridge = createBridge() | ||
| bridge.on('event', 'arg') | ||
| assert.calledWith(fakeCFBridge.on, 'event', 'arg') | ||
| describe '.notify', -> | ||
| it 'proxies the call to the bridge', -> | ||
| bridge = createBridge() | ||
| bridge.notify(method: 'method') | ||
| assert.calledWith(fakeCFBridge.notify, method: 'method') | ||
| describe '.onConnect', -> | ||
| it 'proxies the call to the bridge', -> | ||
| bridge = createBridge() | ||
| fn = -> | ||
| bridge.onConnect(fn) | ||
| assert.calledWith(fakeCFBridge.onConnect, fn) |
| @@ -0,0 +1 @@ | ||
| ES6Promise.polyfill() |
| @@ -0,0 +1,261 @@ | ||
| assert = chai.assert | ||
| sinon.assert.expose assert, prefix: null | ||
| describe 'CrossFrameBridge', -> | ||
| sandbox = sinon.sandbox.create() | ||
| createBridge = null | ||
| createChannel = null | ||
| beforeEach module('h') | ||
| beforeEach inject (CrossFrameBridge) -> | ||
| createBridge = (options) -> | ||
| new CrossFrameBridge(options) | ||
| createChannel = -> | ||
| call: sandbox.stub() | ||
| bind: sandbox.stub() | ||
| unbind: sandbox.stub() | ||
| notify: sandbox.stub() | ||
| destroy: sandbox.stub() | ||
| sandbox.stub(Channel, 'build') | ||
| afterEach -> | ||
| sandbox.restore() | ||
| describe '.createChannel', -> | ||
| it 'creates a new channel with the provided options', -> | ||
| Channel.build.returns(createChannel()) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| assert.called(Channel.build) | ||
| assert.calledWith(Channel.build, { | ||
| window: 'WINDOW' | ||
| origin: 'ORIGIN' | ||
| scope: 'crossFrameBridge:TOKEN' | ||
| onReady: sinon.match.func | ||
| }) | ||
| it 'adds the channel to the .links property', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| assert.include(bridge.links, {channel: channel, window: 'WINDOW'}) | ||
| it 'registers any existing listeners on the channel', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.on('message1', sinon.spy()) | ||
| bridge.on('message2', sinon.spy()) | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| assert.called(channel.bind) | ||
| assert.calledWith(channel.bind, 'message1', sinon.match.func) | ||
| assert.calledWith(channel.bind, 'message2', sinon.match.func) | ||
| it 'returns the newly created channel', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| ret = bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| assert.equal(ret, channel) | ||
| describe '.call', -> | ||
| it 'forwards the call to every created channel', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.call({method: 'method1', params: 'params1'}) | ||
| assert.called(channel.call) | ||
| message = channel.call.lastCall.args[0] | ||
| assert.equal(message.method, 'method1') | ||
| assert.equal(message.params, 'params1') | ||
| it 'provides a timeout', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.call({method: 'method1', params: 'params1'}) | ||
| message = channel.call.lastCall.args[0] | ||
| assert.isNumber(message.timeout) | ||
| it 'calls options.callback when all channels return successfully', -> | ||
| channel1 = createChannel() | ||
| channel2 = createChannel() | ||
| channel1.call.yieldsTo('success', 'result1') | ||
| channel2.call.yieldsTo('success', 'result2') | ||
| callback = sandbox.stub() | ||
| bridge = createBridge() | ||
| Channel.build.returns(channel1) | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| Channel.build.returns(channel2) | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.call({method: 'method1', params: 'params1', callback: callback}) | ||
| assert.called(callback) | ||
| assert.calledWith(callback, null, ['result1', 'result2']) | ||
| it 'calls options.callback with an error when one or more channels fail', -> | ||
| err = new Error('Uh oh') | ||
| channel1 = createChannel() | ||
| channel1.call.yieldsTo('error', err, 'A reason for the error') | ||
| channel2 = createChannel() | ||
| channel2.call.yieldsTo('success', 'result2') | ||
| callback = sandbox.stub() | ||
| bridge = createBridge() | ||
| Channel.build.returns(channel1) | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| Channel.build.returns(channel2) | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.call({method: 'method1', params: 'params1', callback: callback}) | ||
| assert.called(callback) | ||
| assert.calledWith(callback, err) | ||
| it 'destroys the channel when a call fails', -> | ||
| channel = createChannel() | ||
| channel.call.yieldsTo('error', new Error(''), 'A reason for the error') | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.call({method: 'method1', params: 'params1', callback: sandbox.stub()}) | ||
| assert.called(channel.destroy) | ||
| it 'no longer publishes to a channel that has had an errored response', -> | ||
| channel = createChannel() | ||
| channel.call.yieldsTo('error', new Error(''), 'A reason for the error') | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.call({method: 'method1', params: 'params1', callback: sandbox.stub()}) | ||
| bridge.call({method: 'method1', params: 'params1', callback: sandbox.stub()}) | ||
| assert.calledOnce(channel.call) | ||
| it 'treats a timeout as a success with no result', -> | ||
| channel = createChannel() | ||
| channel.call.yieldsTo('error', 'timeout_error', 'timeout') | ||
| Channel.build.returns(channel) | ||
| callback = sandbox.stub() | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.call({method: 'method1', params: 'params1', callback: callback}) | ||
| assert.called(callback) | ||
| assert.calledWith(callback, null, [null]) | ||
| it 'returns a promise object', -> | ||
| channel = createChannel() | ||
| channel.call.yieldsTo('error', 'timeout_error', 'timeout') | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| ret = bridge.call({method: 'method1', params: 'params1'}) | ||
| assert.isFunction(ret.then) | ||
| describe '.notify', -> | ||
| it 'publishes the message on every created channel', -> | ||
| channel = createChannel() | ||
| message = {method: 'message1', params: 'params'} | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.notify(message) | ||
| assert.called(channel.notify) | ||
| assert.calledWith(channel.notify, message) | ||
| describe '.on', -> | ||
| it 'registers an event listener on all created channels', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.on('message1', sandbox.spy()) | ||
| assert.called(channel.bind) | ||
| assert.calledWith(channel.bind, 'message1', sinon.match.func) | ||
| it 'only allows one message to be registered per method', -> | ||
| bridge = createBridge() | ||
| bridge.on('message1', sandbox.spy()) | ||
| assert.throws -> | ||
| bridge.on('message1', sandbox.spy()) | ||
| describe '.off', -> | ||
| it 'removes the event listener from the created channels', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.off('message1', sandbox.spy()) | ||
| it 'ensures that the event is no longer bound when new channels are created', -> | ||
| channel1 = createChannel() | ||
| channel2 = createChannel() | ||
| Channel.build.returns(channel1) | ||
| bridge = createBridge() | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| bridge.off('message1', sandbox.spy()) | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| assert.notCalled(channel2.bind) | ||
| describe '.onConnect', -> | ||
| it 'adds a callback that is called when a new channel is connected', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| Channel.build.yieldsTo('onReady', channel) | ||
| callback = sandbox.stub() | ||
| bridge = createBridge() | ||
| bridge.onConnect(callback) | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| assert.called(callback) | ||
| assert.calledWith(callback, channel) | ||
| it 'allows multiple callbacks to be registered', -> | ||
| channel = createChannel() | ||
| Channel.build.returns(channel) | ||
| Channel.build.yieldsTo('onReady', channel) | ||
| callback1 = sandbox.stub() | ||
| callback2 = sandbox.stub() | ||
| bridge = createBridge() | ||
| bridge.onConnect(callback1) | ||
| bridge.onConnect(callback2) | ||
| bridge.createChannel('WINDOW', 'ORIGIN', 'TOKEN') | ||
| assert.called(callback1) | ||
| assert.called(callback2) | ||