Commit 0ddccaa8 authored by Randall Leeds's avatar Randall Leeds

Merge pull request #1878 from hypothesis/syncbridge

Pull annotator out of the sidebar
parents 7083e5a7 ff0bc489
# Wraps the annotation store to trigger events for the CRUD actions
class AnnotationMapperService
this.$inject = ['$rootScope', 'threading', 'store']
constructor: ($rootScope, threading, store) ->
this.setupAnnotation = (ann) -> ann
this.loadAnnotations = (annotations) ->
annotations = for annotation in annotations
container = threading.idTable[annotation.id]
if container?.message
angular.copy(annotation, container.message)
$rootScope.$emit('annotationUpdated', container.message)
continue
else
annotation
annotations = (new store.AnnotationResource(a) for a in annotations)
$rootScope.$emit('annotationsLoaded', annotations)
this.createAnnotation = (annotation) ->
annotation = new store.AnnotationResource(annotation)
$rootScope.$emit('beforeAnnotationCreated', annotation)
annotation
this.deleteAnnotation = (annotation) ->
annotation.$delete(id: annotation.id).then ->
$rootScope.$emit('annotationDeleted', annotation)
annotation
angular.module('h').service('annotationMapper', AnnotationMapperService)
class AnnotationSync
# Default configuration
options:
# Formats an annotation into a message body for sending across the bridge.
formatter: (annotation) -> annotation
# Recieves an annotation extracted from the message body received
# via the bridge and returns an annotation for use in the local app.
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
# Function used to emit annotation events
emit: (event, args...) ->
throw new Error('options.emit unspecified for AnnotationSync.')
# Function used to register handlers for annotation events
on: (event, handler) ->
throw new Error('options.on unspecified for AnnotationSync.')
# Cache of annotations which have crossed the bridge for fast, encapsulated
# association of annotations received in arguments to window-local copies.
cache: null
constructor: (@bridge, options) ->
@options = $.extend(true, {}, @options, options)
@cache = {}
@_on = @options.on
@_emit = @options.emit
# Listen locally for interesting events
for event, handler of @_eventListeners
this._on(event, handler.bind(this))
# Register remotely invokable methods
for method, func of @_channelListeners
@bridge.on(method, func.bind(this))
# Upon new connections, send over the items in our cache
onConnect = (channel) =>
this._syncCache(channel)
@bridge.onConnect(onConnect)
# Provide a public interface to the annotation cache so that other
# sync services can lookup annotations by tag.
getAnnotationForTag: (tag) ->
@cache[tag] or null
sync: (annotations, cb) ->
annotations = (this._format a for a in annotations)
@bridge.call
method: 'sync'
params: annotations
callback: cb
this
# Handlers for messages arriving through a channel
_channelListeners:
'beforeCreateAnnotation': (txn, body) ->
annotation = this._parse(body)
delete @cache[annotation.$$tag]
@_emit 'beforeAnnotationCreated', annotation
@cache[annotation.$$tag] = annotation
this._format annotation
'createAnnotation': (txn, body) ->
annotation = this._parse(body)
delete @cache[annotation.$$tag]
@_emit 'annotationCreated', annotation
@cache[annotation.$$tag] = annotation
this._format annotation
'updateAnnotation': (txn, body) ->
annotation = this._parse(body)
delete @cache[annotation.$$tag]
@_emit('beforeAnnotationUpdated', annotation)
@_emit('annotationUpdated', annotation)
@cache[annotation.$$tag] = annotation
this._format annotation
'deleteAnnotation': (txn, body) ->
annotation = this._parse(body)
delete @cache[annotation.$$tag]
@_emit('annotationDeleted', annotation)
res = this._format(annotation)
res
'sync': (ctx, bodies) ->
(this._format(this._parse(b)) for b in bodies)
'loadAnnotations': (txn, bodies) ->
annotations = (this._parse(a) for a in bodies)
@_emit('loadAnnotations', annotations)
# Handlers for events coming from this frame, to send them across the channel
_eventListeners:
'beforeAnnotationCreated': (annotation) ->
return if annotation.$$tag?
this._mkCallRemotelyAndParseResults('beforeCreateAnnotation')(annotation)
'annotationCreated': (annotation) ->
return unless annotation.$$tag? and @cache[annotation.$$tag]
this._mkCallRemotelyAndParseResults('createAnnotation')(annotation)
'annotationUpdated': (annotation) ->
return unless annotation.$$tag? and @cache[annotation.$$tag]
this._mkCallRemotelyAndParseResults('updateAnnotation')(annotation)
'annotationDeleted': (annotation) ->
return unless annotation.$$tag? and @cache[annotation.$$tag]
onFailure = (err) =>
delete @cache[annotation.$$tag] unless err
this._mkCallRemotelyAndParseResults('deleteAnnotation', onFailure)(annotation)
'annotationsLoaded': (annotations) ->
bodies = (this._format a for a in annotations when not a.$$tag)
return unless bodies.length
@bridge.notify
method: 'loadAnnotations'
params: bodies
_syncCache: (channel) ->
# Synchronise (here to there) the items in our cache
annotations = (this._format a for t, a of @cache)
if annotations.length
channel.notify
method: 'loadAnnotations'
params: annotations
_mkCallRemotelyAndParseResults: (method, callBack) ->
(annotation) =>
# Wrap the callback function to first parse returned items
wrappedCallback = (failure, results) =>
unless failure?
this._parseResults results
callBack? failure, results
# Call the remote method
options =
method: method
callback: wrappedCallback
params: this._format(annotation)
@bridge.call(options)
# Parse returned message bodies to update cache with any changes made remotely
_parseResults: (results) ->
for bodies in results
bodies = [].concat(bodies) # Ensure always an array.
this._parse(body) for body in bodies when body != null
return
# Assign a non-enumerable tag to objects which cross the bridge.
# This tag is used to identify the objects between message.
_tag: (ann, tag) ->
return ann if ann.$$tag
tag = tag or window.btoa(Math.random())
Object.defineProperty(ann, '$$tag', value: tag)
@cache[tag] = ann
ann
# Parse a message body from a RPC call with the provided parser.
_parse: (body) ->
local = @cache[body.tag]
remote = @options.parser(body.msg)
if local?
merged = @options.merge(local, remote)
else
merged = remote
this._tag(merged, body.tag)
# Format an annotation into an RPC message body with the provided formatter.
_format: (ann) ->
this._tag(ann)
{
tag: ann.$$tag
msg: @options.formatter(ann)
}
if angular?
angular.module('h').value('AnnotationSync', AnnotationSync)
else
Annotator.Plugin.CrossFrame.AnnotationSync = AnnotationSync
# Holds the current state between the current state of the annotator in the
# attached iframes for display in the sidebar. This covers both tool and
# rendered state such as selected highlights.
createAnnotationUI = ->
value = (selection) ->
if Object.keys(selection).length then selection else null
{
TOOL_COMMENT: 'comment'
TOOL_HIGHLIGHT: 'highlight'
tool: 'comment'
visibleHighlights: false
# Contains a map of annotation id:true pairs.
focusedAnnotationMap: null
# Contains a map of annotation id:true pairs.
selectedAnnotationMap: null
###*
# @ngdoc method
# @name annotationUI.focusedAnnotations
# @returns nothing
# @description Takes an array of annotations and uses them to set
# the focusedAnnotationMap.
###
focusAnnotations: (annotations) ->
selection = {}
selection[id] = true for {id} in annotations
@focusedAnnotationMap = value(selection)
###*
# @ngdoc method
# @name annotationUI.hasSelectedAnnotations
# @returns true if there are any selected annotations.
###
hasSelectedAnnotations: ->
!!@selectedAnnotationMap
###*
# @ngdoc method
# @name annotationUI.isAnnotationSelected
# @returns true if the provided annotation is selected.
###
isAnnotationSelected: (id) ->
!!@selectedAnnotationMap?[id]
###*
# @ngdoc method
# @name annotationUI.selectAnnotations
# @returns nothing
# @description Takes an array of annotation objects and uses them to
# set the selectedAnnotationMap property.
###
selectAnnotations: (annotations) ->
selection = {}
selection[id] = true for {id} in annotations
@selectedAnnotationMap = value(selection)
###*
# @ngdoc method
# @name annotationUI.xorSelectedAnnotations()
# @returns nothing
# @description takes an array of annotations and adds them to the
# selectedAnnotationMap if not present otherwise removes them.
###
xorSelectedAnnotations: (annotations) ->
selection = angular.extend({}, @selectedAnnotationMap)
for {id} in annotations
if selection[id]
delete selection[id]
else
selection[id] = true
@selectedAnnotationMap = value(selection)
###*
# @ngdoc method
# @name annotationUI.removeSelectedAnnotation()
# @returns nothing
# @description removes an annotation from the current selection.
###
removeSelectedAnnotation: (annotation) ->
selection = angular.extend({}, @selectedAnnotationMap)
if selection
delete selection[annotation.id]
@selectedAnnotationMap = value(selection)
###*
# @ngdoc method
# @name annotationUI.clearSelectedAnnotations()
# @returns nothing
# @description removes all annotations from the current selection.
###
clearSelectedAnnotations: ->
@selectedAnnotationMap = null
}
angular.module('h').factory('annotationUI', createAnnotationUI)
# 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 {Bridge} 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: ($rootScope, $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.selectAnnotations(annotations)
focusAnnotations: (ctx, tags=[]) ->
annotations = getAnnotationsByTags(tags)
annotationUI.focusAnnotations(annotations)
toggleAnnotationSelection: (ctx, tags=[]) ->
annotations = getAnnotationsByTags(tags)
annotationUI.xorSelectedAnnotations(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)
# Because the channel events are all outside of the angular framework we
# need to inform Angular that it needs to re-check it's state and re-draw
# any UI that may have been affected by the handlers.
ensureDigest = (fn) ->
->
fn.apply(this, arguments)
$rootScope.$digest()
for own channel, listener of channelListeners
bridge.on(channel, ensureDigest(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)
$ = 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
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
annotationUpdated: (annotation) =>
return unless annotation.$$tag? and @cache[annotation.$$tag]
this.updateAnnotation annotation
this
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
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
beforeCreateAnnotation: (annotation, cb) ->
this._call
method: 'beforeCreateAnnotation'
params: this._format annotation
callback: cb
annotation
createAnnotation: (annotation, cb) ->
this._call
method: 'createAnnotation'
params: this._format annotation
callback: cb
annotation
updateAnnotation: (annotation, cb) ->
this._call
method: 'updateAnnotation'
params: this._format annotation
callback: cb
annotation
deleteAnnotation: (annotation, cb) ->
this._call
method: 'deleteAnnotation'
params: this._format annotation
callback: cb
annotation
sync: (annotations, cb) ->
annotations = (this._format a for a in annotations)
this._call
method: 'sync'
params: annotations
callback: cb
this
$ = Annotator.$
# 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
# Class for establishing a messaging connection to the parent sidebar as well
# as keeping the annotation state in sync with the sidebar application, this
# frame acts as the bridge client, the sidebar is the server. This plugin
# can also be used to send messages through to the sidebar using the
# notify method.
CrossFrame = class Annotator.Plugin.CrossFrame extends Annotator.Plugin
constructor: (elem, options) ->
super
opts = extract(options, 'server')
discovery = new CrossFrame.Discovery(window, opts)
opts = extract(options, 'scope')
bridge = new CrossFrame.Bridge(opts)
opts = extract(options, 'on', 'emit', 'formatter', 'parser')
annotationSync = new CrossFrame.AnnotationSync(bridge, opts)
this.pluginInit = ->
onDiscoveryCallback = (source, origin, token) ->
bridge.createChannel(source, origin, token)
discovery.startDiscovery(onDiscoveryCallback)
this.destroy = ->
# super doesnt work here :(
Annotator.Plugin::destroy.apply(this, arguments)
discovery.stopDiscovery()
this.sync = (annotations, cb) ->
annotationSync.sync(annotations, cb)
this.on = (event, fn) ->
bridge.on(event, fn)
this.notify = (message) ->
bridge.notify(message)
this.onConnect = (fn) ->
bridge.onConnect(fn)
......@@ -71,8 +71,24 @@ configureTemplates = ['$sceDelegateProvider', ($sceDelegateProvider) ->
]
angular.module('h', imports)
setupCrossFrame = ['crossframe', (crossframe) -> crossframe.connect()]
setupStreamer = [
'$http', '$window', 'streamer'
($http, $window, streamer) ->
clientId = uuid.v4()
streamer.clientId = clientId
$.ajaxSetup(headers: {'X-Client-Id': clientId})
$http.defaults.headers.common['X-Client-Id'] = clientId
]
module = angular.module('h', imports)
.config(configureDocument)
.config(configureLocation)
.config(configureRoutes)
.config(configureTemplates)
unless mocha? # Crude method of detecting test environment.
module.run(setupCrossFrame)
module.run(setupStreamer)
......@@ -18,6 +18,21 @@ class Auth
_checkingToken = false
@user = undefined
# TODO: Remove this once Auth has been migrated.
$rootScope.$on 'beforeAnnotationCreated', (event, annotation) =>
annotation.user = @user
annotation.permissions = {}
annotator.publish('beforeAnnotationCreated', annotation)
$rootScope.$on 'annotationCreated', (event, annotation) =>
annotator.publish('annotationCreated', annotation)
$rootScope.$on 'annotationUpdated', (event, annotation) =>
annotator.publish('annotationUpdated', annotation)
$rootScope.$on 'beforeAnnotationUpdated', (event, annotation) =>
annotator.publish('beforeAnnotationUpdated', annotation)
# Fired when the identity-service successfully requests authentication.
# Sets up the Annotator.Auth plugin instance and the auth.user property.
# It sets a flag between that time period to indicate that the token is
......
class Bridge
options:
# Scope identifier to distinguish this channel from any others
scope: 'bridge'
# 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 Bridge")
@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('Bridge', Bridge)
else
Annotator.Plugin.CrossFrame.Bridge = Bridge
# Watch the UI state and update scope properties.
class AnnotationUIController
this.$inject = ['$rootScope', '$scope', 'annotationUI']
constructor: ( $rootScope, $scope, annotationUI ) ->
$rootScope.$watch (-> annotationUI.selectedAnnotationMap), (map={}) ->
count = Object.keys(map).length
$scope.selectedAnnotationsCount = count
if count
$scope.selectedAnnotations = map
else
$scope.selectedAnnotations = null
$rootScope.$watch (-> annotationUI.focusedAnnotationMap), (map={}) ->
$scope.focusedAnnotations = map
$rootScope.$on 'annotationDeleted', (event, annotation) ->
annotationUI.removeSelectedAnnotation(annotation)
class AppController
this.$inject = [
'$document', '$location', '$route', '$scope', '$window',
'annotator', 'auth', 'drafts', 'identity',
'permissions', 'streamer', 'streamfilter'
'$controller', '$document', '$location', '$route', '$scope', '$window',
'auth', 'drafts', 'identity',
'permissions', 'streamer', 'streamfilter', 'annotationUI',
'annotationMapper', 'threading'
]
constructor: (
$document, $location, $route, $scope, $window,
annotator, auth, drafts, identity,
permissions, streamer, streamfilter,
$controller, $document, $location, $route, $scope, $window,
auth, drafts, identity,
permissions, streamer, streamfilter, annotationUI,
annotationMapper, threading
) ->
{plugins, host, providers} = annotator
$controller(AnnotationUIController, {$scope})
$scope.auth = auth
isFirstRun = $location.search().hasOwnProperty('firstrun')
......@@ -24,10 +45,10 @@ class AppController
return unless data?.length
switch action
when 'create', 'update', 'past'
annotator.loadAnnotations data
annotationMapper.loadAnnotations data
when 'delete'
for annotation in data
annotator.publish 'annotationDeleted', (annotation)
$scope.$emit('annotationDeleted', annotation)
streamer.onmessage = (data) ->
return if !data or data.type != 'annotation-notification'
......@@ -43,12 +64,12 @@ class AppController
# Clean up any annotations that need to be unloaded.
for id, container of $scope.threading.idTable when container.message
# Remove annotations not belonging to this user when highlighting.
if annotator.tool is 'highlight' and annotation.user != auth.user
annotator.publish 'annotationDeleted', container.message
if annotationUI.tool is 'highlight' and annotation.user != auth.user
$scope.$emit('annotationDeleted', container.message)
drafts.remove annotation
# Remove annotations the user is not authorized to view.
else if not permissions.permits 'read', container.message, auth.user
annotator.publish 'annotationDeleted', container.message
$scope.$emit('annotationDeleted', container.message)
drafts.remove container.message
$scope.$watch 'sort.name', (name) ->
......@@ -69,7 +90,7 @@ class AppController
# Update any edits in progress.
for draft in drafts.all()
annotator.publish 'beforeAnnotationCreated', draft
$scope.$emit('beforeAnnotationCreated', draft)
# Reopen the streamer.
streamer.close()
......@@ -95,8 +116,7 @@ class AppController
$scope.clearSelection = ->
$scope.search.query = ''
$scope.selectedAnnotations = null
$scope.selectedAnnotationsCount = 0
annotationUI.clearSelectedAnnotations()
$scope.dialog = visible: false
......@@ -109,22 +129,21 @@ class AppController
update: (query) ->
unless angular.equals $location.search()['q'], query
$location.search('q', query or null)
delete $scope.selectedAnnotations
delete $scope.selectedAnnotationsCount
annotationUI.clearSelectedAnnotations()
$scope.sort = name: 'Location'
$scope.threading = plugins.Threading
$scope.threading = threading
$scope.threadRoot = $scope.threading?.root
class AnnotationViewerController
this.$inject = [
'$location', '$routeParams', '$scope',
'annotator', 'streamer', 'store', 'streamfilter'
'streamer', 'store', 'streamfilter', 'annotationMapper'
]
constructor: (
$location, $routeParams, $scope,
annotator, streamer, store, streamfilter
streamer, store, streamfilter, annotationMapper
) ->
# Tells the view that these annotations are standalone
$scope.isEmbedded = false
......@@ -141,11 +160,10 @@ class AnnotationViewerController
id = $routeParams.id
store.SearchResource.get _id: id, ({rows}) ->
annotator.loadAnnotations(rows)
annotationMapper.loadAnnotations(rows)
$scope.threadRoot = children: [$scope.threading.getContainer(id)]
store.SearchResource.get references: id, ({rows}) ->
annotator.loadAnnotations(rows)
annotationMapper.loadAnnotations(rows)
streamfilter
.setMatchPolicyIncludeAny()
......@@ -156,12 +174,12 @@ class AnnotationViewerController
class ViewerController
this.$inject = [
'$scope', '$route',
'annotator', 'auth', 'flash', 'streamer', 'streamfilter', 'store'
'$scope', '$route', 'annotationUI', 'crossframe', 'annotationMapper',
'auth', 'flash', 'streamer', 'streamfilter', 'store'
]
constructor: (
$scope, $route,
annotator, auth, flash, streamer, streamfilter, store
$scope, $route, annotationUI, crossframe, annotationMapper,
auth, flash, streamer, streamfilter, store
) ->
# Tells the view that these annotations are embedded into the owner doc
$scope.isEmbedded = true
......@@ -171,49 +189,47 @@ class ViewerController
loadAnnotations = ->
query = limit: 200
if annotator.tool is 'highlight'
if annotationUI.tool is 'highlight'
return unless auth.user
query.user = auth.user
for p in annotator.providers
for p in crossframe.providers
for e in p.entities when e not in loaded
loaded.push e
store.SearchResource.get angular.extend(uri: e, query), (results) ->
annotator.loadAnnotations(results.rows)
r = store.SearchResource.get angular.extend(uri: e, query), (results) ->
annotationMapper.loadAnnotations(results.rows)
streamfilter.resetFilter().addClause('/uri', 'one_of', loaded)
if auth.user and annotator.tool is 'highlight'
if auth.user and annotationUI.tool is 'highlight'
streamfilter.addClause('/user', 'equals', auth.user)
streamer.send({filter: streamfilter.getFilter()})
$scope.$watch (-> annotator.tool), (newVal, oldVal) ->
$scope.$watch (-> annotationUI.tool), (newVal, oldVal) ->
return if newVal is oldVal
$route.reload()
$scope.$watchCollection (-> annotator.providers), loadAnnotations
$scope.$watchCollection (-> crossframe.providers), loadAnnotations
$scope.focus = (annotation) ->
if angular.isObject annotation
highlights = [annotation.$$tag]
else
highlights = []
for p in annotator.providers
p.channel.notify
method: 'focusAnnotations'
params: highlights
crossframe.notify
method: 'focusAnnotations'
params: highlights
$scope.scrollTo = (annotation) ->
if angular.isObject annotation
for p in annotator.providers
p.channel.notify
method: 'scrollToAnnotation'
params: annotation.$$tag
crossframe.notify
method: 'scrollToAnnotation'
params: annotation.$$tag
$scope.shouldShowThread = (container) ->
if $scope.selectedAnnotations? and not container.parent.parent
$scope.selectedAnnotations[container.message?.id]
if annotationUI.hasSelectedAnnotations() and not container.parent.parent
annotationUI.isAnnotationSelected(container.message?.id)
else
true
......@@ -224,3 +240,4 @@ angular.module('h')
.controller('AppController', AppController)
.controller('ViewerController', ViewerController)
.controller('AnnotationViewerController', AnnotationViewerController)
.controller('AnnotationUIController', AnnotationUIController)
# Instantiates all objects used for cross frame discovery and communication.
class CrossFrameService
providers: null
this.inject = [
'$rootScope', '$document', '$window', 'store', 'annotationUI'
'Discovery', 'Bridge',
'AnnotationSync', 'AnnotationUISync'
]
constructor: (
$rootScope, $document, $window, store, annotationUI
Discovery, Bridge,
AnnotationSync, AnnotationUISync
) ->
@providers = []
createDiscovery = ->
options =
server: true
new Discovery($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 Bridge(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.$apply ->
$rootScope.$emit.call($rootScope, args...)
on: (event, handler) ->
$rootScope.$apply ->
$rootScope.$on(event, (event, args...) -> handler.apply(this, args))
new AnnotationSync(bridge, options)
createAnnotationUISync = (bridge, annotationSync) ->
new AnnotationUISync($rootScope, $window, bridge, annotationSync, annotationUI)
addProvider = (channel) =>
provider = {channel: channel, entities: []}
channel.call
method: 'getDocumentInfo'
success: (info) =>
$rootScope.$apply =>
provider.entities = (link.href for link in info.metadata.link)
@providers.push(provider)
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)
......@@ -26,14 +26,15 @@ validate = (value) ->
#
# `AnnotationController` provides an API for the annotation directive. It
# manages the interaction between the domain and view models and uses the
# {@link annotator annotator service} for persistence.
# {@link annotationMapper AnnotationMapper service} for persistence.
###
AnnotationController = [
'$document', '$scope', '$timeout',
'annotator', 'auth', 'drafts', 'flash', 'permissions', 'timeHelpers'
($document, $scope, $timeout,
annotator, auth, drafts, flash, permissions, timeHelpers
) ->
'$scope', '$timeout', '$rootScope', '$document',
'auth', 'drafts', 'flash', 'permissions',
'timeHelpers', 'annotationUI', 'annotationMapper'
($scope, $timeout, $rootScope, $document,
auth, drafts, flash, permissions,
timeHelpers, annotationUI, annotationMapper) ->
@annotation = {}
@action = 'view'
@document = null
......@@ -44,7 +45,7 @@ AnnotationController = [
@showDiff = undefined
@timestamp = null
highlight = annotator.tool is 'highlight'
highlight = annotationUI.tool is 'highlight'
model = $scope.annotationGet()
original = null
vm = this
......@@ -93,7 +94,7 @@ AnnotationController = [
###
this.delete = ->
if confirm "Are you sure you want to delete this annotation?"
annotator.deleteAnnotation model
annotationMapper.deleteAnnotation model
###*
# @ngdoc method
......@@ -114,7 +115,7 @@ AnnotationController = [
this.revert = ->
drafts.remove model
if @action is 'create'
annotator.publish 'annotationDeleted', model
$rootScope.$emit('annotationDeleted', model)
else
this.render()
@action = 'view'
......@@ -150,10 +151,10 @@ AnnotationController = [
switch @action
when 'create'
model.$create().then ->
annotator.publish 'annotationCreated', model
$rootScope.$emit('annotationCreated', model)
when 'delete', 'edit'
model.$update(id: model.id).then ->
annotator.publish 'annotationUpdated', model
$rootScope.$emit('annotationUpdated', model)
@editing = false
@action = 'view'
......@@ -173,7 +174,7 @@ AnnotationController = [
# Construct the reply.
references = [references..., id]
reply = annotator.createAnnotation {references, uri}
reply = annotationMapper.createAnnotation({references, uri})
if auth.user?
if permissions.isPublic model.permissions
......@@ -270,7 +271,7 @@ AnnotationController = [
highlight = false # skip this on future updates
model.permissions = permissions.private()
model.$create().then ->
annotator.publish 'annotationCreated', model
$rootScope.$emit('annotationCreated', model)
highlight = false # skip this on future updates
else
drafts.add model, => this.revert()
......@@ -298,9 +299,9 @@ AnnotationController = [
# value is used to signal whether the annotation is being displayed inside
# an embedded widget.
###
annotation = [
'$document', 'annotator',
($document, annotator) ->
annotationDirective = [
'$document',
($document) ->
linkFn = (scope, elem, attrs, [ctrl, thread, threadFilter, counter]) ->
# Observe the embedded attribute
attrs.$observe 'annotationEmbedded', (value) ->
......@@ -352,4 +353,4 @@ annotation = [
angular.module('h')
.controller('AnnotationController', AnnotationController)
.directive('annotation', annotation)
.directive('annotation', annotationDirective)
# 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 Discovery(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 Discovery(window)
# client.startDiscovery(function (window, source, token) {
# // Establish a message bus to the new server window.
# server.stopDiscovery();
# }
class Discovery
# 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('Discovery 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 Discovery 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('Discovery', Discovery)
else
Annotator.Plugin.CrossFrame.Discovery = Discovery
......@@ -52,10 +52,25 @@ class Annotator.Guest extends Annotator
delete @options.app
this.addPlugin 'Bridge',
cfOptions =
scope: 'annotator:bridge'
on: (event, handler) =>
this.subscribe(event, handler)
emit: (event, args...) =>
switch event
# AnnotationSync tries to emit some events without taking actions.
# We catch them and perform the right action (which will then emit
# the event for real)
when 'annotationDeleted'
this.deleteAnnotation(args...)
when 'loadAnnotations'
this.loadAnnotations(args...)
# Other events can simply be emitted.
else
this.publish(event, args)
formatter: (annotation) =>
formatted = {}
formatted['uri'] = @getHref()
formatted.uri = @getHref()
for k, v of annotation when k isnt 'anchors'
formatted[k] = v
# Work around issue in jschannel where a repeated object is considered
......@@ -63,13 +78,9 @@ class Annotator.Guest extends Annotator
if formatted.document?.title
formatted.document.title = formatted.document.title.slice()
formatted
onConnect: (source, origin, scope) =>
@panel = this._setupXDM
window: source
origin: origin
scope: "#{scope}:provider"
onReady: =>
this.publish('panelReady')
this.addPlugin('CrossFrame', cfOptions)
@crossframe = this._connectAnnotationUISync(this.plugins.CrossFrame)
# Load plugins
for own name, opts of @options
......@@ -99,7 +110,7 @@ class Annotator.Guest extends Annotator
annotations = (hl.annotation for hl in highlights)
# Announce the new positions, so that the sidebar knows
this.plugins.Bridge.sync(annotations)
this.plugins.CrossFrame.sync(annotations)
# Watch for removed highlights, and update positions in sidebar
this.subscribe "highlightRemoved", (highlight) =>
......@@ -118,7 +129,7 @@ class Annotator.Guest extends Annotator
delete highlight.anchor.target.pos
# Announce the new positions, so that the sidebar knows
this.plugins.Bridge.sync([highlight.annotation])
this.plugins.CrossFrame.sync([highlight.annotation])
# Utility function to remove the hash part from a URL
_removeHash: (url) ->
......@@ -142,35 +153,22 @@ class Annotator.Guest extends Annotator
metadata.link?.forEach (link) => link.href = @_removeHash link.href
metadata
_setupXDM: (options) ->
# jschannel chokes FF and Chrome extension origins.
if (options.origin.match /^chrome-extension:\/\//) or
(options.origin.match /^resource:\/\//)
options.origin = '*'
channel = Channel.build options
channel
.bind('onEditorHide', this.onEditorHide)
.bind('onEditorSubmit', this.onEditorSubmit)
.bind('focusAnnotations', (ctx, tags=[]) =>
_connectAnnotationUISync: (crossframe) ->
crossframe.onConnect(=> this.publish('panelReady'))
crossframe.on('onEditorHide', this.onEditorHide)
crossframe.on('onEditorSubmit', this.onEditorSubmit)
crossframe.on 'focusAnnotations', (ctx, tags=[]) =>
for hl in @anchoring.getHighlights()
if hl.annotation.$$tag in tags
hl.setFocused true
else
hl.setFocused false
)
.bind('scrollToAnnotation', (ctx, tag) =>
crossframe.on 'scrollToAnnotation', (ctx, tag) =>
for hl in @anchoring.getHighlights()
if hl.annotation.$$tag is tag
hl.scrollTo()
return
)
.bind('getDocumentInfo', (trans) =>
crossframe.on 'getDocumentInfo', (trans) =>
(@plugins.PDF?.getMetaData() ? Promise.reject())
.then (md) =>
trans.complete
......@@ -180,18 +178,14 @@ class Annotator.Guest extends Annotator
trans.complete
uri: @getHref()
metadata: @getMetadata()
.catch (e) ->
trans.delayReturn(true)
)
.bind('setTool', (ctx, name) =>
crossframe.on 'setTool', (ctx, name) =>
@tool = name
this.publish 'setTool', name
)
.bind('setVisibleHighlights', (ctx, state) =>
crossframe.on 'setVisibleHighlights', (ctx, state) =>
this.publish 'setVisibleHighlights', state
)
_setupWrapper: ->
@wrapper = @element
......@@ -239,31 +233,31 @@ class Annotator.Guest extends Annotator
createAnnotation: ->
annotation = super
this.plugins.Bridge.sync([annotation])
this.plugins.CrossFrame.sync([annotation])
annotation
showAnnotations: (annotations) =>
@panel?.notify
@crossframe?.notify
method: "showAnnotations"
params: (a.$$tag for a in annotations)
toggleAnnotationSelection: (annotations) =>
@panel?.notify
@crossframe?.notify
method: "toggleAnnotationSelection"
params: (a.$$tag for a in annotations)
updateAnnotations: (annotations) =>
@panel?.notify
@crossframe?.notify
method: "updateAnnotations"
params: (a.$$tag for a in annotations)
showEditor: (annotation) =>
@panel?.notify
@crossframe?.notify
method: "showEditor"
params: annotation.$$tag
focusAnnotations: (annotations) =>
@panel?.notify
@crossframe?.notify
method: "focusAnnotations"
params: (a.$$tag for a in annotations)
......@@ -328,7 +322,8 @@ class Annotator.Guest extends Annotator
# toggle: should this toggle membership in an existing selection?
selectAnnotations: (annotations, toggle) =>
if toggle
# Tell sidebar to add these annotations to the sidebar
# Tell sidebar to add these annotations to the sidebar if not already
# selected, otherwise remove them.
this.toggleAnnotationSelection annotations
else
# Tell sidebar to show the viewer for these annotations
......@@ -358,7 +353,7 @@ class Annotator.Guest extends Annotator
(event.metaKey or event.ctrlKey)
setTool: (name) ->
@panel?.notify
@crossframe?.notify
method: 'setTool'
params: name
......@@ -366,7 +361,7 @@ class Annotator.Guest extends Annotator
setVisibleHighlights: (shouldShowHighlights) ->
return if @visibleHighlights == shouldShowHighlights
@panel?.notify
@crossframe?.notify
method: 'setVisibleHighlights'
params: shouldShowHighlights
......@@ -385,11 +380,11 @@ class Annotator.Guest extends Annotator
# Open the sidebar
showFrame: ->
@panel?.notify method: 'open'
@crossframe?.notify method: 'open'
# Close the sidebar
hideFrame: ->
@panel?.notify method: 'back'
@crossframe?.notify method: 'back'
addToken: (token) =>
@api.notify
......
......@@ -26,265 +26,11 @@ renderFactory = ['$$rAF', ($$rAF) ->
]
class Hypothesis extends Annotator
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
'annotationDeleted': 'annotationDeleted'
'annotationsLoaded': 'digest'
# Plugin configuration
options:
noDocAccess: true
Threading: {}
# Internal state
providers: null
host: null
tool: 'comment'
visibleHighlights: false
this.$inject = ['$document', '$window', 'store']
constructor: ( $document, $window, store ) ->
super ($document.find 'body')
@providers = []
@store = store
# Load plugins
for own name, opts of @options
if not @plugins[name] and name of Annotator.Plugin
this.addPlugin(name, opts)
# Set up the bridge plugin, which bridges the main annotation methods
# between the host page and the panel widget.
whitelist = ['target', 'document', 'uri']
this.addPlugin 'Bridge',
gateway: true
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
onConnect: (source, origin, scope) =>
options =
window: source
origin: origin
scope: "#{scope}:provider"
onReady: => if source is $window.parent then @host = channel
channel = this._setupXDM options
provider = channel: channel, entities: []
channel.call
method: 'getDocumentInfo'
success: (info) =>
provider.entities = (link.href for link in info.metadata.link)
@providers.push provider
@element.scope().$digest()
this.digest()
# Allow the host to define it's own state
unless source is $window.parent
channel.notify
method: 'setTool'
params: this.tool
channel.notify
method: 'setVisibleHighlights'
params: this.visibleHighlights
_setupXDM: (options) ->
# jschannel chokes FF and Chrome extension origins.
if (options.origin.match /^chrome-extension:\/\//) or
(options.origin.match /^resource:\/\//)
options.origin = '*'
provider = Channel.build options
.bind('publish', (ctx, args...) => this.publish args...)
.bind('back', =>
# Navigate "back" out of the interface.
@element.scope().$apply => this.hide()
)
.bind('open', =>
# Pop out the sidebar
@element.scope().$apply => this.show()
)
.bind('showEditor', (ctx, tag) =>
@element.scope().$apply =>
this.showEditor this._getLocalAnnotation(tag)
)
.bind('showAnnotations', (ctx, tags=[]) =>
@element.scope().$apply =>
this.showViewer this._getLocalAnnotations(tags)
)
.bind('updateAnnotations', (ctx, tags=[]) =>
@element.scope().$apply =>
this.updateViewer this._getLocalAnnotations(tags)
)
.bind('focusAnnotations', (ctx, tags=[]) =>
@element.scope().$apply =>
this.focusAnnotations tags
)
.bind('toggleAnnotationSelection', (ctx, tags=[]) =>
@element.scope().$apply =>
this.toggleViewerSelection this._getLocalAnnotations(tags)
)
.bind('setTool', (ctx, name) =>
@element.scope().$apply => this.setTool name
)
.bind('setVisibleHighlights', (ctx, state) =>
@element.scope().$apply => this.setVisibleHighlights state
)
# Look up an annotation based on its bridge tag
_getLocalAnnotation: (tag) -> @plugins.Bridge.cache[tag]
# Look up a list of annotations, based on their bridge tags
_getLocalAnnotations: (tags) -> this._getLocalAnnotation t for t in tags
_setupWrapper: ->
@wrapper = @element.find('#wrapper')
this
_setupDocumentEvents: ->
document.addEventListener 'dragover', (event) =>
@host?.notify
method: 'dragFrame'
params: event.screenX
this
# Override things not used in the angular version.
_setupDynamicStyle: -> this
_setupViewer: -> this
_setupEditor: -> this
# Override things not needed, because we don't access the document
# with this instance
_setupDocumentAccessStrategies: -> this
_scan: -> this
createAnnotation: (annotation) ->
annotation = new @store.AnnotationResource(annotation)
this.publish 'beforeAnnotationCreated', annotation
annotation
deleteAnnotation: (annotation) ->
annotation.$delete(id: annotation.id).then =>
this.publish 'annotationDeleted', annotation
annotation
loadAnnotations: (annotations) ->
annotations = for annotation in annotations
container = @plugins.Threading.idTable[annotation.id]
if container?.message
angular.copy annotation, container.message
this.publish 'annotationUpdated', container.message
continue
else
annotation
super (new @store.AnnotationResource(a) for a in annotations)
# Do nothing in the app frame, let the host handle it.
setupAnnotation: (annotation) -> annotation
# Properly set the selectedAnnotations- and the Count variables
_setSelectedAnnotations: (selected) ->
scope = @element.scope()
count = Object.keys(selected).length
scope.selectedAnnotationsCount = count
if count
scope.selectedAnnotations = selected
else
scope.selectedAnnotations = null
toggleViewerSelection: (annotations=[]) ->
scope = @element.scope()
scope.search.query = ''
selected = scope.selectedAnnotations or {}
for a in annotations
if selected[a.id]
delete selected[a.id]
else
selected[a.id] = true
@_setSelectedAnnotations selected
this
focusAnnotations: (tags) ->
@element.scope().focusedAnnotations = tags
updateViewer: (annotations=[]) ->
# TODO: re-implement
this
showViewer: (annotations=[]) ->
scope = @element.scope()
scope.search.query = ''
selected = {}
for a in annotations
selected[a.id] = true
@_setSelectedAnnotations selected
this.show()
this
showEditor: (annotation) ->
delete @element.scope().selectedAnnotations
this.show()
this
show: ->
@host.notify method: 'showFrame'
hide: ->
@host.notify method: 'hideFrame'
digest: ->
@element.scope().$evalAsync angular.noop
beforeAnnotationCreated: (annotation) ->
annotation.user = @element.injector().get('auth').user
annotation.permissions = {}
@digest()
annotationDeleted: (annotation) ->
scope = @element.scope()
if scope.selectedAnnotations?[annotation.id]
delete scope.selectedAnnotations[annotation.id]
@_setSelectedAnnotations scope.selectedAnnotations
setTool: (name) ->
return if name is @tool
@tool = name
this.publish 'setTool', name
for p in @providers
p.channel.notify
method: 'setTool'
params: name
setVisibleHighlights: (state) ->
return if state is @visibleHighlights
@visibleHighlights = state
this.publish 'setVisibleHighlights', state
for p in @providers
p.channel.notify
method: 'setVisibleHighlights'
params: state
# Dummy class that wraps annotator until the Auth plugin is removed.
class AngularAnnotator extends Annotator
this.$inject = ['$document']
constructor: ($document) ->
super(document.createElement('div'))
class DraftProvider
......@@ -476,5 +222,5 @@ class ViewFilter
angular.module('h')
.factory('render', renderFactory)
.provider('drafts', DraftProvider)
.service('annotator', Hypothesis)
.service('annotator', AngularAnnotator)
.service('viewFilter', ViewFilter)
......@@ -18,7 +18,7 @@ angular.module('h')
svc = $document.find('link')
.filter -> @rel is 'service' and @type is 'application/annotatorsvc+json'
.filter -> @href
.prop('href')
.prop('href') or ''
camelize = (string) ->
string.replace /(?:^|_)([a-z])/g, (_, char) -> char.toUpperCase()
......
......@@ -121,15 +121,5 @@ backoff = (index, max) ->
return 500 * Math.random() * (Math.pow(2, index) - 1)
run = [
'$http', '$window', 'streamer'
($http, $window, streamer) ->
clientId = uuid.v4()
streamer.clientId = clientId
$.ajaxSetup(headers: {'X-Client-Id': clientId})
$http.defaults.headers.common['X-Client-Id'] = clientId
]
angular.module('h.streamer', [])
.service('streamer', Streamer)
.run(run)
class StreamSearchController
this.inject = [
'$scope', '$rootScope', '$routeParams',
'annotator', 'auth', 'queryparser', 'searchfilter', 'store',
'streamer', 'streamfilter'
'auth', 'queryparser', 'searchfilter', 'store',
'streamer', 'streamfilter', 'annotationMapper'
]
constructor: (
$scope, $rootScope, $routeParams
annotator, auth, queryparser, searchfilter, store,
streamer, streamfilter
auth, queryparser, searchfilter, store,
streamer, streamfilter, annotationMapper
) ->
# Initialize the base filter
streamfilter
......@@ -24,7 +24,7 @@ class StreamSearchController
searchParams = searchfilter.toObject $scope.search.query
query = angular.extend limit: 10, searchParams
store.SearchResource.get query, ({rows}) ->
annotator.loadAnnotations(rows)
annotationMapper.loadAnnotations(rows)
$scope.isEmbedded = false
$scope.isStream = true
......
class Annotator.Plugin.Threading extends Annotator.Plugin
class ThreadingService
# Mix in message thread properties into the prototype. The body of the
# class will overwrite any methods applied here. If you need inheritance
# assign the message thread to a local varible.
# The mail object is exported by the jwz.js library.
$.extend(this.prototype, mail.messageThread())
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
'annotationCreated': 'annotationCreated'
'annotationDeleted': 'annotationDeleted'
'annotationsLoaded': 'annotationsLoaded'
root: null
pluginInit: ->
this.$inject = ['$rootScope']
constructor: ($rootScope) ->
# Create a root container.
@root = mail.messageContainer()
$rootScope.$on('beforeAnnotationCreated', this.beforeAnnotationCreated)
$rootScope.$on('annotationCreated', this.annotationCreated)
$rootScope.$on('annotationDeleted', this.annotationDeleted)
$rootScope.$on('annotationsLoaded', this.annotationsLoaded)
# TODO: Refactor the jwz API for progressive updates.
# Right now the idTable is wiped when `messageThread.thread()` is called and
......@@ -60,12 +60,11 @@ class Annotator.Plugin.Threading extends Annotator.Plugin
if !container.message && container.children.length == 0
parent.removeChild(container)
delete this.idTable[container.message?.id]
beforeAnnotationCreated: (annotation) =>
beforeAnnotationCreated: (event, annotation) =>
this.thread([annotation])
annotationCreated: (annotation) =>
annotationCreated: (event, annotation) =>
references = annotation.references or []
if typeof(annotation.references) == 'string' then references = []
ref = references[references.length-1]
......@@ -74,7 +73,7 @@ class Annotator.Plugin.Threading extends Annotator.Plugin
@idTable[annotation.id] = child
break
annotationDeleted: (annotation) =>
annotationDeleted: (event, annotation) =>
if this.idTable[annotation.id]
container = this.idTable[annotation.id]
container.message = null
......@@ -93,6 +92,8 @@ class Annotator.Plugin.Threading extends Annotator.Plugin
this.pruneEmpties(@root)
break
annotationsLoaded: (annotations) =>
annotationsLoaded: (event, annotations) =>
messages = (@root.flattenChildren() or []).concat(annotations)
this.thread(messages)
angular.module('h').service('threading', ThreadingService)
/*!
* @overview es6-promise - a tiny implementation of Promises/A+.
* @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald)
* @license Licensed under MIT license
* See https://raw.githubusercontent.com/jakearchibald/es6-promise/master/LICENSE
* @version 2.0.0
*/
(function() {
"use strict";
function $$utils$$objectOrFunction(x) {
return typeof x === 'function' || (typeof x === 'object' && x !== null);
}
function $$utils$$isFunction(x) {
return typeof x === 'function';
}
function $$utils$$isMaybeThenable(x) {
return typeof x === 'object' && x !== null;
}
var $$utils$$_isArray;
if (!Array.isArray) {
$$utils$$_isArray = function (x) {
return Object.prototype.toString.call(x) === '[object Array]';
};
} else {
$$utils$$_isArray = Array.isArray;
}
var $$utils$$isArray = $$utils$$_isArray;
var $$utils$$now = Date.now || function() { return new Date().getTime(); };
function $$utils$$F() { }
var $$utils$$o_create = (Object.create || function (o) {
if (arguments.length > 1) {
throw new Error('Second argument not supported');
}
if (typeof o !== 'object') {
throw new TypeError('Argument must be an object');
}
$$utils$$F.prototype = o;
return new $$utils$$F();
});
var $$asap$$len = 0;
var $$asap$$default = function asap(callback, arg) {
$$asap$$queue[$$asap$$len] = callback;
$$asap$$queue[$$asap$$len + 1] = arg;
$$asap$$len += 2;
if ($$asap$$len === 2) {
// If len is 1, that means that we need to schedule an async flush.
// If additional callbacks are queued before the queue is flushed, they
// will be processed by this flush that we are scheduling.
$$asap$$scheduleFlush();
}
};
var $$asap$$browserGlobal = (typeof window !== 'undefined') ? window : {};
var $$asap$$BrowserMutationObserver = $$asap$$browserGlobal.MutationObserver || $$asap$$browserGlobal.WebKitMutationObserver;
// test for web worker but not in IE10
var $$asap$$isWorker = typeof Uint8ClampedArray !== 'undefined' &&
typeof importScripts !== 'undefined' &&
typeof MessageChannel !== 'undefined';
// node
function $$asap$$useNextTick() {
return function() {
process.nextTick($$asap$$flush);
};
}
function $$asap$$useMutationObserver() {
var iterations = 0;
var observer = new $$asap$$BrowserMutationObserver($$asap$$flush);
var node = document.createTextNode('');
observer.observe(node, { characterData: true });
return function() {
node.data = (iterations = ++iterations % 2);
};
}
// web worker
function $$asap$$useMessageChannel() {
var channel = new MessageChannel();
channel.port1.onmessage = $$asap$$flush;
return function () {
channel.port2.postMessage(0);
};
}
function $$asap$$useSetTimeout() {
return function() {
setTimeout($$asap$$flush, 1);
};
}
var $$asap$$queue = new Array(1000);
function $$asap$$flush() {
for (var i = 0; i < $$asap$$len; i+=2) {
var callback = $$asap$$queue[i];
var arg = $$asap$$queue[i+1];
callback(arg);
$$asap$$queue[i] = undefined;
$$asap$$queue[i+1] = undefined;
}
$$asap$$len = 0;
}
var $$asap$$scheduleFlush;
// Decide what async method to use to triggering processing of queued callbacks:
if (typeof process !== 'undefined' && {}.toString.call(process) === '[object process]') {
$$asap$$scheduleFlush = $$asap$$useNextTick();
} else if ($$asap$$BrowserMutationObserver) {
$$asap$$scheduleFlush = $$asap$$useMutationObserver();
} else if ($$asap$$isWorker) {
$$asap$$scheduleFlush = $$asap$$useMessageChannel();
} else {
$$asap$$scheduleFlush = $$asap$$useSetTimeout();
}
function $$$internal$$noop() {}
var $$$internal$$PENDING = void 0;
var $$$internal$$FULFILLED = 1;
var $$$internal$$REJECTED = 2;
var $$$internal$$GET_THEN_ERROR = new $$$internal$$ErrorObject();
function $$$internal$$selfFullfillment() {
return new TypeError("You cannot resolve a promise with itself");
}
function $$$internal$$cannotReturnOwn() {
return new TypeError('A promises callback cannot return that same promise.')
}
function $$$internal$$getThen(promise) {
try {
return promise.then;
} catch(error) {
$$$internal$$GET_THEN_ERROR.error = error;
return $$$internal$$GET_THEN_ERROR;
}
}
function $$$internal$$tryThen(then, value, fulfillmentHandler, rejectionHandler) {
try {
then.call(value, fulfillmentHandler, rejectionHandler);
} catch(e) {
return e;
}
}
function $$$internal$$handleForeignThenable(promise, thenable, then) {
$$asap$$default(function(promise) {
var sealed = false;
var error = $$$internal$$tryThen(then, thenable, function(value) {
if (sealed) { return; }
sealed = true;
if (thenable !== value) {
$$$internal$$resolve(promise, value);
} else {
$$$internal$$fulfill(promise, value);
}
}, function(reason) {
if (sealed) { return; }
sealed = true;
$$$internal$$reject(promise, reason);
}, 'Settle: ' + (promise._label || ' unknown promise'));
if (!sealed && error) {
sealed = true;
$$$internal$$reject(promise, error);
}
}, promise);
}
function $$$internal$$handleOwnThenable(promise, thenable) {
if (thenable._state === $$$internal$$FULFILLED) {
$$$internal$$fulfill(promise, thenable._result);
} else if (promise._state === $$$internal$$REJECTED) {
$$$internal$$reject(promise, thenable._result);
} else {
$$$internal$$subscribe(thenable, undefined, function(value) {
$$$internal$$resolve(promise, value);
}, function(reason) {
$$$internal$$reject(promise, reason);
});
}
}
function $$$internal$$handleMaybeThenable(promise, maybeThenable) {
if (maybeThenable.constructor === promise.constructor) {
$$$internal$$handleOwnThenable(promise, maybeThenable);
} else {
var then = $$$internal$$getThen(maybeThenable);
if (then === $$$internal$$GET_THEN_ERROR) {
$$$internal$$reject(promise, $$$internal$$GET_THEN_ERROR.error);
} else if (then === undefined) {
$$$internal$$fulfill(promise, maybeThenable);
} else if ($$utils$$isFunction(then)) {
$$$internal$$handleForeignThenable(promise, maybeThenable, then);
} else {
$$$internal$$fulfill(promise, maybeThenable);
}
}
}
function $$$internal$$resolve(promise, value) {
if (promise === value) {
$$$internal$$reject(promise, $$$internal$$selfFullfillment());
} else if ($$utils$$objectOrFunction(value)) {
$$$internal$$handleMaybeThenable(promise, value);
} else {
$$$internal$$fulfill(promise, value);
}
}
function $$$internal$$publishRejection(promise) {
if (promise._onerror) {
promise._onerror(promise._result);
}
$$$internal$$publish(promise);
}
function $$$internal$$fulfill(promise, value) {
if (promise._state !== $$$internal$$PENDING) { return; }
promise._result = value;
promise._state = $$$internal$$FULFILLED;
if (promise._subscribers.length === 0) {
} else {
$$asap$$default($$$internal$$publish, promise);
}
}
function $$$internal$$reject(promise, reason) {
if (promise._state !== $$$internal$$PENDING) { return; }
promise._state = $$$internal$$REJECTED;
promise._result = reason;
$$asap$$default($$$internal$$publishRejection, promise);
}
function $$$internal$$subscribe(parent, child, onFulfillment, onRejection) {
var subscribers = parent._subscribers;
var length = subscribers.length;
parent._onerror = null;
subscribers[length] = child;
subscribers[length + $$$internal$$FULFILLED] = onFulfillment;
subscribers[length + $$$internal$$REJECTED] = onRejection;
if (length === 0 && parent._state) {
$$asap$$default($$$internal$$publish, parent);
}
}
function $$$internal$$publish(promise) {
var subscribers = promise._subscribers;
var settled = promise._state;
if (subscribers.length === 0) { return; }
var child, callback, detail = promise._result;
for (var i = 0; i < subscribers.length; i += 3) {
child = subscribers[i];
callback = subscribers[i + settled];
if (child) {
$$$internal$$invokeCallback(settled, child, callback, detail);
} else {
callback(detail);
}
}
promise._subscribers.length = 0;
}
function $$$internal$$ErrorObject() {
this.error = null;
}
var $$$internal$$TRY_CATCH_ERROR = new $$$internal$$ErrorObject();
function $$$internal$$tryCatch(callback, detail) {
try {
return callback(detail);
} catch(e) {
$$$internal$$TRY_CATCH_ERROR.error = e;
return $$$internal$$TRY_CATCH_ERROR;
}
}
function $$$internal$$invokeCallback(settled, promise, callback, detail) {
var hasCallback = $$utils$$isFunction(callback),
value, error, succeeded, failed;
if (hasCallback) {
value = $$$internal$$tryCatch(callback, detail);
if (value === $$$internal$$TRY_CATCH_ERROR) {
failed = true;
error = value.error;
value = null;
} else {
succeeded = true;
}
if (promise === value) {
$$$internal$$reject(promise, $$$internal$$cannotReturnOwn());
return;
}
} else {
value = detail;
succeeded = true;
}
if (promise._state !== $$$internal$$PENDING) {
// noop
} else if (hasCallback && succeeded) {
$$$internal$$resolve(promise, value);
} else if (failed) {
$$$internal$$reject(promise, error);
} else if (settled === $$$internal$$FULFILLED) {
$$$internal$$fulfill(promise, value);
} else if (settled === $$$internal$$REJECTED) {
$$$internal$$reject(promise, value);
}
}
function $$$internal$$initializePromise(promise, resolver) {
try {
resolver(function resolvePromise(value){
$$$internal$$resolve(promise, value);
}, function rejectPromise(reason) {
$$$internal$$reject(promise, reason);
});
} catch(e) {
$$$internal$$reject(promise, e);
}
}
function $$$enumerator$$makeSettledResult(state, position, value) {
if (state === $$$internal$$FULFILLED) {
return {
state: 'fulfilled',
value: value
};
} else {
return {
state: 'rejected',
reason: value
};
}
}
function $$$enumerator$$Enumerator(Constructor, input, abortOnReject, label) {
this._instanceConstructor = Constructor;
this.promise = new Constructor($$$internal$$noop, label);
this._abortOnReject = abortOnReject;
if (this._validateInput(input)) {
this._input = input;
this.length = input.length;
this._remaining = input.length;
this._init();
if (this.length === 0) {
$$$internal$$fulfill(this.promise, this._result);
} else {
this.length = this.length || 0;
this._enumerate();
if (this._remaining === 0) {
$$$internal$$fulfill(this.promise, this._result);
}
}
} else {
$$$internal$$reject(this.promise, this._validationError());
}
}
$$$enumerator$$Enumerator.prototype._validateInput = function(input) {
return $$utils$$isArray(input);
};
$$$enumerator$$Enumerator.prototype._validationError = function() {
return new Error('Array Methods must be provided an Array');
};
$$$enumerator$$Enumerator.prototype._init = function() {
this._result = new Array(this.length);
};
var $$$enumerator$$default = $$$enumerator$$Enumerator;
$$$enumerator$$Enumerator.prototype._enumerate = function() {
var length = this.length;
var promise = this.promise;
var input = this._input;
for (var i = 0; promise._state === $$$internal$$PENDING && i < length; i++) {
this._eachEntry(input[i], i);
}
};
$$$enumerator$$Enumerator.prototype._eachEntry = function(entry, i) {
var c = this._instanceConstructor;
if ($$utils$$isMaybeThenable(entry)) {
if (entry.constructor === c && entry._state !== $$$internal$$PENDING) {
entry._onerror = null;
this._settledAt(entry._state, i, entry._result);
} else {
this._willSettleAt(c.resolve(entry), i);
}
} else {
this._remaining--;
this._result[i] = this._makeResult($$$internal$$FULFILLED, i, entry);
}
};
$$$enumerator$$Enumerator.prototype._settledAt = function(state, i, value) {
var promise = this.promise;
if (promise._state === $$$internal$$PENDING) {
this._remaining--;
if (this._abortOnReject && state === $$$internal$$REJECTED) {
$$$internal$$reject(promise, value);
} else {
this._result[i] = this._makeResult(state, i, value);
}
}
if (this._remaining === 0) {
$$$internal$$fulfill(promise, this._result);
}
};
$$$enumerator$$Enumerator.prototype._makeResult = function(state, i, value) {
return value;
};
$$$enumerator$$Enumerator.prototype._willSettleAt = function(promise, i) {
var enumerator = this;
$$$internal$$subscribe(promise, undefined, function(value) {
enumerator._settledAt($$$internal$$FULFILLED, i, value);
}, function(reason) {
enumerator._settledAt($$$internal$$REJECTED, i, reason);
});
};
var $$promise$all$$default = function all(entries, label) {
return new $$$enumerator$$default(this, entries, true /* abort on reject */, label).promise;
};
var $$promise$race$$default = function race(entries, label) {
/*jshint validthis:true */
var Constructor = this;
var promise = new Constructor($$$internal$$noop, label);
if (!$$utils$$isArray(entries)) {
$$$internal$$reject(promise, new TypeError('You must pass an array to race.'));
return promise;
}
var length = entries.length;
function onFulfillment(value) {
$$$internal$$resolve(promise, value);
}
function onRejection(reason) {
$$$internal$$reject(promise, reason);
}
for (var i = 0; promise._state === $$$internal$$PENDING && i < length; i++) {
$$$internal$$subscribe(Constructor.resolve(entries[i]), undefined, onFulfillment, onRejection);
}
return promise;
};
var $$promise$resolve$$default = function resolve(object, label) {
/*jshint validthis:true */
var Constructor = this;
if (object && typeof object === 'object' && object.constructor === Constructor) {
return object;
}
var promise = new Constructor($$$internal$$noop, label);
$$$internal$$resolve(promise, object);
return promise;
};
var $$promise$reject$$default = function reject(reason, label) {
/*jshint validthis:true */
var Constructor = this;
var promise = new Constructor($$$internal$$noop, label);
$$$internal$$reject(promise, reason);
return promise;
};
var $$es6$promise$promise$$counter = 0;
function $$es6$promise$promise$$needsResolver() {
throw new TypeError('You must pass a resolver function as the first argument to the promise constructor');
}
function $$es6$promise$promise$$needsNew() {
throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.");
}
var $$es6$promise$promise$$default = $$es6$promise$promise$$Promise;
/**
Promise objects represent the eventual result of an asynchronous operation. The
primary way of interacting with a promise is through its `then` method, which
registers callbacks to receive either a promise’s eventual value or the reason
why the promise cannot be fulfilled.
Terminology
-----------
- `promise` is an object or function with a `then` method whose behavior conforms to this specification.
- `thenable` is an object or function that defines a `then` method.
- `value` is any legal JavaScript value (including undefined, a thenable, or a promise).
- `exception` is a value that is thrown using the throw statement.
- `reason` is a value that indicates why a promise was rejected.
- `settled` the final resting state of a promise, fulfilled or rejected.
A promise can be in one of three states: pending, fulfilled, or rejected.
Promises that are fulfilled have a fulfillment value and are in the fulfilled
state. Promises that are rejected have a rejection reason and are in the
rejected state. A fulfillment value is never a thenable.
Promises can also be said to *resolve* a value. If this value is also a
promise, then the original promise's settled state will match the value's
settled state. So a promise that *resolves* a promise that rejects will
itself reject, and a promise that *resolves* a promise that fulfills will
itself fulfill.
Basic Usage:
------------
```js
var promise = new Promise(function(resolve, reject) {
// on success
resolve(value);
// on failure
reject(reason);
});
promise.then(function(value) {
// on fulfillment
}, function(reason) {
// on rejection
});
```
Advanced Usage:
---------------
Promises shine when abstracting away asynchronous interactions such as
`XMLHttpRequest`s.
```js
function getJSON(url) {
return new Promise(function(resolve, reject){
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onreadystatechange = handler;
xhr.responseType = 'json';
xhr.setRequestHeader('Accept', 'application/json');
xhr.send();
function handler() {
if (this.readyState === this.DONE) {
if (this.status === 200) {
resolve(this.response);
} else {
reject(new Error('getJSON: `' + url + '` failed with status: [' + this.status + ']'));
}
}
};
});
}
getJSON('/posts.json').then(function(json) {
// on fulfillment
}, function(reason) {
// on rejection
});
```
Unlike callbacks, promises are great composable primitives.
```js
Promise.all([
getJSON('/posts'),
getJSON('/comments')
]).then(function(values){
values[0] // => postsJSON
values[1] // => commentsJSON
return values;
});
```
@class Promise
@param {function} resolver
Useful for tooling.
@constructor
*/
function $$es6$promise$promise$$Promise(resolver) {
this._id = $$es6$promise$promise$$counter++;
this._state = undefined;
this._result = undefined;
this._subscribers = [];
if ($$$internal$$noop !== resolver) {
if (!$$utils$$isFunction(resolver)) {
$$es6$promise$promise$$needsResolver();
}
if (!(this instanceof $$es6$promise$promise$$Promise)) {
$$es6$promise$promise$$needsNew();
}
$$$internal$$initializePromise(this, resolver);
}
}
$$es6$promise$promise$$Promise.all = $$promise$all$$default;
$$es6$promise$promise$$Promise.race = $$promise$race$$default;
$$es6$promise$promise$$Promise.resolve = $$promise$resolve$$default;
$$es6$promise$promise$$Promise.reject = $$promise$reject$$default;
$$es6$promise$promise$$Promise.prototype = {
constructor: $$es6$promise$promise$$Promise,
/**
The primary way of interacting with a promise is through its `then` method,
which registers callbacks to receive either a promise's eventual value or the
reason why the promise cannot be fulfilled.
```js
findUser().then(function(user){
// user is available
}, function(reason){
// user is unavailable, and you are given the reason why
});
```
Chaining
--------
The return value of `then` is itself a promise. This second, 'downstream'
promise is resolved with the return value of the first promise's fulfillment
or rejection handler, or rejected if the handler throws an exception.
```js
findUser().then(function (user) {
return user.name;
}, function (reason) {
return 'default name';
}).then(function (userName) {
// If `findUser` fulfilled, `userName` will be the user's name, otherwise it
// will be `'default name'`
});
findUser().then(function (user) {
throw new Error('Found user, but still unhappy');
}, function (reason) {
throw new Error('`findUser` rejected and we're unhappy');
}).then(function (value) {
// never reached
}, function (reason) {
// if `findUser` fulfilled, `reason` will be 'Found user, but still unhappy'.
// If `findUser` rejected, `reason` will be '`findUser` rejected and we're unhappy'.
});
```
If the downstream promise does not specify a rejection handler, rejection reasons will be propagated further downstream.
```js
findUser().then(function (user) {
throw new PedagogicalException('Upstream error');
}).then(function (value) {
// never reached
}).then(function (value) {
// never reached
}, function (reason) {
// The `PedgagocialException` is propagated all the way down to here
});
```
Assimilation
------------
Sometimes the value you want to propagate to a downstream promise can only be
retrieved asynchronously. This can be achieved by returning a promise in the
fulfillment or rejection handler. The downstream promise will then be pending
until the returned promise is settled. This is called *assimilation*.
```js
findUser().then(function (user) {
return findCommentsByAuthor(user);
}).then(function (comments) {
// The user's comments are now available
});
```
If the assimliated promise rejects, then the downstream promise will also reject.
```js
findUser().then(function (user) {
return findCommentsByAuthor(user);
}).then(function (comments) {
// If `findCommentsByAuthor` fulfills, we'll have the value here
}, function (reason) {
// If `findCommentsByAuthor` rejects, we'll have the reason here
});
```
Simple Example
--------------
Synchronous Example
```javascript
var result;
try {
result = findResult();
// success
} catch(reason) {
// failure
}
```
Errback Example
```js
findResult(function(result, err){
if (err) {
// failure
} else {
// success
}
});
```
Promise Example;
```javascript
findResult().then(function(result){
// success
}, function(reason){
// failure
});
```
Advanced Example
--------------
Synchronous Example
```javascript
var author, books;
try {
author = findAuthor();
books = findBooksByAuthor(author);
// success
} catch(reason) {
// failure
}
```
Errback Example
```js
function foundBooks(books) {
}
function failure(reason) {
}
findAuthor(function(author, err){
if (err) {
failure(err);
// failure
} else {
try {
findBoooksByAuthor(author, function(books, err) {
if (err) {
failure(err);
} else {
try {
foundBooks(books);
} catch(reason) {
failure(reason);
}
}
});
} catch(error) {
failure(err);
}
// success
}
});
```
Promise Example;
```javascript
findAuthor().
then(findBooksByAuthor).
then(function(books){
// found books
}).catch(function(reason){
// something went wrong
});
```
@method then
@param {Function} onFulfilled
@param {Function} onRejected
Useful for tooling.
@return {Promise}
*/
then: function(onFulfillment, onRejection) {
var parent = this;
var state = parent._state;
if (state === $$$internal$$FULFILLED && !onFulfillment || state === $$$internal$$REJECTED && !onRejection) {
return this;
}
var child = new this.constructor($$$internal$$noop);
var result = parent._result;
if (state) {
var callback = arguments[state - 1];
$$asap$$default(function(){
$$$internal$$invokeCallback(state, child, callback, result);
});
} else {
$$$internal$$subscribe(parent, child, onFulfillment, onRejection);
}
return child;
},
/**
`catch` is simply sugar for `then(undefined, onRejection)` which makes it the same
as the catch block of a try/catch statement.
```js
function findAuthor(){
throw new Error('couldn't find that author');
}
// synchronous
try {
findAuthor();
} catch(reason) {
// something went wrong
}
// async with promises
findAuthor().catch(function(reason){
// something went wrong
});
```
@method catch
@param {Function} onRejection
Useful for tooling.
@return {Promise}
*/
'catch': function(onRejection) {
return this.then(null, onRejection);
}
};
var $$es6$promise$polyfill$$default = function polyfill() {
var local;
if (typeof global !== 'undefined') {
local = global;
} else if (typeof window !== 'undefined' && window.document) {
local = window;
} else {
local = self;
}
var es6PromiseSupport =
"Promise" in local &&
// Some of these methods are missing from
// Firefox/Chrome experimental implementations
"resolve" in local.Promise &&
"reject" in local.Promise &&
"all" in local.Promise &&
"race" in local.Promise &&
// Older version of the spec had a resolver object
// as the arg rather than a function
(function() {
var resolve;
new local.Promise(function(r) { resolve = r; });
return $$utils$$isFunction(resolve);
}());
if (!es6PromiseSupport) {
local.Promise = $$es6$promise$promise$$default;
}
};
var es6$promise$umd$$ES6Promise = {
'Promise': $$es6$promise$promise$$default,
'polyfill': $$es6$promise$polyfill$$default
};
/* global define:true module:true window: true */
if (typeof define === 'function' && define['amd']) {
define(function() { return es6$promise$umd$$ES6Promise; });
} else if (typeof module !== 'undefined' && module['exports']) {
module['exports'] = es6$promise$umd$$ES6Promise;
} else if (typeof this !== 'undefined') {
this['ES6Promise'] = es6$promise$umd$$ES6Promise;
}
}).call(this);
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -13,7 +13,7 @@
<li ng-show="!threadFilter.active() && selectedAnnotations"
><span ng-pluralize
count="selectedAnnotationsCount"
when="{'0': 'No annotations selected',
when="{'0': 'No annotations selected.',
'one': 'Showing 1 selected annotation.',
'other': 'Showing {} selected annotations.'}"></span>
<a href="" ng-click="clearSelection()">Clear selection</a>.</li>
......
......@@ -15,15 +15,10 @@ module.exports = function(config) {
// list of files / patterns to load in the browser
files: [
'h/static/scripts/vendor/polyfills/bind.js',
'h/static/scripts/vendor/polyfills/url.js',
'h/static/scripts/vendor/polyfills/promise.js',
'h/static/scripts/vendor/jquery.js',
'h/static/scripts/vendor/angular.js',
'h/static/scripts/vendor/angular-mocks.js',
'h/static/scripts/vendor/angular-animate.js',
'h/static/scripts/vendor/angular-bootstrap.js',
'h/static/scripts/vendor/angular-resource.js',
'h/static/scripts/vendor/angular-route.js',
'h/static/scripts/vendor/angular-sanitize.js',
'h/static/scripts/vendor/ng-tags-input.js',
'h/static/scripts/vendor/jschannel.js',
'h/static/scripts/vendor/jwz.js',
'h/static/scripts/vendor/moment-with-langs.js',
......@@ -36,12 +31,20 @@ module.exports = function(config) {
'h/static/scripts/vendor/annotator.js',
'h/static/scripts/annotator/monkey.js',
'h/static/scripts/vendor/annotator.auth.js',
'h/static/scripts/vendor/polyfills/url.js',
'h/static/scripts/annotator/plugin/bridge.js',
'h/static/scripts/annotator/plugin/bucket-bar.js',
'h/static/scripts/annotator/plugin/threading.js',
'h/static/scripts/vendor/dom_text_mapper.js',
'h/static/scripts/annotator/annotator.anchoring.js',
// Angular needs to be included after annotator to avoid the
// CrossFrame dependencies in Bridge picking up the angular object.
'h/static/scripts/vendor/angular.js',
'h/static/scripts/vendor/angular-mocks.js',
'h/static/scripts/vendor/angular-animate.js',
'h/static/scripts/vendor/angular-bootstrap.js',
'h/static/scripts/vendor/angular-resource.js',
'h/static/scripts/vendor/angular-route.js',
'h/static/scripts/vendor/angular-sanitize.js',
'h/static/scripts/vendor/ng-tags-input.js',
'h/static/scripts/app.js',
'h/static/scripts/account.js',
'h/static/scripts/helpers.js',
......@@ -49,8 +52,8 @@ module.exports = function(config) {
'h/static/scripts/hypothesis.js',
'h/static/scripts/vendor/sinon.js',
'h/static/scripts/vendor/chai.js',
'h/static/scripts/hypothesis.js',
'h/templates/client/*.html',
'tests/js/bootstrap.coffee',
'tests/js/**/*-test.coffee'
],
......
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)
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)
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
})
it 'does not modify the original map object', ->
orig = annotationUI.focusedAnnotationMap = {1: true}
annotationUI.focusAnnotations([{id: 2}, {id: 3}])
assert.notEqual(annotationUI.focusedAnnotationMap, orig)
it 'nulls the map if no annotations are focused', ->
orig = annotationUI.focusedAnnotationMap = {1: true}
annotationUI.focusAnnotations([])
assert.isNull(annotationUI.focusedAnnotationMap)
describe '.hasSelectedAnnotations', ->
it 'returns true if there are any selected annotations', ->
annotationUI.selectedAnnotationMap = {1: true}
assert.isTrue(annotationUI.hasSelectedAnnotations())
it 'returns false if there are no selected annotations', ->
annotationUI.selectedAnnotationMap = null
assert.isFalse(annotationUI.hasSelectedAnnotations())
describe '.isAnnotationSelected', ->
it 'returns true if the id provided is selected', ->
annotationUI.selectedAnnotationMap = {1: true}
assert.isTrue(annotationUI.isAnnotationSelected(1))
it 'returns false if the id provided is not selected', ->
annotationUI.selectedAnnotationMap = {1: true}
assert.isFalse(annotationUI.isAnnotationSelected(2))
it 'returns false if there are no selected annotations', ->
annotationUI.selectedAnnotationMap = null
assert.isFalse(annotationUI.isAnnotationSelected(1))
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
})
it 'does not modify the original map object', ->
orig = annotationUI.selectedAnnotationMap = {1: true}
annotationUI.selectAnnotations([{id: 2}, {id: 3}])
assert.notEqual(annotationUI.selectedAnnotationMap, orig)
it 'nulls the map if no annotations are selected', ->
orig = annotationUI.selectedAnnotationMap = {1: true}
annotationUI.selectAnnotations([])
assert.isNull(annotationUI.selectedAnnotationMap)
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, 3: true}
annotationUI.xorSelectedAnnotations([{id: 1}, {id: 2}])
assert.deepEqual(annotationUI.selectedAnnotationMap, 2:true, 3: true)
it 'does not modify the original map object', ->
orig = annotationUI.selectedAnnotationMap = {1: true}
annotationUI.xorSelectedAnnotations([{id: 2}, {id: 3}])
assert.notEqual(annotationUI.selectedAnnotationMap, orig)
it 'nulls the map if no annotations are selected', ->
orig = annotationUI.selectedAnnotationMap = {1: true}
annotationUI.xorSelectedAnnotations([id: 1])
assert.isNull(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
})
it 'does not modify the original map object', ->
orig = annotationUI.selectedAnnotationMap = {1: true}
annotationUI.removeSelectedAnnotation(id: 1)
assert.notEqual(annotationUI.selectedAnnotationMap, orig)
it 'nulls the map if no annotations are selected', ->
orig = annotationUI.selectedAnnotationMap = {1: true}
annotationUI.removeSelectedAnnotation(id: 1)
assert.isNull(annotationUI.selectedAnnotationMap)
describe '.clearSelectedAnnotations', ->
it 'removes all annotations from the selection', ->
annotationUI.selectedAnnotationMap = {1: true, 2: true, 3: true}
annotationUI.clearSelectedAnnotations()
assert.isNull(annotationUI.selectedAnnotationMap)
assert = chai.assert
sinon.assert.expose(assert, prefix: '')
describe 'AnnotationUISync', ->
sandbox = sinon.sandbox.create()
$digest = null
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, $rootScope) ->
$digest = sandbox.stub($rootScope, '$digest')
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(
$rootScope, 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)
it 'triggers a digest', ->
createAnnotationUISync()
publish({method: 'back'})
assert.called($digest)
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)
it 'triggers a digest', ->
createAnnotationUISync()
publish({method: 'open'})
assert.called($digest)
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)
it 'triggers a digest', ->
createAnnotationUISync()
publish({method: 'showEditor'})
assert.called($digest)
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.selectAnnotations)
assert.calledWith(fakeAnnotationUI.selectAnnotations, [
{id: 1}, {id: 2}, {id: 3}
])
it 'triggers a digest', ->
createAnnotationUISync()
publish({
method: 'showAnnotations',
params: ['tag1', 'tag2', 'tag3']
})
assert.called($digest)
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}
])
it 'triggers a digest', ->
createAnnotationUISync()
publish({
method: 'focusAnnotations',
params: ['tag1', 'tag2', 'tag3']
})
assert.called($digest)
describe 'on "toggleAnnotationSelection" event', ->
it 'updates the annotationUI to show the provided annotations', ->
createAnnotationUISync()
publish({
method: 'toggleAnnotationSelection',
params: ['tag1', 'tag2', 'tag3']
})
assert.called(fakeAnnotationUI.xorSelectedAnnotations)
assert.calledWith(fakeAnnotationUI.xorSelectedAnnotations, [
{id: 1}, {id: 2}, {id: 3}
])
it 'triggers a digest', ->
createAnnotationUISync()
publish({
method: 'toggleAnnotationSelection',
params: ['tag1', 'tag2', 'tag3']
})
assert.called($digest)
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'
})
it 'triggers a digest of the application state', ->
createAnnotationUISync()
publish({
method: 'setTool',
params: 'highlighter'
})
assert.called($digest)
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
})
it 'triggers a digest of the application state', ->
createAnnotationUISync()
publish({
method: 'setVisibleHighlights',
params: true
})
assert.called($digest)
assert = chai.assert
sinon.assert.expose(assert, prefix: '')
describe 'Annotator.Plugin.CrossFrame', ->
CrossFrame = null
fakeDiscovery = null
fakeBridge = null
fakeAnnotationSync = null
sandbox = sinon.sandbox.create()
createCrossFrame = (options) ->
defaults =
on: sandbox.stub()
emit: sandbox.stub()
element = document.createElement('div')
return new Annotator.Plugin.CrossFrame(element, $.extend({}, defaults, options))
beforeEach ->
fakeDiscovery =
startDiscovery: sandbox.stub()
stopDiscovery: sandbox.stub()
fakeBridge =
createChannel: sandbox.stub()
onConnect: sandbox.stub()
notify: sandbox.stub()
on: sandbox.stub()
fakeAnnotationSync =
sync: sandbox.stub()
CrossFrame = Annotator.Plugin.CrossFrame
sandbox.stub(CrossFrame, 'AnnotationSync').returns(fakeAnnotationSync)
sandbox.stub(CrossFrame, 'Discovery').returns(fakeDiscovery)
sandbox.stub(CrossFrame, 'Bridge').returns(fakeBridge)
afterEach ->
sandbox.restore()
describe 'constructor', ->
it 'instantiates the Discovery component', ->
createCrossFrame()
assert.called(CrossFrame.Discovery)
assert.calledWith(CrossFrame.Discovery, window)
it 'passes the options along to the bridge', ->
createCrossFrame(server: true)
assert.called(CrossFrame.Discovery)
assert.calledWith(CrossFrame.Discovery, window, server: true)
it 'instantiates the CrossFrame component', ->
createCrossFrame()
assert.called(CrossFrame.Bridge)
assert.calledWith(CrossFrame.Discovery)
it 'passes the options along to the bridge', ->
createCrossFrame(scope: 'myscope')
assert.called(CrossFrame.Bridge)
assert.calledWith(CrossFrame.Bridge, scope: 'myscope')
it 'instantiates the AnnotationSync component', ->
createCrossFrame()
assert.called(CrossFrame.AnnotationSync)
it 'passes along options to AnnotationSync', ->
formatter = (x) -> x
createCrossFrame(formatter: formatter)
assert.called(CrossFrame.AnnotationSync)
assert.calledWith(CrossFrame.AnnotationSync, fakeBridge, {
on: sinon.match.func
emit: sinon.match.func
formatter: formatter
})
describe '.pluginInit', ->
it 'starts the discovery of new channels', ->
bridge = createCrossFrame()
bridge.pluginInit()
assert.called(fakeDiscovery.startDiscovery)
it 'creates a channel when a new frame is discovered', ->
bridge = createCrossFrame()
bridge.pluginInit()
fakeDiscovery.startDiscovery.yield('SOURCE', 'ORIGIN', 'TOKEN')
assert.called(fakeBridge.createChannel)
assert.calledWith(fakeBridge.createChannel, 'SOURCE', 'ORIGIN', 'TOKEN')
describe '.destroy', ->
it 'stops the discovery of new frames', ->
bridge = createCrossFrame()
bridge.destroy()
assert.called(fakeDiscovery.stopDiscovery)
describe '.sync', ->
it 'syncs the annotations with the other frame', ->
bridge = createCrossFrame()
bridge.sync()
assert.called(fakeAnnotationSync.sync)
describe '.on', ->
it 'proxies the call to the bridge', ->
bridge = createCrossFrame()
bridge.on('event', 'arg')
assert.calledWith(fakeBridge.on, 'event', 'arg')
describe '.notify', ->
it 'proxies the call to the bridge', ->
bridge = createCrossFrame()
bridge.notify(method: 'method')
assert.calledWith(fakeBridge.notify, method: 'method')
describe '.onConnect', ->
it 'proxies the call to the bridge', ->
bridge = createCrossFrame()
fn = ->
bridge.onConnect(fn)
assert.calledWith(fakeBridge.onConnect, fn)
ES6Promise.polyfill()
assert = chai.assert
sinon.assert.expose assert, prefix: null
describe 'Bridge', ->
sandbox = sinon.sandbox.create()
createBridge = null
createChannel = null
beforeEach module('h')
beforeEach inject (Bridge) ->
createBridge = (options) ->
new Bridge(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: 'bridge: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)
......@@ -48,7 +48,6 @@ describe 'h', ->
send: sandbox.spy()
}
$provide.value 'annotator', fakeAnnotator
$provide.value 'identity', fakeIdentity
$provide.value 'streamer', fakeStreamer
$provide.value '$location', fakeLocation
......@@ -69,7 +68,6 @@ describe 'h', ->
it 'does not show login form for logged in users', ->
createController()
$scope.$digest()
assert.isFalse($scope.dialog.visible)
describe 'AnnotationViewerController', ->
......@@ -84,3 +82,47 @@ describe 'h', ->
it 'sets the isEmbedded property to false', ->
assert.isFalse($scope.isEmbedded)
describe 'AnnotationUIController', ->
$scope = null
$rootScope = null
annotationUI = null
beforeEach module('h')
beforeEach inject ($controller, _$rootScope_) ->
$rootScope = _$rootScope_
$scope = $rootScope.$new()
$scope.search = {}
annotationUI =
tool: 'comment'
selectedAnnotationMap: null
focusedAnnotationsMap: null
removeSelectedAnnotation: sandbox.stub()
$controller 'AnnotationUIController', {$scope, annotationUI}
it 'updates the view when the selection changes', ->
annotationUI.selectedAnnotationMap = {1: true, 2: true}
$rootScope.$digest()
assert.deepEqual($scope.selectedAnnotations, {1: true, 2: true})
it 'updates the selection counter when the selection changes', ->
annotationUI.selectedAnnotationMap = {1: true, 2: true}
$rootScope.$digest()
assert.deepEqual($scope.selectedAnnotationsCount, 2)
it 'clears the selection when no annotations are selected', ->
annotationUI.selectedAnnotationMap = {}
$rootScope.$digest()
assert.deepEqual($scope.selectedAnnotations, null)
assert.deepEqual($scope.selectedAnnotationsCount, 0)
it 'updates the focused annotations when the focus map changes', ->
annotationUI.focusedAnnotationMap = {1: true, 2: true}
$rootScope.$digest()
assert.deepEqual($scope.focusedAnnotations, {1: true, 2: true})
describe 'on annotationDeleted', ->
it 'removes the deleted annotation from the selection', ->
$rootScope.$emit('annotationDeleted', {id: 1})
assert.calledWith(annotationUI.removeSelectedAnnotation, {id: 1})
assert = chai.assert
sinon.assert.expose assert, prefix: null
describe 'CrossFrameService', ->
sandbox = sinon.sandbox.create()
crossframe = null
$rootScope = null
$fakeDocument = null
$fakeWindow = null
fakeStore = null
fakeAnnotationUI = null
fakeDiscovery = null
fakeBridge = null
fakeAnnotationSync = null
fakeAnnotationUISync = null
beforeEach module('h')
beforeEach module ($provide) ->
$fakeDocument = {}
$fakeWindow = {}
fakeStore = {}
fakeAnnotationUI = {}
fakeDiscovery =
startDiscovery: sandbox.stub()
fakeBridge =
notify: sandbox.stub()
createChannel: sandbox.stub()
onConnect: sandbox.stub()
fakeAnnotationSync = {}
fakeAnnotationUISync = {}
$provide.value('$document', $fakeDocument)
$provide.value('$window', $fakeWindow)
$provide.value('store', fakeStore)
$provide.value('annotationUI', fakeAnnotationUI)
$provide.value('Discovery',
sandbox.stub().returns(fakeDiscovery))
$provide.value('Bridge',
sandbox.stub().returns(fakeBridge))
$provide.value('AnnotationSync',
sandbox.stub().returns(fakeAnnotationSync))
$provide.value('AnnotationUISync',
sandbox.stub().returns(fakeAnnotationUISync))
return # $provide returns a promise.
beforeEach inject (_$rootScope_, _crossframe_) ->
$rootScope = _$rootScope_
crossframe = _crossframe_
afterEach ->
sandbox.restore()
describe '.connect()', ->
it 'creates a new channel when the discovery module finds a frame', ->
fakeDiscovery.startDiscovery.yields('source', 'origin', 'token')
crossframe.connect()
assert.calledWith(fakeBridge.createChannel,
'source', 'origin', 'token')
it 'queries discovered frames for metadata', ->
info = {metadata: link: [{href: 'http://example.com'}]}
channel = {call: sandbox.stub().yieldsTo('success', info)}
fakeBridge.onConnect.yields(channel)
crossframe.connect()
assert.calledWith(channel.call, {
method: 'getDocumentInfo'
success: sinon.match.func
})
it 'updates the providers array', ->
info = {metadata: link: [{href: 'http://example.com'}]}
channel = {call: sandbox.stub().yieldsTo('success', info)}
fakeBridge.onConnect.yields(channel)
crossframe.connect()
assert.deepEqual(crossframe.providers, [
{channel: channel, entities: ['http://example.com']}
])
describe '.notify()', ->
it 'proxies the call to the bridge', ->
message = {method: 'foo', params: 'bar'}
crossframe.connect() # create the bridge.
crossframe.notify(message)
assert.calledOn(fakeBridge.notify, fakeBridge)
assert.calledWith(fakeBridge.notify, message)
......@@ -6,13 +6,14 @@ describe 'h.directives.annotation', ->
$document = null
$scope = null
$timeout = null
annotator = null
annotation = null
createController = null
flash = null
fakeAuth = null
fakeStore = null
fakeUser = null
fakeAnnotationMapper = null
fakeAnnotationUI = null
beforeEach module('h')
beforeEach module('h.templates')
......@@ -20,9 +21,20 @@ describe 'h.directives.annotation', ->
beforeEach module ($provide) ->
fakeAuth =
user: 'acct:bill@localhost'
fakeAnnotationMapper =
createAnnotation: sandbox.stub().returns
permissions:
read: ['acct:bill@localhost']
update: ['acct:bill@localhost']
destroy: ['acct:bill@localhost']
admin: ['acct:bill@localhost']
deleteAnnotation: sandbox.stub()
fakeAnnotationUI = {}
$provide.value 'auth', fakeAuth
$provide.value 'store', fakeStore
$provide.value 'annotationMapper', fakeAnnotationMapper
$provide.value 'annotationUI', fakeAnnotationUI
return
beforeEach inject (_$compile_, $controller, _$document_, $rootScope, _$timeout_) ->
......@@ -31,11 +43,6 @@ describe 'h.directives.annotation', ->
$timeout = _$timeout_
$scope = $rootScope.$new()
$scope.annotationGet = (locals) -> annotation
annotator = {
createAnnotation: sandbox.spy (data) -> data
plugins: {},
publish: sandbox.spy()
}
annotation =
id: 'deadbeef'
document:
......@@ -48,7 +55,6 @@ describe 'h.directives.annotation', ->
createController = ->
$controller 'AnnotationController',
$scope: $scope
annotator: annotator
flash: flash
afterEach ->
......@@ -56,7 +62,7 @@ describe 'h.directives.annotation', ->
describe 'when the annotation is a highlight', ->
beforeEach ->
annotator.tool = 'highlight'
fakeAnnotationUI.tool = 'highlight'
annotation.$create = sinon.stub().returns
then: angular.noop
catch: angular.noop
......@@ -91,36 +97,31 @@ describe 'h.directives.annotation', ->
destroy: ['acct:joe@localhost']
admin: ['acct:joe@localhost']
annotator.publish = sinon.spy (event, ann) ->
return unless event == 'beforeAnnotationCreated'
ann.permissions =
read: ['acct:bill@localhost']
update: ['acct:bill@localhost']
destroy: ['acct:bill@localhost']
admin: ['acct:bill@localhost']
it 'creates a new reply with the proper uri and references', ->
controller.reply()
match = sinon.match {references: [annotation.id], uri: annotation.uri}
assert.calledWith(annotator.createAnnotation, match)
assert.calledWith(fakeAnnotationMapper.createAnnotation, match)
it 'adds the world readable principal if the parent is public', ->
reply = {}
fakeAnnotationMapper.createAnnotation.returns(reply)
annotation.permissions.read.push('group:__world__')
controller.reply()
newAnnotation = annotator.createAnnotation.lastCall.args[0]
assert.include(newAnnotation.permissions.read, 'group:__world__')
assert.include(reply.permissions.read, 'group:__world__')
it 'does not add the world readable principal if the parent is private', ->
reply = {}
fakeAnnotationMapper.createAnnotation.returns(reply)
controller.reply()
newAnnotation = annotator.createAnnotation.lastCall.args[0]
assert.notInclude(newAnnotation.permissions.read, 'group:__world__')
assert.notInclude(reply.permissions.read, 'group:__world__')
it 'fills the other permissions too', ->
reply = {}
fakeAnnotationMapper.createAnnotation.returns(reply)
controller.reply()
newAnnotation = annotator.createAnnotation.lastCall.args[0]
assert.equal(newAnnotation.permissions.update[0], 'acct:bill@localhost')
assert.equal(newAnnotation.permissions.delete[0], 'acct:bill@localhost')
assert.equal(newAnnotation.permissions.admin[0], 'acct:bill@localhost')
assert.equal(reply.permissions.update[0], 'acct:bill@localhost')
assert.equal(reply.permissions.delete[0], 'acct:bill@localhost')
assert.equal(reply.permissions.admin[0], 'acct:bill@localhost')
describe '#render', ->
controller = null
......
assert = chai.assert
sinon.assert.expose assert, prefix: null
describe 'Discovery', ->
sandbox = sinon.sandbox.create()
fakeTopWindow = null
fakeFrameWindow = null
createDiscovery = null
beforeEach module('h')
beforeEach inject (Discovery) ->
createDiscovery = (win, options) ->
new Discovery(win, options)
createWindow = ->
top: null
addEventListener: sandbox.stub()
removeEventListener: sandbox.stub()
postMessage: sandbox.stub()
length: 0
frames: []
fakeTopWindow = createWindow()
fakeTopWindow.top = fakeTopWindow
fakeFrameWindow = createWindow()
fakeFrameWindow.top = fakeTopWindow
fakeTopWindow.frames = [fakeFrameWindow]
afterEach ->
sandbox.restore()
describe 'startDiscovery', ->
it 'adds a "message" listener to the window object', ->
discovery = createDiscovery(fakeTopWindow)
discovery.startDiscovery(->)
assert.called(fakeTopWindow.addEventListener)
assert.calledWith(fakeTopWindow.addEventListener, 'message', sinon.match.func, false)
describe 'when acting as a server (options.server = true)', ->
server = null
beforeEach ->
server = createDiscovery(fakeFrameWindow, server: true)
it 'sends out a "offer" message to every frame', ->
server.startDiscovery(->)
assert.called(fakeTopWindow.postMessage)
assert.calledWith(fakeTopWindow.postMessage, '__cross_frame_dhcp_offer', '*')
it 'allows the origin to be provided', ->
server = createDiscovery(fakeFrameWindow, server: true, origin: 'foo')
server.startDiscovery(->)
assert.called(fakeTopWindow.postMessage)
assert.calledWith(fakeTopWindow.postMessage, '__cross_frame_dhcp_offer', 'foo')
it 'does not send the message to itself', ->
server.startDiscovery(->)
assert.notCalled(fakeFrameWindow.postMessage)
it 'sends an "ack" on receiving a "request"', ->
fakeFrameWindow.addEventListener.yields({
data: '__cross_frame_dhcp_request'
source: fakeTopWindow
origin: 'top'
})
server.startDiscovery(->)
assert.called(fakeTopWindow.postMessage)
matcher = sinon.match(/__cross_frame_dhcp_ack:\d+/)
assert.calledWith(fakeTopWindow.postMessage, matcher, 'top')
it 'calls the discovery callback on receiving "request"', ->
fakeFrameWindow.addEventListener.yields({
data: '__cross_frame_dhcp_request'
source: fakeTopWindow
origin: 'top'
})
handler = sandbox.stub()
server.startDiscovery(handler)
assert.called(handler)
assert.calledWith(handler, fakeTopWindow, 'top', sinon.match(/\d+/))
it 'raises an error if it recieves an event from another server', ->
fakeFrameWindow.addEventListener.yields({
data: '__cross_frame_dhcp_offer'
source: fakeTopWindow
origin: 'top'
})
handler = sandbox.stub()
assert.throws ->
server.startDiscovery(handler)
describe 'when acting as a client (options.client = false)', ->
client = null
beforeEach ->
client = createDiscovery(fakeTopWindow)
it 'sends out a discovery message to every frame', ->
client.startDiscovery(->)
assert.called(fakeFrameWindow.postMessage)
assert.calledWith(fakeFrameWindow.postMessage, '__cross_frame_dhcp_discovery', '*')
it 'does not send the message to itself', ->
client.startDiscovery(->)
assert.notCalled(fakeTopWindow.postMessage)
it 'sends a "request" in response to an "offer"', ->
fakeTopWindow.addEventListener.yields({
data: '__cross_frame_dhcp_offer'
source: fakeFrameWindow
origin: 'iframe'
})
client.startDiscovery(->)
assert.called(fakeFrameWindow.postMessage)
assert.calledWith(fakeFrameWindow.postMessage, '__cross_frame_dhcp_request', 'iframe')
it 'does not respond to an "offer" if a "request" is already in progress', ->
fakeTopWindow.addEventListener.yields({
data: '__cross_frame_dhcp_offer'
source: fakeFrameWindow
origin: 'iframe1'
})
fakeTopWindow.addEventListener.yields({
data: '__cross_frame_dhcp_offer'
source: fakeFrameWindow
origin: 'iframe2'
})
client.startDiscovery(->)
# Twice, once for discovery, once for offer.
assert.calledTwice(fakeFrameWindow.postMessage)
lastCall = fakeFrameWindow.postMessage.lastCall
assert(lastCall.notCalledWith(sinon.match.string, 'iframe2'))
it 'allows responding to a "request" once a previous "request" has completed', ->
fakeTopWindow.addEventListener.yields({
data: '__cross_frame_dhcp_offer'
source: fakeFrameWindow
origin: 'iframe1'
})
fakeTopWindow.addEventListener.yields({
data: '__cross_frame_dhcp_ack:1234'
source: fakeFrameWindow
origin: 'iframe1'
})
fakeTopWindow.addEventListener.yields({
data: '__cross_frame_dhcp_offer'
source: fakeFrameWindow
origin: 'iframe2'
})
client.startDiscovery(->)
assert.called(fakeFrameWindow.postMessage)
assert.calledWith(fakeFrameWindow.postMessage, '__cross_frame_dhcp_request', 'iframe2')
it 'calls the discovery callback on receiving an "ack"', ->
fakeTopWindow.addEventListener.yields({
data: '__cross_frame_dhcp_ack:1234'
source: fakeFrameWindow
origin: 'iframe'
})
callback = sandbox.stub()
client.startDiscovery(callback)
assert.called(callback)
assert.calledWith(callback, fakeFrameWindow, 'iframe', '1234')
describe 'stopDiscovery', ->
it 'removes the "message" listener from the window', ->
discovery = createDiscovery(fakeFrameWindow)
discovery.startDiscovery()
discovery.stopDiscovery()
handler = fakeFrameWindow.addEventListener.lastCall.args[1]
assert.called(fakeFrameWindow.removeEventListener)
assert.calledWith(fakeFrameWindow.removeEventListener, 'message', handler)
it 'allows startDiscovery to be called with a new handler', ->
discovery = createDiscovery(fakeFrameWindow)
discovery.startDiscovery()
discovery.stopDiscovery()
assert.doesNotThrow ->
discovery.startDiscovery()
......@@ -2,13 +2,278 @@ assert = chai.assert
sinon.assert.expose(assert, prefix: '')
describe 'Annotator.Guest', ->
sandbox = sinon.sandbox.create()
fakeCrossFrame = null
createGuest = (options) ->
element = document.createElement('div')
return new Annotator.Guest(element, options || {})
# Silence Annotator's sassy backchat
before -> sinon.stub(console, 'log')
after -> console.log.restore()
beforeEach -> sandbox.stub(console, 'log')
afterEach -> sandbox.restore()
beforeEach ->
fakeCrossFrame =
onConnect: sandbox.stub()
on: sandbox.stub()
sandbox.stub(Annotator.Plugin, 'CrossFrame').returns(fakeCrossFrame)
describe 'setting up the bridge', ->
it 'sets the scope for the cross frame bridge', ->
guest = createGuest()
options = Annotator.Plugin.CrossFrame.lastCall.args[1]
assert.equal(options.scope, 'annotator:bridge')
it 'provides an event bus for the annotation sync module', ->
guest = createGuest()
options = Annotator.Plugin.CrossFrame.lastCall.args[1]
assert.isFunction(options.on)
assert.isFunction(options.emit)
it 'provides a formatter for the annotation sync module', ->
guest = createGuest()
options = Annotator.Plugin.CrossFrame.lastCall.args[1]
assert.isFunction(options.formatter)
it 'publishes the "panelReady" event when a connection is established', ->
handler = sandbox.stub()
guest = createGuest()
guest.subscribe('panelReady', handler)
fakeCrossFrame.onConnect.yield()
assert.called(handler)
describe 'the event bus .on method', ->
options = null
guest = null
beforeEach ->
guest = createGuest()
options = Annotator.Plugin.CrossFrame.lastCall.args[1]
it 'proxies the event into the annotator event system', ->
fooHandler = sandbox.stub()
barHandler = sandbox.stub()
options.on('foo', fooHandler)
options.on('bar', barHandler)
guest.publish('foo', ['1', '2'])
guest.publish('bar', ['1', '2'])
assert.calledWith(fooHandler, '1', '2')
assert.calledWith(barHandler, '1', '2')
describe 'the event bus .emit method', ->
options = null
guest = null
beforeEach ->
guest = createGuest()
options = Annotator.Plugin.CrossFrame.lastCall.args[1]
it 'calls deleteAnnotation when an annotationDeleted event is recieved', ->
ann = {id: 1, $$tag: 'tag1'}
sandbox.stub(guest, 'deleteAnnotation')
options.emit('annotationDeleted', ann)
assert.called(guest.deleteAnnotation)
assert.calledWith(guest.deleteAnnotation, ann)
it 'does not proxy the annotationDeleted event', ->
handler = sandbox.stub()
guest.subscribe('annotationDeleted', handler)
options.emit('annotationDeleted', {})
# Called only once by the deleteAnnotation() method.
assert.calledOnce(handler)
it 'calls loadAnnotations when an loadAnnotations event is recieved', ->
ann = {id: 1, $$tag: 'tag1'}
target = sandbox.stub(guest, 'loadAnnotations')
options.emit('loadAnnotations', [ann])
assert.called(target)
assert.calledWith(target, [ann])
it 'does not proxy the loadAnnotations event', ->
handler = sandbox.stub()
guest.subscribe('loadAnnotations', handler)
options.emit('loadAnnotations', [])
assert.notCalled(handler)
it 'proxies all other events into the annotator event system', ->
fooHandler = sandbox.stub()
barHandler = sandbox.stub()
guest.subscribe('foo', fooHandler)
guest.subscribe('bar', barHandler)
options.emit('foo', '1', '2')
options.emit('bar', '1', '2')
assert.calledWith(fooHandler, '1', '2')
assert.calledWith(barHandler, '1', '2')
describe 'the formatter', ->
options = null
guest = null
beforeEach ->
guest = createGuest()
guest.plugins.Document = {uri: -> 'http://example.com'}
options = Annotator.Plugin.CrossFrame.lastCall.args[1]
it 'applies a "uri" property to the formatted object', ->
ann = {$$tag: 'tag1'}
formatted = options.formatter(ann)
assert.equal(formatted.uri, 'http://example.com/')
it 'keeps an existing uri property', ->
ann = {$$tag: 'tag1', uri: 'http://example.com/foo'}
formatted = options.formatter(ann)
assert.equal(formatted.uri, 'http://example.com/foo')
it 'copies the properties from the provided annotation', ->
ann = {$$tag: 'tag1'}
formatted = options.formatter(ann)
assert.equal(formatted.$$tag, 'tag1')
it 'strips the "anchors" property', ->
ann = {$$tag: 'tag1', anchors: []}
formatted = options.formatter(ann)
assert.notProperty(formatted, 'anchors')
it 'clones the document.title array if present', ->
title = ['Page Title']
ann = {$$tag: 'tag1', document: {title: title}}
formatted = options.formatter(ann)
assert.notStrictEqual(title, formatted.document.title)
assert.deepEqual(title, formatted.document.title)
describe 'annotation UI events', ->
emitGuestEvent = (event, args...) ->
fn(args...) for [evt, fn] in fakeCrossFrame.on.args when event == evt
describe 'on "onEditorHide" event', ->
it 'hides the editor', ->
target = sandbox.stub(Annotator.Guest.prototype, 'onEditorHide')
guest = createGuest()
emitGuestEvent('onEditorHide')
assert.called(target)
describe 'on "onEditorSubmit" event', ->
it 'sumbits the editor', ->
target = sandbox.stub(Annotator.Guest.prototype, 'onEditorSubmit')
guest = createGuest()
emitGuestEvent('onEditorSubmit')
assert.called(target)
describe 'on "focusAnnotations" event', ->
it 'focuses any annotations with a matching tag', ->
guest = createGuest()
highlights = [
{annotation: {$$tag: 'tag1'}, setFocused: sandbox.stub()}
{annotation: {$$tag: 'tag2'}, setFocused: sandbox.stub()}
]
sandbox.stub(guest.anchoring, 'getHighlights').returns(highlights)
emitGuestEvent('focusAnnotations', 'ctx', ['tag1'])
assert.called(highlights[0].setFocused)
assert.calledWith(highlights[0].setFocused, true)
it 'unfocuses any annotations without a matching tag', ->
guest = createGuest()
highlights = [
{annotation: {$$tag: 'tag1'}, setFocused: sandbox.stub()}
{annotation: {$$tag: 'tag2'}, setFocused: sandbox.stub()}
]
sandbox.stub(guest.anchoring, 'getHighlights').returns(highlights)
emitGuestEvent('focusAnnotations', 'ctx', ['tag1'])
assert.called(highlights[1].setFocused)
assert.calledWith(highlights[1].setFocused, false)
describe 'on "scrollToAnnotation" event', ->
it 'scrolls to the highLight with the matching tag', ->
guest = createGuest()
highlights = [
{annotation: {$$tag: 'tag1'}, scrollTo: sandbox.stub()}
]
sandbox.stub(guest.anchoring, 'getHighlights').returns(highlights)
emitGuestEvent('scrollToAnnotation', 'ctx', 'tag1')
assert.called(highlights[0].scrollTo)
describe 'on "getDocumentInfo" event', ->
guest = null
beforeEach ->
guest = createGuest()
guest.plugins.PDF =
uri: sandbox.stub().returns('http://example.com')
getMetaData: sandbox.stub()
it 'calls the callback with the href and pdf metadata', (done) ->
assertComplete = (payload) ->
try
assert.equal(payload.uri, 'http://example.com/')
assert.equal(payload.metadata, metadata)
done()
catch e
done(e)
ctx = {complete: assertComplete, delayReturn: sandbox.stub()}
metadata = {title: 'hi'}
promise = Promise.resolve(metadata)
guest.plugins.PDF.getMetaData.returns(promise)
emitGuestEvent('getDocumentInfo', ctx)
it 'calls the callback with the href and document metadata if pdf check fails', (done) ->
assertComplete = (payload) ->
try
assert.equal(payload.uri, 'http://example.com/')
assert.equal(payload.metadata, metadata)
done()
catch e
done(e)
ctx = {complete: assertComplete, delayReturn: sandbox.stub()}
metadata = {title: 'hi'}
guest.plugins.Document = {metadata: metadata}
promise = Promise.reject(new Error('Not a PDF document'))
guest.plugins.PDF.getMetaData.returns(promise)
emitGuestEvent('getDocumentInfo', ctx)
it 'notifies the channel that the return value is async', ->
delete guest.plugins.PDF
ctx = {complete: sandbox.stub(), delayReturn: sandbox.stub()}
emitGuestEvent('getDocumentInfo', ctx)
assert.calledWith(ctx.delayReturn, true)
describe 'on "setTool" event', ->
it 'updates the .tool property', ->
guest = createGuest()
emitGuestEvent('setTool', 'ctx', 'highlighter')
assert.equal(guest.tool, 'highlighter')
it 'publishes the "setTool" event', ->
handler = sandbox.stub()
guest = createGuest()
guest.subscribe('setTool', handler)
emitGuestEvent('setTool', 'ctx', 'highlighter')
assert.called(handler)
assert.calledWith(handler, 'highlighter')
describe 'on "setVisibleHighlights" event', ->
it 'publishes the "setVisibleHighlights" event', ->
handler = sandbox.stub()
guest = createGuest()
guest.subscribe('setTool', handler)
emitGuestEvent('setTool', 'ctx', 'highlighter')
assert.called(handler)
assert.calledWith(handler, 'highlighter')
describe 'onAdderMouseUp', ->
it 'it prevents the default browser action when triggered', () ->
......
......@@ -2,13 +2,23 @@ assert = chai.assert
sinon.assert.expose(assert, prefix: '')
describe 'Annotator.Host', ->
sandbox = sinon.sandbox.create()
createHost = (options) ->
element = document.createElement('div')
return new Annotator.Host(element, options)
# Disable Annotator's ridiculous logging.
before -> sinon.stub(console, 'log')
after -> console.log.restore()
beforeEach ->
# Disable Annotator's ridiculous logging.
sandbox.stub(console, 'log')
fakeCrossFrame =
onConnect: sandbox.stub()
on: sandbox.stub()
sandbox.stub(Annotator.Plugin, 'CrossFrame').returns(fakeCrossFrame)
afterEach -> sandbox.restore()
describe 'options', ->
it 'enables highlighting when showHighlights option is provided', (done) ->
......
......@@ -3,13 +3,13 @@ sinon.assert.expose assert, prefix: null
sandbox = sinon.sandbox.create()
mockFlash = sandbox.spy()
mockDocumentHelpers = {absoluteURI: -> '/session'}
mockDocument = {prop: -> '/session'}
describe 'session', ->
beforeEach module('h.session')
beforeEach module ($provide, sessionProvider) ->
$provide.value 'documentHelpers', mockDocumentHelpers
$provide.value '$document', mockDocument
$provide.value 'flash', mockFlash
sessionProvider.actions =
login:
......
......@@ -20,6 +20,7 @@ describe 'streamer', ->
beforeEach inject (_streamer_) ->
streamer = _streamer_
streamer.clientId = 'FAKE_CLIENT_ID'
afterEach ->
sandbox.restore()
......
assert = chai.assert
sinon.assert.expose(assert, prefix: null)
sandbox = sinon.sandbox.create()
sinon.assert.expose(assert, prefix: '')
describe 'Annotator.Threading', ->
createThreadingInstance = (options) ->
element = document.createElement('div')
return new Annotator.Plugin.Threading(element, options || {})
describe 'Threading', ->
instance = null
beforeEach module('h')
beforeEach inject (_threading_) ->
instance = _threading_
describe 'pruneEmpties', ->
it 'keeps public messages with no children', ->
......@@ -18,7 +20,6 @@ describe 'Annotator.Threading', ->
root.addChild(threadB)
root.addChild(threadC)
instance = createThreadingInstance()
instance.pruneEmpties(root)
assert.equal(root.children.length, 3)
......@@ -34,7 +35,6 @@ describe 'Annotator.Threading', ->
threadA.addChild(threadA1)
threadA.addChild(threadA2)
instance = createThreadingInstance()
instance.pruneEmpties(root)
assert.equal(root.children.length, 1)
......@@ -49,7 +49,6 @@ describe 'Annotator.Threading', ->
root.addChild(threadB)
root.addChild(threadC)
instance = createThreadingInstance()
instance.pruneEmpties(root)
assert.equal(root.children.length, 0)
......@@ -65,7 +64,6 @@ describe 'Annotator.Threading', ->
threadA.addChild(threadA1)
threadA.addChild(threadA2)
instance = createThreadingInstance()
instance.pruneEmpties(root)
assert.equal(root.children.length, 1)
......@@ -81,64 +79,6 @@ describe 'Annotator.Threading', ->
threadA.addChild(threadA1)
threadA.addChild(threadA2)
instance = createThreadingInstance()
instance.pruneEmpties(root)
assert.equal(root.children.length, 0)
describe 'handles events', ->
annotator = null
instance = null
beforeEach ->
instance = createThreadingInstance()
instance.pluginInit()
annotator =
publish: (event, args) ->
unless angular.isArray(args) then args = [args]
meth = instance.events[event]
instance[meth].apply(instance, args)
afterEach ->
sandbox.restore()
it 'calls the thread method on beforeAnnotationCreated', ->
annotation = {id: 'foo'}
sandbox.spy(instance, 'thread')
annotator.publish 'beforeAnnotationCreated', annotation
assert.calledWithMatch instance.thread, [annotation]
it 'calls the thread method on annotationsLoaded', ->
annotation = {id: 'foo'}
sandbox.spy(instance, 'thread')
annotator.publish 'annotationsLoaded', [annotation]
assert.calledWithMatch instance.thread, [annotation]
it 'removes matching top level threads when annotationDeleted is called', ->
annotation = {id: 'foo'}
instance.thread([annotation])
assert.equal(instance.root.children.length, 1)
assert.equal(instance.idTable['foo'].message, annotation)
sandbox.spy(instance, 'pruneEmpties')
annotator.publish 'annotationDeleted', annotation
assert.called(instance.pruneEmpties)
assert.equal(instance.root.children.length, 0)
assert.isUndefined(instance.idTable['foo'])
it 'removes matching reply threads when annotationDeleted is called', ->
parent = {id: 'foo'}
reply = {id: 'bar', references: ['foo']}
instance.thread([parent, reply])
assert.equal(instance.idTable['foo'].children.length, 1)
assert.equal(instance.idTable['bar'].message, reply)
sandbox.spy(instance, 'pruneEmpties')
annotator.publish 'annotationDeleted', reply
assert.called(instance.pruneEmpties)
assert.equal(instance.idTable['foo'].children.length, 0)
assert.isUndefined(instance.idTable['bar'])
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