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
crossframe.notify
method: 'focusAnnotations'
params: highlights
$scope.scrollTo = (annotation) ->
if angular.isObject annotation
for p in annotator.providers
p.channel.notify
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)
This diff is collapsed.
This diff is collapsed.
......@@ -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)
This diff is collapsed.
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()
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -20,6 +20,7 @@ describe 'streamer', ->
beforeEach inject (_streamer_) ->
streamer = _streamer_
streamer.clientId = 'FAKE_CLIENT_ID'
afterEach ->
sandbox.restore()
......
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