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) -> ...@@ -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(configureDocument)
.config(configureLocation) .config(configureLocation)
.config(configureRoutes) .config(configureRoutes)
.config(configureTemplates) .config(configureTemplates)
unless mocha? # Crude method of detecting test environment.
module.run(setupCrossFrame)
module.run(setupStreamer)
...@@ -18,6 +18,21 @@ class Auth ...@@ -18,6 +18,21 @@ class Auth
_checkingToken = false _checkingToken = false
@user = undefined @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. # Fired when the identity-service successfully requests authentication.
# Sets up the Annotator.Auth plugin instance and the auth.user property. # 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 # 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 class AppController
this.$inject = [ this.$inject = [
'$document', '$location', '$route', '$scope', '$window', '$controller', '$document', '$location', '$route', '$scope', '$window',
'annotator', 'auth', 'drafts', 'identity', 'auth', 'drafts', 'identity',
'permissions', 'streamer', 'streamfilter' 'permissions', 'streamer', 'streamfilter', 'annotationUI',
'annotationMapper', 'threading'
] ]
constructor: ( constructor: (
$document, $location, $route, $scope, $window, $controller, $document, $location, $route, $scope, $window,
annotator, auth, drafts, identity, auth, drafts, identity,
permissions, streamer, streamfilter, permissions, streamer, streamfilter, annotationUI,
annotationMapper, threading
) -> ) ->
{plugins, host, providers} = annotator $controller(AnnotationUIController, {$scope})
$scope.auth = auth $scope.auth = auth
isFirstRun = $location.search().hasOwnProperty('firstrun') isFirstRun = $location.search().hasOwnProperty('firstrun')
...@@ -24,10 +45,10 @@ class AppController ...@@ -24,10 +45,10 @@ class AppController
return unless data?.length return unless data?.length
switch action switch action
when 'create', 'update', 'past' when 'create', 'update', 'past'
annotator.loadAnnotations data annotationMapper.loadAnnotations data
when 'delete' when 'delete'
for annotation in data for annotation in data
annotator.publish 'annotationDeleted', (annotation) $scope.$emit('annotationDeleted', annotation)
streamer.onmessage = (data) -> streamer.onmessage = (data) ->
return if !data or data.type != 'annotation-notification' return if !data or data.type != 'annotation-notification'
...@@ -43,12 +64,12 @@ class AppController ...@@ -43,12 +64,12 @@ class AppController
# Clean up any annotations that need to be unloaded. # Clean up any annotations that need to be unloaded.
for id, container of $scope.threading.idTable when container.message for id, container of $scope.threading.idTable when container.message
# Remove annotations not belonging to this user when highlighting. # Remove annotations not belonging to this user when highlighting.
if annotator.tool is 'highlight' and annotation.user != auth.user if annotationUI.tool is 'highlight' and annotation.user != auth.user
annotator.publish 'annotationDeleted', container.message $scope.$emit('annotationDeleted', container.message)
drafts.remove annotation drafts.remove annotation
# Remove annotations the user is not authorized to view. # Remove annotations the user is not authorized to view.
else if not permissions.permits 'read', container.message, auth.user else if not permissions.permits 'read', container.message, auth.user
annotator.publish 'annotationDeleted', container.message $scope.$emit('annotationDeleted', container.message)
drafts.remove container.message drafts.remove container.message
$scope.$watch 'sort.name', (name) -> $scope.$watch 'sort.name', (name) ->
...@@ -69,7 +90,7 @@ class AppController ...@@ -69,7 +90,7 @@ class AppController
# Update any edits in progress. # Update any edits in progress.
for draft in drafts.all() for draft in drafts.all()
annotator.publish 'beforeAnnotationCreated', draft $scope.$emit('beforeAnnotationCreated', draft)
# Reopen the streamer. # Reopen the streamer.
streamer.close() streamer.close()
...@@ -95,8 +116,7 @@ class AppController ...@@ -95,8 +116,7 @@ class AppController
$scope.clearSelection = -> $scope.clearSelection = ->
$scope.search.query = '' $scope.search.query = ''
$scope.selectedAnnotations = null annotationUI.clearSelectedAnnotations()
$scope.selectedAnnotationsCount = 0
$scope.dialog = visible: false $scope.dialog = visible: false
...@@ -109,22 +129,21 @@ class AppController ...@@ -109,22 +129,21 @@ class AppController
update: (query) -> update: (query) ->
unless angular.equals $location.search()['q'], query unless angular.equals $location.search()['q'], query
$location.search('q', query or null) $location.search('q', query or null)
delete $scope.selectedAnnotations annotationUI.clearSelectedAnnotations()
delete $scope.selectedAnnotationsCount
$scope.sort = name: 'Location' $scope.sort = name: 'Location'
$scope.threading = plugins.Threading $scope.threading = threading
$scope.threadRoot = $scope.threading?.root $scope.threadRoot = $scope.threading?.root
class AnnotationViewerController class AnnotationViewerController
this.$inject = [ this.$inject = [
'$location', '$routeParams', '$scope', '$location', '$routeParams', '$scope',
'annotator', 'streamer', 'store', 'streamfilter' 'streamer', 'store', 'streamfilter', 'annotationMapper'
] ]
constructor: ( constructor: (
$location, $routeParams, $scope, $location, $routeParams, $scope,
annotator, streamer, store, streamfilter streamer, store, streamfilter, annotationMapper
) -> ) ->
# Tells the view that these annotations are standalone # Tells the view that these annotations are standalone
$scope.isEmbedded = false $scope.isEmbedded = false
...@@ -141,11 +160,10 @@ class AnnotationViewerController ...@@ -141,11 +160,10 @@ class AnnotationViewerController
id = $routeParams.id id = $routeParams.id
store.SearchResource.get _id: id, ({rows}) -> store.SearchResource.get _id: id, ({rows}) ->
annotator.loadAnnotations(rows) annotationMapper.loadAnnotations(rows)
$scope.threadRoot = children: [$scope.threading.getContainer(id)] $scope.threadRoot = children: [$scope.threading.getContainer(id)]
store.SearchResource.get references: id, ({rows}) -> store.SearchResource.get references: id, ({rows}) ->
annotator.loadAnnotations(rows) annotationMapper.loadAnnotations(rows)
streamfilter streamfilter
.setMatchPolicyIncludeAny() .setMatchPolicyIncludeAny()
...@@ -156,12 +174,12 @@ class AnnotationViewerController ...@@ -156,12 +174,12 @@ class AnnotationViewerController
class ViewerController class ViewerController
this.$inject = [ this.$inject = [
'$scope', '$route', '$scope', '$route', 'annotationUI', 'crossframe', 'annotationMapper',
'annotator', 'auth', 'flash', 'streamer', 'streamfilter', 'store' 'auth', 'flash', 'streamer', 'streamfilter', 'store'
] ]
constructor: ( constructor: (
$scope, $route, $scope, $route, annotationUI, crossframe, annotationMapper,
annotator, auth, flash, streamer, streamfilter, store auth, flash, streamer, streamfilter, store
) -> ) ->
# Tells the view that these annotations are embedded into the owner doc # Tells the view that these annotations are embedded into the owner doc
$scope.isEmbedded = true $scope.isEmbedded = true
...@@ -171,49 +189,47 @@ class ViewerController ...@@ -171,49 +189,47 @@ class ViewerController
loadAnnotations = -> loadAnnotations = ->
query = limit: 200 query = limit: 200
if annotator.tool is 'highlight' if annotationUI.tool is 'highlight'
return unless auth.user return unless auth.user
query.user = 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 for e in p.entities when e not in loaded
loaded.push e loaded.push e
store.SearchResource.get angular.extend(uri: e, query), (results) -> r = store.SearchResource.get angular.extend(uri: e, query), (results) ->
annotator.loadAnnotations(results.rows) annotationMapper.loadAnnotations(results.rows)
streamfilter.resetFilter().addClause('/uri', 'one_of', loaded) 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) streamfilter.addClause('/user', 'equals', auth.user)
streamer.send({filter: streamfilter.getFilter()}) streamer.send({filter: streamfilter.getFilter()})
$scope.$watch (-> annotator.tool), (newVal, oldVal) -> $scope.$watch (-> annotationUI.tool), (newVal, oldVal) ->
return if newVal is oldVal return if newVal is oldVal
$route.reload() $route.reload()
$scope.$watchCollection (-> annotator.providers), loadAnnotations $scope.$watchCollection (-> crossframe.providers), loadAnnotations
$scope.focus = (annotation) -> $scope.focus = (annotation) ->
if angular.isObject annotation if angular.isObject annotation
highlights = [annotation.$$tag] highlights = [annotation.$$tag]
else else
highlights = [] highlights = []
for p in annotator.providers crossframe.notify
p.channel.notify method: 'focusAnnotations'
method: 'focusAnnotations' params: highlights
params: highlights
$scope.scrollTo = (annotation) -> $scope.scrollTo = (annotation) ->
if angular.isObject annotation if angular.isObject annotation
for p in annotator.providers crossframe.notify
p.channel.notify method: 'scrollToAnnotation'
method: 'scrollToAnnotation' params: annotation.$$tag
params: annotation.$$tag
$scope.shouldShowThread = (container) -> $scope.shouldShowThread = (container) ->
if $scope.selectedAnnotations? and not container.parent.parent if annotationUI.hasSelectedAnnotations() and not container.parent.parent
$scope.selectedAnnotations[container.message?.id] annotationUI.isAnnotationSelected(container.message?.id)
else else
true true
...@@ -224,3 +240,4 @@ angular.module('h') ...@@ -224,3 +240,4 @@ angular.module('h')
.controller('AppController', AppController) .controller('AppController', AppController)
.controller('ViewerController', ViewerController) .controller('ViewerController', ViewerController)
.controller('AnnotationViewerController', AnnotationViewerController) .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) -> ...@@ -26,14 +26,15 @@ validate = (value) ->
# #
# `AnnotationController` provides an API for the annotation directive. It # `AnnotationController` provides an API for the annotation directive. It
# manages the interaction between the domain and view models and uses the # 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 = [ AnnotationController = [
'$document', '$scope', '$timeout', '$scope', '$timeout', '$rootScope', '$document',
'annotator', 'auth', 'drafts', 'flash', 'permissions', 'timeHelpers' 'auth', 'drafts', 'flash', 'permissions',
($document, $scope, $timeout, 'timeHelpers', 'annotationUI', 'annotationMapper'
annotator, auth, drafts, flash, permissions, timeHelpers ($scope, $timeout, $rootScope, $document,
) -> auth, drafts, flash, permissions,
timeHelpers, annotationUI, annotationMapper) ->
@annotation = {} @annotation = {}
@action = 'view' @action = 'view'
@document = null @document = null
...@@ -44,7 +45,7 @@ AnnotationController = [ ...@@ -44,7 +45,7 @@ AnnotationController = [
@showDiff = undefined @showDiff = undefined
@timestamp = null @timestamp = null
highlight = annotator.tool is 'highlight' highlight = annotationUI.tool is 'highlight'
model = $scope.annotationGet() model = $scope.annotationGet()
original = null original = null
vm = this vm = this
...@@ -93,7 +94,7 @@ AnnotationController = [ ...@@ -93,7 +94,7 @@ AnnotationController = [
### ###
this.delete = -> this.delete = ->
if confirm "Are you sure you want to delete this annotation?" if confirm "Are you sure you want to delete this annotation?"
annotator.deleteAnnotation model annotationMapper.deleteAnnotation model
###* ###*
# @ngdoc method # @ngdoc method
...@@ -114,7 +115,7 @@ AnnotationController = [ ...@@ -114,7 +115,7 @@ AnnotationController = [
this.revert = -> this.revert = ->
drafts.remove model drafts.remove model
if @action is 'create' if @action is 'create'
annotator.publish 'annotationDeleted', model $rootScope.$emit('annotationDeleted', model)
else else
this.render() this.render()
@action = 'view' @action = 'view'
...@@ -150,10 +151,10 @@ AnnotationController = [ ...@@ -150,10 +151,10 @@ AnnotationController = [
switch @action switch @action
when 'create' when 'create'
model.$create().then -> model.$create().then ->
annotator.publish 'annotationCreated', model $rootScope.$emit('annotationCreated', model)
when 'delete', 'edit' when 'delete', 'edit'
model.$update(id: model.id).then -> model.$update(id: model.id).then ->
annotator.publish 'annotationUpdated', model $rootScope.$emit('annotationUpdated', model)
@editing = false @editing = false
@action = 'view' @action = 'view'
...@@ -173,7 +174,7 @@ AnnotationController = [ ...@@ -173,7 +174,7 @@ AnnotationController = [
# Construct the reply. # Construct the reply.
references = [references..., id] references = [references..., id]
reply = annotator.createAnnotation {references, uri} reply = annotationMapper.createAnnotation({references, uri})
if auth.user? if auth.user?
if permissions.isPublic model.permissions if permissions.isPublic model.permissions
...@@ -270,7 +271,7 @@ AnnotationController = [ ...@@ -270,7 +271,7 @@ AnnotationController = [
highlight = false # skip this on future updates highlight = false # skip this on future updates
model.permissions = permissions.private() model.permissions = permissions.private()
model.$create().then -> model.$create().then ->
annotator.publish 'annotationCreated', model $rootScope.$emit('annotationCreated', model)
highlight = false # skip this on future updates highlight = false # skip this on future updates
else else
drafts.add model, => this.revert() drafts.add model, => this.revert()
...@@ -298,9 +299,9 @@ AnnotationController = [ ...@@ -298,9 +299,9 @@ AnnotationController = [
# value is used to signal whether the annotation is being displayed inside # value is used to signal whether the annotation is being displayed inside
# an embedded widget. # an embedded widget.
### ###
annotation = [ annotationDirective = [
'$document', 'annotator', '$document',
($document, annotator) -> ($document) ->
linkFn = (scope, elem, attrs, [ctrl, thread, threadFilter, counter]) -> linkFn = (scope, elem, attrs, [ctrl, thread, threadFilter, counter]) ->
# Observe the embedded attribute # Observe the embedded attribute
attrs.$observe 'annotationEmbedded', (value) -> attrs.$observe 'annotationEmbedded', (value) ->
...@@ -352,4 +353,4 @@ annotation = [ ...@@ -352,4 +353,4 @@ annotation = [
angular.module('h') angular.module('h')
.controller('AnnotationController', AnnotationController) .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 ...@@ -52,10 +52,25 @@ class Annotator.Guest extends Annotator
delete @options.app 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) => formatter: (annotation) =>
formatted = {} formatted = {}
formatted['uri'] = @getHref() formatted.uri = @getHref()
for k, v of annotation when k isnt 'anchors' for k, v of annotation when k isnt 'anchors'
formatted[k] = v formatted[k] = v
# Work around issue in jschannel where a repeated object is considered # Work around issue in jschannel where a repeated object is considered
...@@ -63,13 +78,9 @@ class Annotator.Guest extends Annotator ...@@ -63,13 +78,9 @@ class Annotator.Guest extends Annotator
if formatted.document?.title if formatted.document?.title
formatted.document.title = formatted.document.title.slice() formatted.document.title = formatted.document.title.slice()
formatted formatted
onConnect: (source, origin, scope) =>
@panel = this._setupXDM this.addPlugin('CrossFrame', cfOptions)
window: source @crossframe = this._connectAnnotationUISync(this.plugins.CrossFrame)
origin: origin
scope: "#{scope}:provider"
onReady: =>
this.publish('panelReady')
# Load plugins # Load plugins
for own name, opts of @options for own name, opts of @options
...@@ -99,7 +110,7 @@ class Annotator.Guest extends Annotator ...@@ -99,7 +110,7 @@ class Annotator.Guest extends Annotator
annotations = (hl.annotation for hl in highlights) annotations = (hl.annotation for hl in highlights)
# Announce the new positions, so that the sidebar knows # 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 # Watch for removed highlights, and update positions in sidebar
this.subscribe "highlightRemoved", (highlight) => this.subscribe "highlightRemoved", (highlight) =>
...@@ -118,7 +129,7 @@ class Annotator.Guest extends Annotator ...@@ -118,7 +129,7 @@ class Annotator.Guest extends Annotator
delete highlight.anchor.target.pos delete highlight.anchor.target.pos
# Announce the new positions, so that the sidebar knows # 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 # Utility function to remove the hash part from a URL
_removeHash: (url) -> _removeHash: (url) ->
...@@ -142,35 +153,22 @@ class Annotator.Guest extends Annotator ...@@ -142,35 +153,22 @@ class Annotator.Guest extends Annotator
metadata.link?.forEach (link) => link.href = @_removeHash link.href metadata.link?.forEach (link) => link.href = @_removeHash link.href
metadata metadata
_setupXDM: (options) -> _connectAnnotationUISync: (crossframe) ->
# jschannel chokes FF and Chrome extension origins. crossframe.onConnect(=> this.publish('panelReady'))
if (options.origin.match /^chrome-extension:\/\//) or crossframe.on('onEditorHide', this.onEditorHide)
(options.origin.match /^resource:\/\//) crossframe.on('onEditorSubmit', this.onEditorSubmit)
options.origin = '*' crossframe.on 'focusAnnotations', (ctx, tags=[]) =>
channel = Channel.build options
channel
.bind('onEditorHide', this.onEditorHide)
.bind('onEditorSubmit', this.onEditorSubmit)
.bind('focusAnnotations', (ctx, tags=[]) =>
for hl in @anchoring.getHighlights() for hl in @anchoring.getHighlights()
if hl.annotation.$$tag in tags if hl.annotation.$$tag in tags
hl.setFocused true hl.setFocused true
else else
hl.setFocused false hl.setFocused false
) crossframe.on 'scrollToAnnotation', (ctx, tag) =>
.bind('scrollToAnnotation', (ctx, tag) =>
for hl in @anchoring.getHighlights() for hl in @anchoring.getHighlights()
if hl.annotation.$$tag is tag if hl.annotation.$$tag is tag
hl.scrollTo() hl.scrollTo()
return return
) crossframe.on 'getDocumentInfo', (trans) =>
.bind('getDocumentInfo', (trans) =>
(@plugins.PDF?.getMetaData() ? Promise.reject()) (@plugins.PDF?.getMetaData() ? Promise.reject())
.then (md) => .then (md) =>
trans.complete trans.complete
...@@ -180,18 +178,14 @@ class Annotator.Guest extends Annotator ...@@ -180,18 +178,14 @@ class Annotator.Guest extends Annotator
trans.complete trans.complete
uri: @getHref() uri: @getHref()
metadata: @getMetadata() metadata: @getMetadata()
.catch (e) ->
trans.delayReturn(true) trans.delayReturn(true)
) crossframe.on 'setTool', (ctx, name) =>
.bind('setTool', (ctx, name) =>
@tool = name @tool = name
this.publish 'setTool', name this.publish 'setTool', name
) crossframe.on 'setVisibleHighlights', (ctx, state) =>
.bind('setVisibleHighlights', (ctx, state) =>
this.publish 'setVisibleHighlights', state this.publish 'setVisibleHighlights', state
)
_setupWrapper: -> _setupWrapper: ->
@wrapper = @element @wrapper = @element
...@@ -239,31 +233,31 @@ class Annotator.Guest extends Annotator ...@@ -239,31 +233,31 @@ class Annotator.Guest extends Annotator
createAnnotation: -> createAnnotation: ->
annotation = super annotation = super
this.plugins.Bridge.sync([annotation]) this.plugins.CrossFrame.sync([annotation])
annotation annotation
showAnnotations: (annotations) => showAnnotations: (annotations) =>
@panel?.notify @crossframe?.notify
method: "showAnnotations" method: "showAnnotations"
params: (a.$$tag for a in annotations) params: (a.$$tag for a in annotations)
toggleAnnotationSelection: (annotations) => toggleAnnotationSelection: (annotations) =>
@panel?.notify @crossframe?.notify
method: "toggleAnnotationSelection" method: "toggleAnnotationSelection"
params: (a.$$tag for a in annotations) params: (a.$$tag for a in annotations)
updateAnnotations: (annotations) => updateAnnotations: (annotations) =>
@panel?.notify @crossframe?.notify
method: "updateAnnotations" method: "updateAnnotations"
params: (a.$$tag for a in annotations) params: (a.$$tag for a in annotations)
showEditor: (annotation) => showEditor: (annotation) =>
@panel?.notify @crossframe?.notify
method: "showEditor" method: "showEditor"
params: annotation.$$tag params: annotation.$$tag
focusAnnotations: (annotations) => focusAnnotations: (annotations) =>
@panel?.notify @crossframe?.notify
method: "focusAnnotations" method: "focusAnnotations"
params: (a.$$tag for a in annotations) params: (a.$$tag for a in annotations)
...@@ -328,7 +322,8 @@ class Annotator.Guest extends Annotator ...@@ -328,7 +322,8 @@ class Annotator.Guest extends Annotator
# toggle: should this toggle membership in an existing selection? # toggle: should this toggle membership in an existing selection?
selectAnnotations: (annotations, toggle) => selectAnnotations: (annotations, toggle) =>
if 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 this.toggleAnnotationSelection annotations
else else
# Tell sidebar to show the viewer for these annotations # Tell sidebar to show the viewer for these annotations
...@@ -358,7 +353,7 @@ class Annotator.Guest extends Annotator ...@@ -358,7 +353,7 @@ class Annotator.Guest extends Annotator
(event.metaKey or event.ctrlKey) (event.metaKey or event.ctrlKey)
setTool: (name) -> setTool: (name) ->
@panel?.notify @crossframe?.notify
method: 'setTool' method: 'setTool'
params: name params: name
...@@ -366,7 +361,7 @@ class Annotator.Guest extends Annotator ...@@ -366,7 +361,7 @@ class Annotator.Guest extends Annotator
setVisibleHighlights: (shouldShowHighlights) -> setVisibleHighlights: (shouldShowHighlights) ->
return if @visibleHighlights == shouldShowHighlights return if @visibleHighlights == shouldShowHighlights
@panel?.notify @crossframe?.notify
method: 'setVisibleHighlights' method: 'setVisibleHighlights'
params: shouldShowHighlights params: shouldShowHighlights
...@@ -385,11 +380,11 @@ class Annotator.Guest extends Annotator ...@@ -385,11 +380,11 @@ class Annotator.Guest extends Annotator
# Open the sidebar # Open the sidebar
showFrame: -> showFrame: ->
@panel?.notify method: 'open' @crossframe?.notify method: 'open'
# Close the sidebar # Close the sidebar
hideFrame: -> hideFrame: ->
@panel?.notify method: 'back' @crossframe?.notify method: 'back'
addToken: (token) => addToken: (token) =>
@api.notify @api.notify
......
...@@ -26,265 +26,11 @@ renderFactory = ['$$rAF', ($$rAF) -> ...@@ -26,265 +26,11 @@ renderFactory = ['$$rAF', ($$rAF) ->
] ]
class Hypothesis extends Annotator # Dummy class that wraps annotator until the Auth plugin is removed.
events: class AngularAnnotator extends Annotator
'beforeAnnotationCreated': 'beforeAnnotationCreated' this.$inject = ['$document']
'annotationDeleted': 'annotationDeleted' constructor: ($document) ->
'annotationsLoaded': 'digest' super(document.createElement('div'))
# 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
class DraftProvider class DraftProvider
...@@ -476,5 +222,5 @@ class ViewFilter ...@@ -476,5 +222,5 @@ class ViewFilter
angular.module('h') angular.module('h')
.factory('render', renderFactory) .factory('render', renderFactory)
.provider('drafts', DraftProvider) .provider('drafts', DraftProvider)
.service('annotator', Hypothesis) .service('annotator', AngularAnnotator)
.service('viewFilter', ViewFilter) .service('viewFilter', ViewFilter)
...@@ -18,7 +18,7 @@ angular.module('h') ...@@ -18,7 +18,7 @@ angular.module('h')
svc = $document.find('link') svc = $document.find('link')
.filter -> @rel is 'service' and @type is 'application/annotatorsvc+json' .filter -> @rel is 'service' and @type is 'application/annotatorsvc+json'
.filter -> @href .filter -> @href
.prop('href') .prop('href') or ''
camelize = (string) -> camelize = (string) ->
string.replace /(?:^|_)([a-z])/g, (_, char) -> char.toUpperCase() string.replace /(?:^|_)([a-z])/g, (_, char) -> char.toUpperCase()
......
...@@ -121,15 +121,5 @@ backoff = (index, max) -> ...@@ -121,15 +121,5 @@ backoff = (index, max) ->
return 500 * Math.random() * (Math.pow(2, index) - 1) 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', []) angular.module('h.streamer', [])
.service('streamer', Streamer) .service('streamer', Streamer)
.run(run)
class StreamSearchController class StreamSearchController
this.inject = [ this.inject = [
'$scope', '$rootScope', '$routeParams', '$scope', '$rootScope', '$routeParams',
'annotator', 'auth', 'queryparser', 'searchfilter', 'store', 'auth', 'queryparser', 'searchfilter', 'store',
'streamer', 'streamfilter' 'streamer', 'streamfilter', 'annotationMapper'
] ]
constructor: ( constructor: (
$scope, $rootScope, $routeParams $scope, $rootScope, $routeParams
annotator, auth, queryparser, searchfilter, store, auth, queryparser, searchfilter, store,
streamer, streamfilter streamer, streamfilter, annotationMapper
) -> ) ->
# Initialize the base filter # Initialize the base filter
streamfilter streamfilter
...@@ -24,7 +24,7 @@ class StreamSearchController ...@@ -24,7 +24,7 @@ class StreamSearchController
searchParams = searchfilter.toObject $scope.search.query searchParams = searchfilter.toObject $scope.search.query
query = angular.extend limit: 10, searchParams query = angular.extend limit: 10, searchParams
store.SearchResource.get query, ({rows}) -> store.SearchResource.get query, ({rows}) ->
annotator.loadAnnotations(rows) annotationMapper.loadAnnotations(rows)
$scope.isEmbedded = false $scope.isEmbedded = false
$scope.isStream = true $scope.isStream = true
......
class Annotator.Plugin.Threading extends Annotator.Plugin class ThreadingService
# Mix in message thread properties into the prototype. The body of the # Mix in message thread properties into the prototype. The body of the
# class will overwrite any methods applied here. If you need inheritance # class will overwrite any methods applied here. If you need inheritance
# assign the message thread to a local varible. # assign the message thread to a local varible.
# The mail object is exported by the jwz.js library.
$.extend(this.prototype, mail.messageThread()) $.extend(this.prototype, mail.messageThread())
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
'annotationCreated': 'annotationCreated'
'annotationDeleted': 'annotationDeleted'
'annotationsLoaded': 'annotationsLoaded'
root: null root: null
pluginInit: -> this.$inject = ['$rootScope']
constructor: ($rootScope) ->
# Create a root container. # Create a root container.
@root = mail.messageContainer() @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. # TODO: Refactor the jwz API for progressive updates.
# Right now the idTable is wiped when `messageThread.thread()` is called and # Right now the idTable is wiped when `messageThread.thread()` is called and
...@@ -60,12 +60,11 @@ class Annotator.Plugin.Threading extends Annotator.Plugin ...@@ -60,12 +60,11 @@ class Annotator.Plugin.Threading extends Annotator.Plugin
if !container.message && container.children.length == 0 if !container.message && container.children.length == 0
parent.removeChild(container) parent.removeChild(container)
delete this.idTable[container.message?.id]
beforeAnnotationCreated: (annotation) => beforeAnnotationCreated: (event, annotation) =>
this.thread([annotation]) this.thread([annotation])
annotationCreated: (annotation) => annotationCreated: (event, annotation) =>
references = annotation.references or [] references = annotation.references or []
if typeof(annotation.references) == 'string' then references = [] if typeof(annotation.references) == 'string' then references = []
ref = references[references.length-1] ref = references[references.length-1]
...@@ -74,7 +73,7 @@ class Annotator.Plugin.Threading extends Annotator.Plugin ...@@ -74,7 +73,7 @@ class Annotator.Plugin.Threading extends Annotator.Plugin
@idTable[annotation.id] = child @idTable[annotation.id] = child
break break
annotationDeleted: (annotation) => annotationDeleted: (event, annotation) =>
if this.idTable[annotation.id] if this.idTable[annotation.id]
container = this.idTable[annotation.id] container = this.idTable[annotation.id]
container.message = null container.message = null
...@@ -93,6 +92,8 @@ class Annotator.Plugin.Threading extends Annotator.Plugin ...@@ -93,6 +92,8 @@ class Annotator.Plugin.Threading extends Annotator.Plugin
this.pruneEmpties(@root) this.pruneEmpties(@root)
break break
annotationsLoaded: (annotations) => annotationsLoaded: (event, annotations) =>
messages = (@root.flattenChildren() or []).concat(annotations) messages = (@root.flattenChildren() or []).concat(annotations)
this.thread(messages) this.thread(messages)
angular.module('h').service('threading', ThreadingService)
This diff is collapsed.
This diff is collapsed.
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
<li ng-show="!threadFilter.active() && selectedAnnotations" <li ng-show="!threadFilter.active() && selectedAnnotations"
><span ng-pluralize ><span ng-pluralize
count="selectedAnnotationsCount" count="selectedAnnotationsCount"
when="{'0': 'No annotations selected', when="{'0': 'No annotations selected.',
'one': 'Showing 1 selected annotation.', 'one': 'Showing 1 selected annotation.',
'other': 'Showing {} selected annotations.'}"></span> 'other': 'Showing {} selected annotations.'}"></span>
<a href="" ng-click="clearSelection()">Clear selection</a>.</li> <a href="" ng-click="clearSelection()">Clear selection</a>.</li>
......
...@@ -15,15 +15,10 @@ module.exports = function(config) { ...@@ -15,15 +15,10 @@ module.exports = function(config) {
// list of files / patterns to load in the browser // list of files / patterns to load in the browser
files: [ 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/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/jschannel.js',
'h/static/scripts/vendor/jwz.js', 'h/static/scripts/vendor/jwz.js',
'h/static/scripts/vendor/moment-with-langs.js', 'h/static/scripts/vendor/moment-with-langs.js',
...@@ -36,12 +31,20 @@ module.exports = function(config) { ...@@ -36,12 +31,20 @@ module.exports = function(config) {
'h/static/scripts/vendor/annotator.js', 'h/static/scripts/vendor/annotator.js',
'h/static/scripts/annotator/monkey.js', 'h/static/scripts/annotator/monkey.js',
'h/static/scripts/vendor/annotator.auth.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/bridge.js',
'h/static/scripts/annotator/plugin/bucket-bar.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/vendor/dom_text_mapper.js',
'h/static/scripts/annotator/annotator.anchoring.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/app.js',
'h/static/scripts/account.js', 'h/static/scripts/account.js',
'h/static/scripts/helpers.js', 'h/static/scripts/helpers.js',
...@@ -49,8 +52,8 @@ module.exports = function(config) { ...@@ -49,8 +52,8 @@ module.exports = function(config) {
'h/static/scripts/hypothesis.js', 'h/static/scripts/hypothesis.js',
'h/static/scripts/vendor/sinon.js', 'h/static/scripts/vendor/sinon.js',
'h/static/scripts/vendor/chai.js', 'h/static/scripts/vendor/chai.js',
'h/static/scripts/hypothesis.js',
'h/templates/client/*.html', 'h/templates/client/*.html',
'tests/js/bootstrap.coffee',
'tests/js/**/*-test.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', -> ...@@ -48,7 +48,6 @@ describe 'h', ->
send: sandbox.spy() send: sandbox.spy()
} }
$provide.value 'annotator', fakeAnnotator
$provide.value 'identity', fakeIdentity $provide.value 'identity', fakeIdentity
$provide.value 'streamer', fakeStreamer $provide.value 'streamer', fakeStreamer
$provide.value '$location', fakeLocation $provide.value '$location', fakeLocation
...@@ -69,7 +68,6 @@ describe 'h', -> ...@@ -69,7 +68,6 @@ describe 'h', ->
it 'does not show login form for logged in users', -> it 'does not show login form for logged in users', ->
createController() createController()
$scope.$digest()
assert.isFalse($scope.dialog.visible) assert.isFalse($scope.dialog.visible)
describe 'AnnotationViewerController', -> describe 'AnnotationViewerController', ->
...@@ -84,3 +82,47 @@ describe 'h', -> ...@@ -84,3 +82,47 @@ describe 'h', ->
it 'sets the isEmbedded property to false', -> it 'sets the isEmbedded property to false', ->
assert.isFalse($scope.isEmbedded) 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', -> ...@@ -6,13 +6,14 @@ describe 'h.directives.annotation', ->
$document = null $document = null
$scope = null $scope = null
$timeout = null $timeout = null
annotator = null
annotation = null annotation = null
createController = null createController = null
flash = null flash = null
fakeAuth = null fakeAuth = null
fakeStore = null fakeStore = null
fakeUser = null fakeUser = null
fakeAnnotationMapper = null
fakeAnnotationUI = null
beforeEach module('h') beforeEach module('h')
beforeEach module('h.templates') beforeEach module('h.templates')
...@@ -20,9 +21,20 @@ describe 'h.directives.annotation', -> ...@@ -20,9 +21,20 @@ describe 'h.directives.annotation', ->
beforeEach module ($provide) -> beforeEach module ($provide) ->
fakeAuth = fakeAuth =
user: 'acct:bill@localhost' 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 'auth', fakeAuth
$provide.value 'store', fakeStore $provide.value 'store', fakeStore
$provide.value 'annotationMapper', fakeAnnotationMapper
$provide.value 'annotationUI', fakeAnnotationUI
return return
beforeEach inject (_$compile_, $controller, _$document_, $rootScope, _$timeout_) -> beforeEach inject (_$compile_, $controller, _$document_, $rootScope, _$timeout_) ->
...@@ -31,11 +43,6 @@ describe 'h.directives.annotation', -> ...@@ -31,11 +43,6 @@ describe 'h.directives.annotation', ->
$timeout = _$timeout_ $timeout = _$timeout_
$scope = $rootScope.$new() $scope = $rootScope.$new()
$scope.annotationGet = (locals) -> annotation $scope.annotationGet = (locals) -> annotation
annotator = {
createAnnotation: sandbox.spy (data) -> data
plugins: {},
publish: sandbox.spy()
}
annotation = annotation =
id: 'deadbeef' id: 'deadbeef'
document: document:
...@@ -48,7 +55,6 @@ describe 'h.directives.annotation', -> ...@@ -48,7 +55,6 @@ describe 'h.directives.annotation', ->
createController = -> createController = ->
$controller 'AnnotationController', $controller 'AnnotationController',
$scope: $scope $scope: $scope
annotator: annotator
flash: flash flash: flash
afterEach -> afterEach ->
...@@ -56,7 +62,7 @@ describe 'h.directives.annotation', -> ...@@ -56,7 +62,7 @@ describe 'h.directives.annotation', ->
describe 'when the annotation is a highlight', -> describe 'when the annotation is a highlight', ->
beforeEach -> beforeEach ->
annotator.tool = 'highlight' fakeAnnotationUI.tool = 'highlight'
annotation.$create = sinon.stub().returns annotation.$create = sinon.stub().returns
then: angular.noop then: angular.noop
catch: angular.noop catch: angular.noop
...@@ -91,36 +97,31 @@ describe 'h.directives.annotation', -> ...@@ -91,36 +97,31 @@ describe 'h.directives.annotation', ->
destroy: ['acct:joe@localhost'] destroy: ['acct:joe@localhost']
admin: ['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', -> it 'creates a new reply with the proper uri and references', ->
controller.reply() controller.reply()
match = sinon.match {references: [annotation.id], uri: annotation.uri} 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', -> it 'adds the world readable principal if the parent is public', ->
reply = {}
fakeAnnotationMapper.createAnnotation.returns(reply)
annotation.permissions.read.push('group:__world__') annotation.permissions.read.push('group:__world__')
controller.reply() controller.reply()
newAnnotation = annotator.createAnnotation.lastCall.args[0] assert.include(reply.permissions.read, 'group:__world__')
assert.include(newAnnotation.permissions.read, 'group:__world__')
it 'does not add the world readable principal if the parent is private', -> it 'does not add the world readable principal if the parent is private', ->
reply = {}
fakeAnnotationMapper.createAnnotation.returns(reply)
controller.reply() controller.reply()
newAnnotation = annotator.createAnnotation.lastCall.args[0] assert.notInclude(reply.permissions.read, 'group:__world__')
assert.notInclude(newAnnotation.permissions.read, 'group:__world__')
it 'fills the other permissions too', -> it 'fills the other permissions too', ->
reply = {}
fakeAnnotationMapper.createAnnotation.returns(reply)
controller.reply() controller.reply()
newAnnotation = annotator.createAnnotation.lastCall.args[0] assert.equal(reply.permissions.update[0], 'acct:bill@localhost')
assert.equal(newAnnotation.permissions.update[0], 'acct:bill@localhost') assert.equal(reply.permissions.delete[0], 'acct:bill@localhost')
assert.equal(newAnnotation.permissions.delete[0], 'acct:bill@localhost') assert.equal(reply.permissions.admin[0], 'acct:bill@localhost')
assert.equal(newAnnotation.permissions.admin[0], 'acct:bill@localhost')
describe '#render', -> describe '#render', ->
controller = null 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.
This diff is collapsed.
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