Commit a1f2b97e authored by Aron Carroll's avatar Aron Carroll

Merge pull request #1436 from hypothesis/view-sort-overhaul

Overhaul view and sort behaviour.
parents d97a4389 e199ff71
...@@ -5,7 +5,6 @@ imports = [ ...@@ -5,7 +5,6 @@ imports = [
'h.controllers' 'h.controllers'
'h.controllers.AccountManagement' 'h.controllers.AccountManagement'
'h.directives' 'h.directives'
'h.directives.annotation'
'h.filters' 'h.filters'
'h.identity' 'h.identity'
'h.streamsearch' 'h.streamsearch'
...@@ -14,39 +13,17 @@ imports = [ ...@@ -14,39 +13,17 @@ imports = [
configure = [ configure = [
'$locationProvider', '$provide', '$routeProvider', '$sceDelegateProvider', '$locationProvider', '$routeProvider', '$sceDelegateProvider',
($locationProvider, $provide, $routeProvider, $sceDelegateProvider) -> ($locationProvider, $routeProvider, $sceDelegateProvider) ->
$locationProvider.html5Mode(true) $locationProvider.html5Mode(true)
# Disable annotating while drafting
$provide.decorator 'drafts', [
'annotator', '$delegate',
(annotator, $delegate) ->
{add, remove} = $delegate
$delegate.add = (draft) ->
add.call $delegate, draft
annotator.disableAnnotating $delegate.isEmpty()
$delegate.remove = (draft) ->
remove.call $delegate, draft
annotator.enableAnnotating $delegate.isEmpty()
$delegate
]
$routeProvider.when '/a/:id', $routeProvider.when '/a/:id',
controller: 'AnnotationViewerController' controller: 'AnnotationViewerController'
templateUrl: 'viewer.html' templateUrl: 'viewer.html'
$routeProvider.when '/editor',
controller: 'EditorController'
templateUrl: 'editor.html'
$routeProvider.when '/viewer', $routeProvider.when '/viewer',
controller: 'ViewerController' controller: 'ViewerController'
templateUrl: 'viewer.html' templateUrl: 'viewer.html'
$routeProvider.when '/page_search', reloadOnSearch: false
controller: 'SearchController'
templateUrl: 'page_search.html'
$routeProvider.when '/stream', $routeProvider.when '/stream',
controller: 'StreamSearchController' controller: 'StreamSearchController'
templateUrl: 'viewer.html' templateUrl: 'viewer.html'
......
...@@ -9,16 +9,42 @@ imports = [ ...@@ -9,16 +9,42 @@ imports = [
] ]
# User authorization function for the Permissions plugin.
authorizeAction = (action, annotation, user) ->
if annotation.permissions
tokens = annotation.permissions[action] || []
if tokens.length == 0
# Empty or missing tokens array: only admin can perform action.
return false
for token in tokens
if user == token
return true
if token == 'group:__world__'
return true
# No tokens matched: action should not be performed.
return false
# Coarse-grained authorization
else if annotation.user
return user and user == annotation.user
# No authorization info on annotation: free-for-all!
true
class App class App
this.$inject = [ this.$inject = [
'$element', '$location', '$q', '$rootScope', '$route', '$scope', '$timeout', '$location', '$q', '$route', '$scope', '$timeout',
'annotator', 'flash', 'identity', 'queryparser', 'socket', 'annotator', 'flash', 'identity', 'socket', 'streamfilter',
'streamfilter', 'viewFilter', 'documentHelpers' 'documentHelpers', 'drafts'
] ]
constructor: ( constructor: (
$element, $location, $q, $rootScope, $route, $scope, $timeout $location, $q, $route, $scope, $timeout
annotator, flash, identity, queryparser, socket, annotator, flash, identity, socket, streamfilter,
streamfilter, viewFilter, documentHelpers documentHelpers, drafts
) -> ) ->
{plugins, host, providers} = annotator {plugins, host, providers} = annotator
...@@ -37,71 +63,15 @@ class App ...@@ -37,71 +63,15 @@ class App
if action == 'past' if action == 'past'
action = 'create' action = 'create'
inRootScope = (annotation, recursive = false) ->
if recursive and annotation.references?
return inRootScope({id: annotation.references[0]})
for ann in $rootScope.annotations
return true if ann.id is annotation.id
false
switch action switch action
when 'create' when 'create', 'update'
# Sorting the data for updates. plugins.Store?._onLoadAnnotations data
# Because sometimes a reply can arrive in the same package as the
# Root annotation, we have to make a len(references, updates sort
data.sort (a,b) ->
ref_a = a.references?.length or 0
ref_b = b.references?.length or 0
return ref_a - ref_b if ref_a != ref_b
a_upd = if a.updated? then new Date(a.updated) else new Date()
b_upd = if b.updated? then new Date(b.updated) else new Date()
a_upd.getTime() - b_upd.getTime()
# XXX: Temporary workaround until solving the race condition for annotationsLoaded event
# Between threading and bridge plugins
for annotation in data
plugins.Threading.thread annotation
if plugins.Store?
plugins.Store._onLoadAnnotations data
# XXX: Ugly workaround to update the scope content
for annotation in data
switch $rootScope.viewState.view
when 'Document'
unless annotator.isComment(annotation)
$rootScope.annotations.push annotation if not inRootScope(annotation, true)
when 'Comments'
if annotator.isComment(annotation)
$rootScope.annotations.push annotation if not inRootScope(annotation)
else
$rootScope.annotations.push annotation if not inRootScope(annotation)
$scope.$digest()
when 'update'
plugins.Store._onLoadAnnotations data
if $location.path() is '/stream'
for annotation in data
$rootScope.annotations.push annotation if not inRootScope(annotation)
when 'delete' when 'delete'
for annotation in data for annotation in data
# Remove it from the rootScope too annotation = plugins.Threading.idTable[annotation.id]?.message
for ann, index in $rootScope.annotations continue unless annotation?
if ann.id is annotation.id plugins.Store?.unregisterAnnotation(annotation)
$rootScope.annotations.splice(index, 1) annotator.deleteAnnotation(annotation)
break
container = annotator.threading.getContainer annotation.id
if container.message
# XXX: This is a temporary workaround until real client-side only
# XXX: delete will be introduced
index = plugins.Store.annotations.indexOf container.message
plugins.Store.annotations[index..index] = [] if index > -1
annotator.deleteAnnotation container.message
# Refresh page search
$route.reload() if $location.path() is '/page_search' and data.length
initIdentity = (persona) -> initIdentity = (persona) ->
"""Initialize identity callbacks.""" """Initialize identity callbacks."""
...@@ -121,7 +91,7 @@ class App ...@@ -121,7 +91,7 @@ class App
onlogin(assertion) onlogin(assertion)
onlogout: -> onlogout: ->
onlogout() onlogout()
else if annotator.discardDrafts() else if drafts.discard()
if claimedUser if claimedUser
identity.request() identity.request()
else else
...@@ -130,30 +100,22 @@ class App ...@@ -130,30 +100,22 @@ class App
initStore = -> initStore = ->
"""Initialize the storage component.""" """Initialize the storage component."""
Store = plugins.Store Store = plugins.Store
delete plugins.Store delete plugins.Store
annotator.addPlugin 'Store', annotator.options.Store
annotator.threading.thread [] if $scope.persona or annotator.socialView.name is 'none'
annotator.threading.idTable = {} annotator.addPlugin 'Store', annotator.options.Store
$scope.$root.annotations = [] $scope.store = plugins.Store
$scope.store = plugins.Store
_id = $route.current.params.id _id = $route.current.params.id
_promise = null _promise = null
# Load any initial annotations that should be displayed # Load any initial annotations that should be displayed
if _id if _id
# XXX: Two requests here is less than ideal # XXX: Two requests here is less than ideal
_promise = plugins.Store.loadAnnotationsFromSearch({_id}) plugins.Store.loadAnnotationsFromSearch({_id}).then ->
.then ->
plugins.Store.loadAnnotationsFromSearch({references: _id}) plugins.Store.loadAnnotationsFromSearch({references: _id})
$q.when _promise, ->
thread = annotator.threading.getContainer _id
$scope.$root.annotations = [thread.message]
return unless Store return unless Store
Store.destroy() Store.destroy()
...@@ -172,36 +134,33 @@ class App ...@@ -172,36 +134,33 @@ class App
Store._onLoadAnnotations = angular.noop Store._onLoadAnnotations = angular.noop
# * Make the update function into a noop. # * Make the update function into a noop.
Store.updateAnnotation = angular.noop Store.updateAnnotation = angular.noop
# * Remove the plugin and re-add it to the annotator.
# Sort out which annotations should remain in place.
# Even though most operations on the old Store are now noops the Annotator user = $scope.persona
# itself may still be setting up previously fetched annotatiosn. We may view = annotator.socialView.name
# delete annotations which are later set up in the DOM again, causing cull = (acc, annotation) ->
# issues with the viewer and heatmap. As these are loaded, we can delete if view is 'single-player' and annotation.user != user
# them, but the threading plugin will get confused and break threading. acc.drop.push annotation
# Here, we cleanup these annotations as they are set up by the Annotator, else if authorizeAction 'read', annotation, user
# preserving the existing threading. This is all a bit paranoid, but acc.keep.push annotation
# important when many annotations are loading as authentication is else
# changing. It's all so ugly it makes me cry, though. Someone help acc.drop.push annotation
# restore sanity? acc
annotations = Store.annotations.slice()
cleanup = (loaded) -> {keep, drop} = Store.annotations.reduce cull, {keep: [], drop: []}
$rootScope.$evalAsync -> # Let plugins react Store.annotations = []
deleted = []
for l in loaded if plugins.Store?
if l in annotations plugins.Store.annotations = keep
# If this annotation still exists, we'll need to thread it again else
# since the delete will mangle the threading data structures. drop = drop.concat keep
existing = annotator.threading.idTable[l.id]?.message
annotator.deleteAnnotation(l) # Clean up the ones that should be removed.
deleted.push l do cleanup = (drop) ->
if existing return if drop.length == 0
plugins.Threading.thread existing [first, rest...] = drop
annotations = (a for a in annotations when a not in deleted) annotator.deleteAnnotation first
if annotations.length is 0 $timeout -> cleanup rest
annotator.unsubscribe 'annotationsLoaded', cleanup
cleanup (a for a in annotations when a.thread)
annotator.subscribe 'annotationsLoaded', cleanup
initUpdater = (failureCount=0) -> initUpdater = (failureCount=0) ->
"""Initialize the websocket used for realtime updates.""" """Initialize the websocket used for realtime updates."""
...@@ -246,13 +205,11 @@ class App ...@@ -246,13 +205,11 @@ class App
else else
applyUpdates action, data applyUpdates action, data
$scope.$digest()
_dfdSock.promise _dfdSock.promise
onlogin = (assertion) -> onlogin = (assertion) ->
# Delete any old Auth plugin.
plugins.Auth?.destroy()
delete plugins.Auth
# Configure the Auth plugin with the issued assertion as refresh token. # Configure the Auth plugin with the issued assertion as refresh token.
annotator.addPlugin 'Auth', annotator.addPlugin 'Auth',
tokenUrl: documentHelpers.absoluteURI( tokenUrl: documentHelpers.absoluteURI(
...@@ -260,22 +217,27 @@ class App ...@@ -260,22 +217,27 @@ class App
# Set the user from the token. # Set the user from the token.
plugins.Auth.withToken (token) -> plugins.Auth.withToken (token) ->
plugins.Permissions._setAuthFromToken(token) annotator.addPlugin 'Permissions',
loggedInUser = plugins.Permissions.user.replace /^acct:/, '' user: token.userId
userAuthorize: authorizeAction
permissions:
read: [token.userId]
update: [token.userId]
delete: [token.userId]
admin: [token.userId]
loggedInUser = token.userId.replace /^acct:/, ''
reset() reset()
onlogout = -> onlogout = ->
return unless drafts.discard()
plugins.Auth?.element.removeData('annotator:headers') plugins.Auth?.element.removeData('annotator:headers')
plugins.Auth?.destroy()
delete plugins.Auth delete plugins.Auth
plugins.Permissions.setUser(null) plugins.Permissions?.setUser(null)
plugins.Permissions?.destroy()
# XXX: Temporary workaround until Annotator v2.0 or v1.2.10 delete plugins.Permissions
plugins.Permissions.options.permissions =
read: []
update: []
delete: []
admin: []
loggedInUser = null loggedInUser = null
reset() reset()
...@@ -284,14 +246,9 @@ class App ...@@ -284,14 +246,9 @@ class App
# Do not rely on the identity service to invoke callbacks within an # Do not rely on the identity service to invoke callbacks within an
# angular digest cycle. # angular digest cycle.
$scope.$evalAsync -> $scope.$evalAsync ->
if annotator.ongoing_edit # Update any edits in progress.
annotator.clickAdder() for draft in drafts.all()
annotator.publish 'beforeAnnotationCreated', draft
if $scope.ongoingHighlightSwitch
$scope.ongoingHighlightSwitch = false
annotator.setTool 'highlight'
else
annotator.setTool 'comment'
# Convert the verified user id to the format used by the API. # Convert the verified user id to the format used by the API.
persona = loggedInUser persona = loggedInUser
...@@ -315,22 +272,19 @@ class App ...@@ -315,22 +272,19 @@ class App
$scope.$watch 'socialView.name', (newValue, oldValue) -> $scope.$watch 'socialView.name', (newValue, oldValue) ->
return if newValue is oldValue return if newValue is oldValue
initStore()
if $scope.persona if newValue is 'single-player' and not $scope.persona
initStore()
else if newValue is 'single-player'
identity.request()
$scope.$watch 'frame.visible', (newValue, oldValue) ->
routeName = $location.path().replace /^\//, ''
if newValue
annotator.show() annotator.show()
annotator.host.notify method: 'showFrame', params: routeName flash 'info',
else if oldValue 'You will need to sign in for your highlights to be saved.'
annotator.hide()
annotator.host.notify method: 'hideFrame', params: routeName $scope.$watch 'sort.name', (name) ->
for p in annotator.providers return unless name
p.channel.notify method: 'setActiveHighlights' [predicate, reverse] = switch name
when 'Newest' then ['message.updated', true]
when 'Oldest' then ['message.updated', false]
when 'Location' then ['message.target[0].pos.top', false]
$scope.sort = {name, predicate, reverse}
$scope.$watch 'store.entities', (entities, oldEntities) -> $scope.$watch 'store.entities', (entities, oldEntities) ->
return if entities is oldEntities return if entities is oldEntities
...@@ -340,85 +294,9 @@ class App ...@@ -340,85 +294,9 @@ class App
.resetFilter() .resetFilter()
.addClause('/uri', 'one_of', entities) .addClause('/uri', 'one_of', entities)
$scope.updater.then (sock) -> $scope.updater.then (sock) ->
filter = streamfilter.getFilter() filter = streamfilter.getFilter()
sock.send(JSON.stringify({filter})) sock.send(JSON.stringify({filter}))
$rootScope.viewState =
sort: ''
view: 'Screen'
# Show the sort/view control for a while.
#
# hide: should we hide it after a second?
_vstp = null
$rootScope.showViewSort = (show = true, hide = false) ->
if _vstp then $timeout.cancel _vstp
$rootScope.viewState.showControls = show
if $rootScope.viewState.showControls and hide
_vstp = $timeout (-> $rootScope.viewState.showControls = false), 1000
# "View" -- which annotations are shown
$rootScope.applyView = (view) ->
return if $rootScope.viewState.view is view
$rootScope.viewState.view = view
$rootScope.showViewSort true, true
switch view
when 'Screen'
# Go over all providers, and switch them to dynamic mode
# They will, in turn, call back updateView
# with the right set of annotations
for p in providers
p.channel.notify method: 'setDynamicBucketMode', params: true
when 'Document'
for p in providers
p.channel.notify method: 'showAll'
when 'Comments'
for p in providers
p.channel.notify method: 'setDynamicBucketMode', params: false
annotations = plugins.Store?.annotations
comments = annotations.filter (a) -> annotator.isComment(a)
$rootScope.annotations = comments
when 'Selection'
for p in providers
p.channel.notify method: 'setDynamicBucketMode', params: false
else
throw new Error "Unknown view requested: " + view
# "Sort" -- order annotations are shown
$rootScope.applySort = (sort) ->
return if $rootScope.viewState.sort is sort
$rootScope.viewState.sort = sort
$rootScope.showViewSort true, true
switch sort
when 'Newest'
$rootScope.predicate = 'updated'
$rootScope.searchPredicate = 'message.updated'
$rootScope.reverse = true
when 'Oldest'
$rootScope.predicate = 'updated'
$rootScope.searchPredicate = 'message.updated'
$rootScope.reverse = false
when 'Location'
$rootScope.predicate = 'target[0].pos.top'
$rootScope.searchPredicate = 'message.target[0].pos.top'
$rootScope.reverse = false
$rootScope.applySort "Location"
$rootScope.$on '$routeChangeSuccess', (event, next, current) ->
unless next.$$route? then return
$scope.search.query = $location.search()['q']
$rootScope.viewState.show = next.$$route.originalPath is '/viewer'
unless next.$$route.originalPath is '/stream'
if current and next.$$route.originalPath is '/a/:id'
$scope.reloadAnnotations()
$scope.loadMore = (number) -> $scope.loadMore = (number) ->
unless streamfilter.getPastData().hits then return unless streamfilter.getPastData().hits then return
...@@ -431,78 +309,33 @@ class App ...@@ -431,78 +309,33 @@ class App
sock.send(JSON.stringify(sockmsg)) sock.send(JSON.stringify(sockmsg))
$scope.authTimeout = -> $scope.authTimeout = ->
delete annotator.ongoing_edit
$scope.ongoingHighlightSwitch = false
flash 'info', flash 'info',
'For your security, the forms have been reset due to inactivity.' 'For your security, the forms have been reset due to inactivity.'
$scope.frame = visible: false $scope.clearSelection = ->
$scope.search.query = ''
$scope.selectedAnnotations = null
$scope.selectedAnnotationsCount = 0
$scope.id = identity $scope.id = identity
$scope.model = persona: undefined $scope.model = persona: undefined
$scope.threading = plugins.Threading
$scope.search = $scope.search =
query: $location.search()['q']
clear: -> clear: ->
$location.search('q', null) $location.search('q', null)
update: (query) -> update: (query) ->
unless angular.equals $location.search()['q'], query unless angular.equals $location.search()['q'], query
if annotator.discardDrafts() $location.search('q', query or null)
$location.search('q', query or null) delete $scope.selectedAnnotations
delete $scope.selectedAnnotationsCount
$scope.socialView = annotator.socialView $scope.socialView = annotator.socialView
$scope.sort = name: 'Location'
class Editor
this.$inject = [
'$location', '$routeParams', '$sce', '$scope',
'annotator'
]
constructor: (
$location, $routeParams, $sce, $scope,
annotator
) ->
{providers} = annotator
save = ->
$location.path('/viewer').search('id', $scope.annotation.id).replace()
for p in providers
p.channel.notify method: 'onEditorSubmit'
p.channel.notify method: 'onEditorHide'
cancel = ->
$location.path('/viewer').search('id', null).replace()
for p in providers
p.channel.notify method: 'onEditorHide'
$scope.action = if $routeParams.action? then $routeParams.action else 'create'
if $scope.action is 'create'
annotator.subscribe 'annotationCreated', save
annotator.subscribe 'annotationDeleted', cancel
else
if $scope.action is 'edit' or $scope.action is 'redact'
annotator.subscribe 'annotationUpdated', save
$scope.$on '$destroy', ->
if $scope.action is 'edit' or $scope.action is 'redact'
annotator.unsubscribe 'annotationUpdated', save
else
if $scope.action is 'create'
annotator.unsubscribe 'annotationCreated', save
annotator.unsubscribe 'annotationDeleted', cancel
$scope.annotation = annotator.ongoing_edit
delete annotator.ongoing_edit
$scope.$watch 'annotation.target', (targets) ->
return unless targets
for target in targets
if target.diffHTML?
target.trustedDiffHTML = $sce.trustAsHtml target.diffHTML
target.showDiff = not target.diffCaseOnly
else
delete target.trustedDiffHTML
target.showDiff = false
class AnnotationViewer class AnnotationViewer
...@@ -510,11 +343,13 @@ class AnnotationViewer ...@@ -510,11 +343,13 @@ class AnnotationViewer
constructor: ($routeParams, $scope, streamfilter) -> constructor: ($routeParams, $scope, streamfilter) ->
# Tells the view that these annotations are standalone # Tells the view that these annotations are standalone
$scope.isEmbedded = false $scope.isEmbedded = false
$scope.isStream = false
# Provide no-ops until these methods are moved elsewere. They only apply # Provide no-ops until these methods are moved elsewere. They only apply
# to annotations loaded into the stream. # to annotations loaded into the stream.
$scope.activate = angular.noop $scope.activate = angular.noop
$scope.openDetails = angular.noop
$scope.shouldShowThread = -> true
$scope.$watch 'updater', (updater) -> $scope.$watch 'updater', (updater) ->
if updater? if updater?
...@@ -530,298 +365,35 @@ class AnnotationViewer ...@@ -530,298 +365,35 @@ class AnnotationViewer
class Viewer class Viewer
this.$inject = [ this.$inject = [
'$location', '$rootScope', '$routeParams', '$scope', '$filter', '$routeParams', '$sce', '$scope',
'annotator' 'annotator', 'searchfilter', 'viewFilter'
] ]
constructor: ( constructor: (
$location, $rootScope, $routeParams, $scope, $filter, $routeParams, $sce, $scope,
annotator annotator, searchfilter, viewFilter
) -> ) ->
if $routeParams.q
return $location.path('/page_search').replace()
# 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
$scope.isStream = true
{providers, threading} = annotator
$scope.activate = (annotation) -> $scope.activate = (annotation) ->
if angular.isArray annotation if angular.isObject annotation
highlights = (a.$$tag for a in annotation when a?)
else if angular.isObject annotation
highlights = [annotation.$$tag] highlights = [annotation.$$tag]
else else
highlights = [] highlights = []
for p in providers for p in annotator.providers
p.channel.notify p.channel.notify
method: 'setActiveHighlights' method: 'setActiveHighlights'
params: highlights params: highlights
$scope.openDetails = (annotation) -> $scope.shouldShowThread = (container) ->
for p in providers if $scope.selectedAnnotations? and not container.parent.parent
p.channel.notify $scope.selectedAnnotations[container.message?.id]
method: 'scrollTo'
params: annotation.$$tag
class Search
this.$inject = ['$filter', '$location', '$rootScope', '$routeParams', '$sce', '$scope',
'annotator', 'viewFilter']
constructor: ($filter, $location, $rootScope, $routeParams, $sce, $scope,
annotator, viewFilter) ->
unless $routeParams.q
return $location.path('/viewer').replace()
{providers, threading} = annotator
$scope.highlighter = '<span class="search-hl-active">$&</span>'
$scope.filter_orderBy = $filter('orderBy')
$scope.matches = []
$scope.render_order = {}
$scope.render_pos = {}
$scope.ann_info =
shown : {}
show_quote: {}
more_top : {}
more_bottom : {}
more_top_num : {}
more_bottom_num: {}
buildRenderOrder = (threadid, threads) =>
unless threads?.length
return
sorted = $scope.filter_orderBy threads, $scope.sortThread, true
for thread in sorted
$scope.render_pos[thread.message.id] = $scope.render_order[threadid].length
$scope.render_order[threadid].push thread.message.id
buildRenderOrder(threadid, thread.children)
setMoreTop = (threadid, annotation) =>
unless annotation.id in $scope.matches
return false
result = false
pos = $scope.render_pos[annotation.id]
if pos > 0
prev = $scope.render_order[threadid][pos-1]
unless prev in $scope.matches
result = true
result
setMoreBottom = (threadid, annotation) =>
unless annotation.id in $scope.matches
return false
result = false
pos = $scope.render_pos[annotation.id]
if pos < $scope.render_order[threadid].length-1
next = $scope.render_order[threadid][pos+1]
unless next in $scope.matches
result = true
result
$scope.activate = (annotation) ->
if angular.isArray annotation
highlights = (a.$$tag for a in annotation when a?)
else if angular.isObject annotation
highlights = [annotation.$$tag]
else else
highlights = [] true
for p in providers
p.channel.notify
method: 'setActiveHighlights'
params: highlights
$scope.openDetails = (annotation) ->
# Temporary workaround, until search result annotation card
# scopes get their 'annotation' fields, too.
return unless annotation
for p in providers
p.channel.notify
method: 'scrollTo'
params: annotation.$$tag
$scope.$watchCollection 'annotations', (nVal, oVal) =>
refresh()
refresh = =>
query = $routeParams.q
[$scope.matches, $scope.filters] = viewFilter.filter $rootScope.annotations, query
# Create the regexps for highlighting the matches inside the annotations' bodies
$scope.text_tokens = $scope.filters.text.terms.slice()
$scope.text_regexp = []
$scope.quote_tokens = $scope.filters.quote.terms.slice()
$scope.quote_regexp = []
# Highligh any matches
for term in $scope.filters.any.terms
$scope.text_tokens.push term
$scope.quote_tokens.push term
# Saving the regexps and higlighter to the annotator for highlighttext regeneration
for token in $scope.text_tokens
regexp = new RegExp(token,"ig")
$scope.text_regexp.push regexp
for token in $scope.quote_tokens
regexp = new RegExp(token,"ig")
$scope.quote_regexp.push regexp
annotator.text_regexp = $scope.text_regexp
annotator.quote_regexp = $scope.quote_regexp
annotator.highlighter = $scope.highlighter
threads = []
roots = {}
$scope.render_order = {}
# Choose the root annotations to work with
for id, thread of annotator.threading.idTable when thread.message?
annotation = thread.message
annotation_root = if annotation.references? then annotation.references[0] else annotation.id
# Already handled thread
if roots[annotation_root]? then continue
root_annotation = (annotator.threading.getContainer annotation_root).message
unless root_annotation in $rootScope.annotations then continue
if annotation.id in $scope.matches
# We have a winner, let's put its root annotation into our list and build the rendering
root_thread = annotator.threading.getContainer annotation_root
threads.push root_thread
$scope.render_order[annotation_root] = []
buildRenderOrder(annotation_root, [root_thread])
roots[annotation_root] = true
# Re-construct exact order the annotation threads will be shown
# Fill search related data before display
# - add highlights
# - populate the top/bottom show more links
# - decide that by default the annotation is shown or hidden
# - Open detail mode for quote hits
for thread in threads
thread.message.highlightText = thread.message.text
if thread.message.id in $scope.matches
$scope.ann_info.shown[thread.message.id] = true
if thread.message.text?
for regexp in $scope.text_regexp
thread.message.highlightText = thread.message.highlightText.replace regexp, $scope.highlighter
else
$scope.ann_info.shown[thread.message.id] = false
$scope.ann_info.more_top[thread.message.id] = setMoreTop(thread.message.id, thread.message)
$scope.ann_info.more_bottom[thread.message.id] = setMoreBottom(thread.message.id, thread.message)
if $scope.quote_tokens?.length > 0
$scope.ann_info.show_quote[thread.message.id] = true
for target in thread.message.target
target.highlightQuote = target.quote
for regexp in $scope.quote_regexp
target.highlightQuote = target.highlightQuote.replace regexp, $scope.highlighter
target.highlightQuote = $sce.trustAsHtml target.highlightQuote
if target.diffHTML?
target.trustedDiffHTML = $sce.trustAsHtml target.diffHTML
target.showDiff = not target.diffCaseOnly
else
delete target.trustedDiffHTML
target.showDiff = false
else
for target in thread.message.target
target.highlightQuote = target.quote
$scope.ann_info.show_quote[thread.message.id] = false
children = thread.flattenChildren()
if children?
for child in children
child.highlightText = child.text
if child.id in $scope.matches
$scope.ann_info.shown[child.id] = true
for regexp in $scope.text_regexp
child.highlightText = child.highlightText.replace regexp, $scope.highlighter
else
$scope.ann_info.shown[child.id] = false
$scope.ann_info.more_top[child.id] = setMoreTop(thread.message.id, child)
$scope.ann_info.more_bottom[child.id] = setMoreBottom(thread.message.id, child)
$scope.ann_info.show_quote[child.id] = false
# Calculate the number of hidden annotations for <x> more labels
for threadid, order of $scope.render_order
hidden = 0
last_shown = null
for id in order
if id in $scope.matches
if last_shown? then $scope.ann_info.more_bottom_num[last_shown] = hidden
$scope.ann_info.more_top_num[id] = hidden
last_shown = id
hidden = 0
else
hidden += 1
if last_shown? then $scope.ann_info.more_bottom_num[last_shown] = hidden
$rootScope.search_annotations = threads
$scope.threads = threads
for thread in threads
$rootScope.focus thread.message, true
$scope.$on '$routeUpdate', refresh
$scope.getThreadId = (id) ->
thread = annotator.threading.getContainer id
threadid = id
if thread.message.references?
threadid = thread.message.references[0]
threadid
$scope.clickMoreTop = (id, $event) ->
$event?.stopPropagation()
threadid = $scope.getThreadId id
pos = $scope.render_pos[id]
rendered = $scope.render_order[threadid]
$scope.ann_info.more_top[id] = false
pos -= 1
while pos >= 0
prev_id = rendered[pos]
if $scope.ann_info.shown[prev_id]
$scope.ann_info.more_bottom[prev_id] = false
break
$scope.ann_info.more_bottom[prev_id] = false
$scope.ann_info.more_top[prev_id] = false
$scope.ann_info.shown[prev_id] = true
pos -= 1
$scope.clickMoreBottom = (id, $event) ->
$event?.stopPropagation()
threadid = $scope.getThreadId id
pos = $scope.render_pos[id]
rendered = $scope.render_order[threadid]
$scope.ann_info.more_bottom[id] = false
pos += 1
while pos < rendered.length
next_id = rendered[pos]
if $scope.ann_info.shown[next_id]
$scope.ann_info.more_top[next_id] = false
break
$scope.ann_info.more_bottom[next_id] = false
$scope.ann_info.more_top[next_id] = false
$scope.ann_info.shown[next_id] = true
pos += 1
refresh()
angular.module('h.controllers', imports) angular.module('h.controllers', imports)
.controller('AppController', App) .controller('AppController', App)
.controller('EditorController', Editor)
.controller('ViewerController', Viewer) .controller('ViewerController', Viewer)
.controller('AnnotationViewerController', AnnotationViewer) .controller('AnnotationViewerController', AnnotationViewer)
.controller('SearchController', Search)
imports = [
'ngSanitize'
'ngTagsInput'
'h.helpers.documentHelpers'
'h.services'
]
formInput = -> formInput = ->
link: (scope, elem, attr, [form, model, validator]) -> link: (scope, elem, attr, [form, model, validator]) ->
return unless form?.$name and model?.$name and validator return unless form?.$name and model?.$name and validator
...@@ -79,7 +87,6 @@ markdown = ['$filter', '$timeout', ($filter, $timeout) -> ...@@ -79,7 +87,6 @@ markdown = ['$filter', '$timeout', ($filter, $timeout) ->
unless readonly then $timeout -> input.focus() unless readonly then $timeout -> input.focus()
require: '?ngModel' require: '?ngModel'
restrict: 'E'
scope: scope:
readonly: '@' readonly: '@'
required: '@' required: '@'
...@@ -131,36 +138,6 @@ privacy = -> ...@@ -131,36 +138,6 @@ privacy = ->
templateUrl: 'privacy.html' templateUrl: 'privacy.html'
recursive = ['$compile', '$timeout', ($compile, $timeout) ->
compile: (tElement, tAttrs, transclude) ->
placeholder = angular.element '<!-- recursive -->'
attachQueue = []
tick = false
template = tElement.contents().clone()
tElement.html ''
transclude = $compile template, (scope, cloneAttachFn) ->
clone = placeholder.clone()
cloneAttachFn clone
$timeout ->
transclude scope, (el, scope) -> attachQueue.push [clone, el]
unless tick
tick = true
requestAnimationFrame ->
tick = false
for [clone, el] in attachQueue
clone.after el
clone.bind '$destroy', -> el.remove()
attachQueue = []
clone
post: (scope, iElement, iAttrs, controller) ->
transclude scope, (contents) -> iElement.append contents
restrict: 'A'
terminal: true
]
tabReveal = ['$parse', ($parse) -> tabReveal = ['$parse', ($parse) ->
compile: (tElement, tAttrs, transclude) -> compile: (tElement, tAttrs, transclude) ->
panes = [] panes = []
...@@ -205,57 +182,6 @@ tabReveal = ['$parse', ($parse) -> ...@@ -205,57 +182,6 @@ tabReveal = ['$parse', ($parse) ->
] ]
thread = ['$rootScope', '$window', ($rootScope, $window) ->
# Helper -- true if selection ends inside the target and is non-empty
ignoreClick = (event) ->
sel = $window.getSelection()
if sel.focusNode?.compareDocumentPosition(event.target) & 8
if sel.toString().length
return true
return false
link: (scope, elem, attr, ctrl) ->
childrenEditing = {}
# If this is supposed to be focused, then open it
if scope.annotation in ($rootScope.focused or [])
scope.collapsed = false
scope.$on "focusChange", ->
# XXX: This not needed to be done when the viewer and search will be unified
ann = scope.annotation ? scope.thread.message
if ann in $rootScope.focused
scope.collapsed = false
else
unless ann.references?.length
scope.collapsed = true
scope.toggleCollapsed = (event) ->
event.stopPropagation()
return if (ignoreClick event) or Object.keys(childrenEditing).length
scope.collapsed = !scope.collapsed
# XXX: This not needed to be done when the viewer and search will be unified
ann = scope.annotation ? scope.thread.message
if scope.collapsed
$rootScope.unFocus ann, true
else
scope.openDetails ann
$rootScope.focus ann, true
scope.$on 'toggleEditing', (event) ->
{$id, editing} = event.targetScope
if editing
scope.collapsed = false
unless childrenEditing[$id]
event.targetScope.$on '$destroy', ->
delete childrenEditing[$id]
childrenEditing[$id] = true
else
delete childrenEditing[$id]
restrict: 'C'
]
# TODO: Move this behaviour to a route. # TODO: Move this behaviour to a route.
showAccount = -> showAccount = ->
restrict: 'A' restrict: 'A'
...@@ -298,8 +224,9 @@ repeatAnim = -> ...@@ -298,8 +224,9 @@ repeatAnim = ->
username = ['$filter', '$window', ($filter, $window) -> username = ['$filter', '$window', ($filter, $window) ->
link: (scope, elem, attr) -> link: (scope, elem, attr) ->
scope.$watch 'user', -> scope.$watch 'user', (user) ->
scope.uname = $filter('persona')(scope.user, 'username') if user
scope.uname = $filter('persona')(scope.user, 'username')
scope.uclick = (event) -> scope.uclick = (event) ->
event.preventDefault() event.preventDefault()
...@@ -312,50 +239,6 @@ username = ['$filter', '$window', ($filter, $window) -> ...@@ -312,50 +239,6 @@ username = ['$filter', '$window', ($filter, $window) ->
template: '<span class="user" ng-click="uclick($event)">{{uname}}</span>' template: '<span class="user" ng-click="uclick($event)">{{uname}}</span>'
] ]
fuzzytime = ['$filter', '$window', ($filter, $window) ->
link: (scope, elem, attr, ctrl) ->
return unless ctrl?
elem
.find('a')
.bind 'click', (event) ->
event.stopPropagation()
ctrl.$render = ->
scope.ftime = ($filter 'fuzzyTime') ctrl.$viewValue
# Determining the timezone name
timezone = jstz.determine().name()
# The browser language
userLang = navigator.language || navigator.userLanguage
# Now to make a localized hint date, set the language
momentDate = moment ctrl.$viewValue
momentDate.lang(userLang)
# Try to localize to the browser's timezone
try
scope.hint = momentDate.tz(timezone).format('LLLL')
catch error
# For invalid timezone, use the default
scope.hint = momentDate.format('LLLL')
timefunct = ->
$window.setInterval =>
scope.ftime = ($filter 'fuzzyTime') ctrl.$viewValue
scope.$digest()
, 5000
scope.timer = timefunct()
scope.$on '$destroy', ->
$window.clearInterval scope.timer
require: '?ngModel'
restrict: 'E'
scope: true
template: '<a target="_blank" href="{{shared_link}}" title="{{hint}}">{{ftime | date:mediumDate}}</a>'
]
whenscrolled = -> whenscrolled = ->
link: (scope, elem, attr) -> link: (scope, elem, attr) ->
...@@ -378,15 +261,12 @@ match = -> ...@@ -378,15 +261,12 @@ match = ->
require: 'ngModel' require: 'ngModel'
angular.module('h.directives', ['ngSanitize', 'ngTagsInput']) angular.module('h.directives', imports)
.directive('formInput', formInput) .directive('formInput', formInput)
.directive('formValidate', formValidate) .directive('formValidate', formValidate)
.directive('fuzzytime', fuzzytime)
.directive('markdown', markdown) .directive('markdown', markdown)
.directive('privacy', privacy) .directive('privacy', privacy)
.directive('recursive', recursive)
.directive('tabReveal', tabReveal) .directive('tabReveal', tabReveal)
.directive('thread', thread)
.directive('username', username) .directive('username', username)
.directive('showAccount', showAccount) .directive('showAccount', showAccount)
.directive('repeatAnim', repeatAnim) .directive('repeatAnim', repeatAnim)
......
imports = [ ### global -extractURIComponent, -validate ###
'ngSanitize'
'h.helpers.documentHelpers'
'h.services'
]
# Use an anchor tag to extract specific components within a uri. # Use an anchor tag to extract specific components within a uri.
extractURIComponent = (uri, component) -> extractURIComponent = (uri, component) ->
...@@ -13,252 +8,303 @@ extractURIComponent = (uri, component) -> ...@@ -13,252 +8,303 @@ extractURIComponent = (uri, component) ->
extractURIComponent.a[component] extractURIComponent.a[component]
class Annotation # Validate an annotation.
this.$inject = [ # Annotations must be attributed to a user or marked as deleted.
'$element', '$location', '$rootScope', '$sce', '$scope', '$timeout', # A public annotation is valid only if they have a body.
'$window', # A non-public annotation requires only a target (e.g. a highlight).
'annotator', 'documentHelpers', 'drafts' validate = (value) ->
] return unless angular.isObject value
constructor: ( worldReadable = 'group:__world__' in (value.permissions?.read or [])
$element, $location, $rootScope, $sce, $scope, $timeout, (value.tags?.length or value.text?.length) or
$window, (value.target?.length and not worldReadable)
annotator, documentHelpers, drafts
) ->
model = $scope.model ###*
{plugins, threading} = annotator # @ngdoc type
# @name annotation.AnnotationController
$scope.action = 'create' #
$scope.editing = false # @property {Object} annotation The annotation view model.
$scope.preview = 'no' # @property {Object} document The document metadata view model.
# @property {string} action One of 'view', 'edit', 'create' or 'delete'.
if model.document and model.target.length # @property {string} preview If previewing an edit then 'yes', else 'no'.
domain = extractURIComponent(model.uri, 'hostname') # @property {boolean} editing True if editing components are shown.
# @property {boolean} embedded True if the annotation is an embedded widget.
title = model.document.title or domain # @property {boolean} shared True if the share link is visible.
if title.length > 30 #
title = title.slice(0, 30) + '…' # @description
#
$scope.document = # `AnnotationController` provides an API for the annotation directive. It
uri: model.uri # manages the interaction between the domain and view models and uses the
domain: domain # {@link annotator annotator service} for persistence.
title: title ###
AnnotationController = [
$scope.cancel = ($event) -> '$scope', 'annotator', 'drafts', 'flash'
$event?.stopPropagation() ($scope, annotator, drafts, flash) ->
$scope.editing = false @annotation = {}
drafts.remove $scope.model @action = 'view'
@document = null
switch $scope.action @preview = 'no'
when 'create' @editing = false
annotator.deleteAnnotation $scope.model @embedded = false
else @shared = false
$scope.model.text = $scope.origText
$scope.model.tags = $scope.origTags highlight = annotator.tool is 'highlight'
$scope.action = 'create' model = $scope.annotationGet()
original = null
$scope.save = ($event) ->
$event?.stopPropagation() ###*
annotation = $scope.model # @ngdoc method
# @name annotation.AnnotationController#isComment.
# Forbid saving comments without a body (text or tags) # @returns {boolean} True if the annotation is a comment.
if annotator.isComment(annotation) and not annotation.text and ###
not annotation.tags?.length this.isComment = ->
$window.alert "You can not add a comment without adding some text, or at least a tag." not (model.target?.length or model.references?.length)
return
###*
# Forbid the publishing of annotations # @ngdoc method
# without a body (text or tags) # @name annotation.AnnotationController#isHighlight.
if $scope.form.privacy.$viewValue is "Public" and # @returns {boolean} True if the annotation is a highlight.
$scope.action isnt "delete" and ###
not annotation.text and not annotation.tags?.length this.isHighlight = ->
$window.alert "You can not make this annotation public without adding some text, or at least a tag." model.target?.length and not model.references?.length and
return not (model.text or model.deleted or model.tags?.length)
$scope.rebuildHighlightText() ###*
# @ngdoc method
$scope.editing = false # @name annotation.AnnotationController#isPrivate
drafts.remove annotation # @returns {boolean} True if the annotation is private to the current user.
###
switch $scope.action this.isPrivate = ->
model.user and angular.equals(model.permissions?.read or [], [model.user])
###*
# @ngdoc method
# @name annotation.AnnotationController#authorize
# @param {string} action The action to authorize.
# @returns {boolean} True if the action is authorized for the current user.
# @description Checks whether the current user can perform an action on
# the annotation.
###
this.authorize = (action) ->
return false unless model?
annotator.plugins.Permissions?.authorize action, model
###*
# @ngdoc method
# @name annotation.AnnotationController#delete
# @description Deletes the annotation.
###
this.delete = ->
if confirm "Are you sure you want to delete this annotation?"
annotator.deleteAnnotation model
###*
# @ngdoc method
# @name annotation.AnnotationController#edit
# @description Switches the view to an editor.
###
this.edit = ->
drafts.add model, => this.revert()
@action = if model.id? then 'edit' else 'create'
@editing = true
@preview = 'no'
###*
# @ngdoc method
# @name annotation.AnnotationController#view
# @description Reverts an edit in progress and returns to the viewer.
###
this.revert = ->
drafts.remove model
if @action is 'create'
annotator.publish 'annotationDeleted', model
else
this.render()
@action = 'view'
@editing = false
###*
# @ngdoc method
# @name annotation.AnnotationController#save
# @description Saves any edits and returns to the viewer.
###
this.save = ->
unless model.user or model.deleted
return flash 'info', 'Please sign in to save your annotations.'
unless validate(@annotation)
return flash 'info', 'Please add text or a tag before publishing.'
angular.extend model, @annotation,
tags: (tag.text for tag in @annotation.tags)
switch @action
when 'create' when 'create'
# First, focus on the newly created annotation annotator.publish 'annotationCreated', model
unless annotator.isReply annotation when 'delete', 'edit'
$rootScope.focus annotation, true annotator.publish 'annotationUpdated', model
if annotator.isComment(annotation) and @editing = false
$rootScope.viewState.view isnt "Comments" @action = 'view'
$rootScope.applyView "Comments"
else if not annotator.isReply(annotation) and ###*
$rootScope.viewState.view not in ["Document", "Selection"] # @ngdoc method
$rootScope.applyView "Screen" # @name annotation.AnnotationController#reply
annotator.publish 'annotationCreated', annotation # @description
when 'delete' # Creates a new message in reply to this annotation.
root = $scope.$root.annotations ###
root = (a for a in root when a isnt root) this.reply = ->
annotation.deleted = true unless model.id?
unless annotation.text? then annotation.text = '' return flash 'error', 'Please save this annotation before replying.'
annotator.updateAnnotation annotation
else # Extract the references value from this container.
annotator.updateAnnotation annotation {id, references, uri} = model
references = references or []
if typeof(references) == 'string' then references = [references]
# Construct the reply.
references = [references..., id]
reply = {references, uri}
annotator.publish 'beforeAnnotationCreated', reply
###*
# @ngdoc method
# @name annotation.AnnotationController#toggleShared
# @description
# Toggle the shared property.
###
this.toggleShared = ->
unless model.id?
return flash 'error', 'Please save this annotation before sharing.'
@shared = not @shared
###*
# @ngdoc method
# @name annotation.AnnotationController#render
# @description Called to update the view when the model changes.
###
this.render = ->
# Extend the view model with a copy of the domain model.
# Note that copy is used so that deep properties aren't shared.
angular.extend @annotation, angular.copy model
# Extract the document metadata.
if model.document and model.target.length
domain = extractURIComponent(model.uri, 'hostname')
@document =
uri: model.uri
domain: domain
title: model.document.title or domain
if @document.title.length > 30
@document.title = @document.title[0..29] + '…'
else
@document = null
# Form the tags for ngTagsInput.
@annotation.tags = ({text} for text in (model.tags or []))
$scope.reply = ($event) -> # Discard the draft if the scope goes away.
$event?.stopPropagation() $scope.$on '$destroy', ->
unless plugins.Auth? and plugins.Auth.haveValidToken() drafts.remove model
$window.alert "In order to reply, you need to sign in."
return
references = # Render on updates.
if $scope.thread.message.references $scope.$watch (-> model.updated), (updated) =>
[$scope.thread.message.references..., $scope.thread.message.id] if updated then drafts.remove model
this.render() # XXX: TODO: don't clobber the view when collaborating
# Update once logged in.
$scope.$watch (-> model.user), (user) =>
if highlight and this.isHighlight()
if user
annotator.publish 'annotationCreated', model
else else
[$scope.thread.message.id] drafts.add model, => this.revert()
reply =
references: references
uri: $scope.thread.message.uri
annotator.publish 'beforeAnnotationCreated', [reply]
drafts.add reply
return
$scope.edit = ($event) ->
$event?.stopPropagation()
$scope.action = 'edit'
$scope.editing = true
$scope.origText = $scope.model.text
$scope.origTags = $scope.model.tags
drafts.add $scope.model, -> $scope.cancel()
return
$scope.delete = ($event) ->
$event?.stopPropagation()
replies = $scope.thread.children?.length or 0
# We can delete the annotation if it hasn't got any replies or it is
# private. Otherwise, we ask for a redaction message.
if replies == 0 or $scope.form.privacy.$viewValue is 'Private'
# If we're deleting it without asking for a message, confirm first.
if confirm "Are you sure you want to delete this annotation?"
if $scope.form.privacy.$viewValue is 'Private' and replies
#Cascade delete its children
for reply in $scope.thread.flattenChildren()
if plugins?.Permissions?.authorize 'delete', reply
annotator.deleteAnnotation reply
annotator.deleteAnnotation $scope.model
else else
$scope.action = 'delete' this.render()
$scope.editing = true
$scope.origText = $scope.model.text # Start editing brand new annotations immediately
$scope.origTags = $scope.model.tags unless model.id? or (highlight and this.isHighlight()) then this.edit()
$scope.model.text = ''
$scope.model.tags = '' this
]
$scope.$watch 'editing', -> $scope.$emit 'toggleEditing'
$scope.$watch 'model.id', (id) ->
if id?
$scope.thread = $scope.model.thread
# Check if this is a brand new annotation
if drafts.contains $scope.model
$scope.editing = true
link = documentHelpers.absoluteURI("/a/#{$scope.model.id}")
$scope.shared_link = link
$scope.$watch 'model.target', (targets) ->
return unless targets
for target in targets
if target.diffHTML?
target.trustedDiffHTML = $sce.trustAsHtml target.diffHTML
target.showDiff = not target.diffCaseOnly
else
delete target.trustedDiffHTML
target.showDiff = false
$scope.$watch 'shared', (newValue) ->
if newValue? is true
$timeout -> $element.find('input').focus()
$timeout -> $element.find('input').select()
$scope.shared = false
return
$scope.$watchCollection 'model.thread.children', (newValue=[]) ->
return unless $scope.model
replies = (r.message for r in newValue)
replies = replies.sort(annotator.sortAnnotations).reverse()
$scope.model.reply_list = replies
$scope.toggle = ->
$element.find('.share-dialog').slideToggle()
return
$scope.share = ($event) ->
$event.stopPropagation()
return if $element.find('.share-dialog').is ":visible"
$scope.shared = not $scope.shared
$scope.toggle()
return
$scope.rebuildHighlightText = ->
if annotator.text_regexp?
$scope.model.highlightText = $scope.model.text
for regexp in annotator.text_regexp
$scope.model.highlightText =
$scope.model.highlightText.replace regexp, annotator.highlighter
annotation = ['$filter', '$parse', 'annotator', ($filter, $parse, annotator) ->
link: (scope, elem, attrs, controller) ->
return unless controller?
scope.embedded = $parse(attrs.annotationEmbedded)() is true
# Bind shift+enter to save
elem.bind
keydown: (e) ->
if e.keyCode == 13 && e.shiftKey
scope.save(e)
scope.addTag = (tag) ->
scope.model.tags ?= []
scope.model.tags.push(tag.text)
scope.removeTag = (tag) ->
if tag and scope.model.tags
scope.model.tags = scope.model.tags.filter((t) -> t isnt tag.text)
delete scope.model.tags if scope.model.tags.length is 0
# Watch for changes
scope.$watch 'model', (model) ->
scope.thread = annotator.threading.idTable[model.id]
scope.auth = {}
scope.auth.delete =
if model? and annotator.plugins?.Permissions?
annotator.plugins.Permissions.authorize 'delete', model
else
true
scope.auth.update =
if scope.model? and annotator.plugins?.Permissions?
annotator.plugins.Permissions.authorize 'update', model
else
true
scope.tags = ({text: tag} for tag in scope.model.tags or []) ###*
# @ngdoc directive
# @name annotation
# @restrict A
# @description
# Directive that instantiates
# {@link annotation.AnnotationController AnnotationController}.
#
# If the `annotation-embbedded` attribute is specified, its interpolated
# value is used to signal whether the annotation is being displayed inside
# an embedded widget.
###
annotation = ['annotator', 'documentHelpers', (annotator, documentHelpers) ->
linkFn = (scope, elem, attrs, [ctrl, thread, threadFilter, counter]) ->
# Helper function to remove the temporary thread created for a new reply.
prune = (message) ->
return if message.id? # threading plugin will take care of it
return unless thread.container.message is message
thread.container.parent?.removeChild(thread.container)
if thread?
annotator.subscribe 'annotationDeleted', prune
scope.$on '$destroy', ->
annotator.unsubscribe 'annotationDeleted', prune
# Observe the embedded attribute
attrs.$observe 'annotationEmbedded', (value) ->
ctrl.embedded = value? and value != 'false'
# Save on Shift + Enter.
elem.on 'keydown', (event) ->
if event.keyCode == 13 && event.shiftKey
event.preventDefault()
scope.$evalAsync ->
ctrl.save()
# Focus and select the share link when it becomes available.
scope.$watch (-> ctrl.shared), (shared) ->
if shared then scope.$evalAsync ->
elem.find('input').focus().select()
# Keep track of edits going on in the thread.
if counter?
# Expand the thread if descendants are editing.
scope.$watch (-> counter.count 'edit'), (count) ->
if count and not ctrl.editing and thread.collapsed
thread.toggleCollapsed()
# Propagate changes through the counters.
scope.$watch (-> ctrl.editing), (editing, old) ->
if editing
counter.count 'edit', 1
# Disable the filter and freeze it to always match while editing.
threadFilter?.freeze()
else if old
counter.count 'edit', -1
threadFilter?.freeze(false)
# Clean up when the thread is destroyed
scope.$on '$destroy', ->
if ctrl.editing then counter?.count 'edit', -1
# Export the baseURI for the share link
scope.baseURI = documentHelpers.baseURI
controller: 'AnnotationController' controller: 'AnnotationController'
require: '?ngModel' controllerAs: 'vm'
restrict: 'C' link: linkFn
require: ['annotation', '?^thread', '?^threadFilter', '?^deepCount']
scope: scope:
model: '=ngModel' annotationGet: '&annotation'
mode: '@'
replies: '@'
templateUrl: 'annotation.html' templateUrl: 'annotation.html'
] ]
angular.module('h.directives.annotation', imports) angular.module('h.directives')
.controller('AnnotationController', Annotation) .controller('AnnotationController', AnnotationController)
.directive('annotation', annotation) .directive('annotation', annotation)
###*
# @ngdoc type
# @name deepCount.DeepCountController
#
# @description
# `DeepCountController` exports a single getter / setter that can be used
# to retrieve and manipulate a set of counters. Changes to these counters
# are bubbled and aggregated by any instances higher up in the DOM. Digests
# are performed from the top down, scheduled during animation frames, and
# debounced for performance.
###
DeepCountController = [
'$element', '$scope', 'render',
($element, $scope, render) ->
cancelFrame = null
counters = {}
parent = $element.parent().controller('deepCount')
###*
# @ngdoc method
# @name deepCount.DeepCountController#count
#
# @param {string} key An aggregate key.
# @param {number} delta If provided, the amount by which the aggregate
# for the given key should be incremented.
# @return {number} The value of the aggregate for the given key.
#
# @description
# Modify an aggregate when called with an argument and return its current
# value.
###
this.count = (key, delta) ->
if delta is undefined or delta is 0 then return counters[key] or 0
counters[key] ?= 0
counters[key] += delta
unless counters[key] then delete counters[key]
if parent
# Bubble updates.
parent.count key, delta
else
# Debounce digests from the top.
if cancelFrame then cancelFrame()
cancelFrame = render ->
$scope.$digest()
cancelFrame = null
counters[key] or 0
this
]
###*
# @ngdoc directive
# @name deepCount
# @restrict A
# @description
# Directive that instantiates
# {@link deepCount.DeepCountController DeepCountController} and exports it
# to the current scope under the name specified by the attribute parameter.
###
deepCount = [
'$parse',
($parse) ->
controller: 'DeepCountController'
link: (scope, elem, attrs, ctrl) ->
parsedCounterName = $parse attrs.deepCount
if parsedCounterName.assign
parsedCounterName.assign scope, angular.bind ctrl, ctrl.count
]
angular.module('h.directives')
.controller('DeepCountController', DeepCountController)
.directive('deepCount', deepCount)
simpleSearch = ['$parse', ($parse) -> simpleSearch = ['$parse', ($parse) ->
uuid = 0 uuid = 0
link: (scope, elem, attr, ctrl) -> link: (scope, elem, attr, ctrl) ->
_search = $parse(attr.onsearch)
_clear = $parse(attr.onclear)
scope.viewId = uuid++ scope.viewId = uuid++
scope.dosearch = ->
_search(scope, {"this": scope.searchtext})
scope.reset = (event) -> scope.reset = (event) ->
event.preventDefault() event.preventDefault()
scope.searchtext = '' scope.query = ''
_clear(scope) if attr.onclear
scope.search = (event) ->
event.preventDefault()
scope.query = scope.searchtext
scope.$watch attr.query, (query) -> scope.$watch 'query', (query) ->
if query? return if query is undefined
scope.searchtext = query scope.searchtext = query
_search(scope, {"this": scope.searchtext}) if query
scope.onSearch?(query: scope.searchtext)
else
scope.onClear?()
restrict: 'C' restrict: 'C'
scope:
query: '='
onSearch: '&'
onClear: '&'
template: ''' template: '''
<form class="simple-search-form" ng-class="!searchtext && 'simple-search-inactive'" name="searchBox" ng-submit="dosearch()"> <form class="simple-search-form" ng-class="!searchtext && 'simple-search-inactive'" name="searchBox" ng-submit="search($event)">
<input id="simple-search-{{viewId}}" class="simple-search-input" type="text" ng-model="searchtext" name="searchText" placeholder="Search…" /> <input id="simple-search-{{viewId}}" class="simple-search-input" type="text" ng-model="searchtext" name="searchText" placeholder="Search…" />
<label for="simple-search-{{viewId}}" class="simple-search-icon icon-search"></label> <label for="simple-search-{{viewId}}" class="simple-search-icon icon-search"></label>
<button class="simple-search-clear" type="reset" ng-hide="!searchtext" ng-click="reset($event)"> <button class="simple-search-clear" type="reset" ng-hide="!searchtext" ng-click="reset($event)">
......
### global -COLLAPSED_CLASS ###
COLLAPSED_CLASS = 'thread-collapsed'
###*
# @ngdoc type
# @name thread.ThreadController
#
# @property {Object} container The thread domain model. An instance of
# `mail.messageContainer`.
# @property {boolean} collapsed True if the thread is collapsed.
#
# @description
# `ThreadController` provides an API for the thread directive controlling
# the collapsing behavior.
###
ThreadController = [
->
@container = null
@collapsed = false
###*
# @ngdoc method
# @name thread.ThreadController#toggleCollapsed
# @description
# Toggle the collapsed property.
###
this.toggleCollapsed = ->
@collapsed = not @collapsed
this
]
###*
# @ngdoc directive
# @name thread
# @restrict A
# @description
# Directive that instantiates {@link thread.ThreadController ThreadController}.
#
# If the `thread-collapsed` attribute is specified, it is treated as an
# expression to watch in the context of the current scope that controls
# the collapsed state of the thread.
###
thread = [
'$parse', '$window', 'render',
($parse, $window, render) ->
linkFn = (scope, elem, attrs, [ctrl, counter]) ->
# Toggle collapse on click.
elem.on 'click', (event) ->
event.stopPropagation()
# Ignore if the target scope has been destroyed.
# Prevents collapsing when e.g. a child is deleted by a click event.
if angular.element(event.target).scope() is undefined
return
# Ignore if the user just created a non-empty selection.
sel = $window.getSelection()
if sel.containsNode(event.target, true) and sel.toString().length
return
# Ignore if the user clicked a link
if event.target.tagName in ['A', 'INPUT']
return unless angular.element(event.target).hasClass 'reply-count'
# Ignore a collapse if edit interactions are present in the view.
if counter?.count('edit') > 0 and not ctrl.collapsed
return
scope.$evalAsync ->
ctrl.toggleCollapsed()
# Queue a render frame to complete the binding and show the element.
render ->
ctrl.container = $parse(attrs.thread)(scope)
counter.count 'message', 1
scope.$digest()
scope.$on '$destroy', -> counter.count 'message', -1
# Add and remove the collapsed class when the collapsed property changes.
scope.$watch (-> ctrl.collapsed), (collapsed) ->
if collapsed
attrs.$addClass COLLAPSED_CLASS
else
attrs.$removeClass COLLAPSED_CLASS
# Watch the thread-collapsed attribute.
if attrs.threadCollapsed
scope.$watch $parse(attrs.threadCollapsed), (collapsed) ->
ctrl.toggleCollapsed() if !!collapsed != ctrl.collapsed
controller: 'ThreadController'
controllerAs: 'vm'
link: linkFn
require: ['thread', '?^deepCount']
scope: true
]
angular.module('h.directives')
.controller('ThreadController', ThreadController)
.directive('thread', thread)
###*
# @ngdoc type
# @name threadFilter.ThreadFilterController
#
# @property {boolean} match True if the last checked message was a match.
#
# @description
# `ThreadFilterController` provides an API for maintaining filtering over
# a message thread.
###
ThreadFilterController = [
'viewFilter'
(viewFilter) ->
@match = false
@_active = false
@_children = []
@_filters = null
@_frozen = false
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#active
#
# @param {boolean=} active New state
# @return {boolean} state
#
# @description
# This method is a getter / setter.
#
# Activate or deactivate filtering when called with an argument and
# return the activation status.
###
this.active = (active) ->
if active is undefined then return @_active
else if @active == active then return @_active
else
child.active active for child in @_children
if @_frozen then @_active else @_active = active
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#filters
#
# @param {Object=} filters New filters
# @return {Object} filters
#
# @description
# This method is a getter / setter.
#
# Set the filter configuration when called with an argument and return
# return the configuration.
###
this.filters = (filters) ->
if filters is undefined then return @_filters
else if @filters == filters then return @_filters
else
child.filters filters for child in @_children
if @_frozen then @_filters else @_filters = filters
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#freeze
#
# @param {boolean=} frozen New state
# @return {boolean} frozen state
#
# @description
# This method is a getter / setter.
#
# Freeze or unfreeze the filter when called with an argument and
# return the frozen state. A frozen filter will not change its activation
# state or filter configuration.
###
this.freeze = (frozen=true) ->
if frozen? then @_frozen = frozen else @_frozen
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#check
#
# @param {Object} container The `mail.messageContainer` to filter.
# @return {boolean} True if the message matches the filters, it has not
# yet been saved, or if filtering is not active.
#
# @description
# Check whether a message container carries a message matching the
# configured filters. If filtering is not active then the result is
# always `true`. Updates the `match` property to reflect the result.
###
this.check = (container) ->
unless container?.message then return false
if this.active()
@match = !!viewFilter.filter([container.message], @_filters).length
else
@match = true
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#registerChild
#
# @param {Object} target The child controller instance.
#
# @description
# Add another instance of the thread filter controller to the set of
# registered children. Changes in activation status and filter configuration
# are propagated to child controllers.
###
this.registerChild = (target) ->
@_children.push target
###*
# @ngdoc method
# @name threadFilter.ThreadFilterController#unregisterChild
#
# @param {Object} target The child controller instance.
#
# @description
# Remove a previously registered instance of the thread filter controller
# from the set of registered children.
###
this.unregisterChild = (target) ->
@_children = (child for child in @_children if child isnt target)
this
]
###*
# @ngdoc directive
# @name threadFilter
# @restrict A
# @description
# Directive that instantiates
# {@link threadFilter.ThreadFilterController ThreadController}.
#
# The threadFilter directive utilizes the {@link searchfilter searchfilter}
# service to parse the expression passed in the directive attribute as a
# faceted search query and configures its controller with the resulting
# filters. It watches the `match` property of the controller and updates
# its thread's message count under the 'filter' key.
###
threadFilter = [
'$parse', 'searchfilter'
($parse, searchfilter) ->
linkFn = (scope, elem, attrs, [ctrl, thread, counter]) ->
if counter?
scope.$watch (-> ctrl.match), (match, old) ->
if match and not old
counter.count 'match', 1
else if old
counter.count 'match', -1
scope.$on '$destroy', ->
if ctrl.match then counter.count 'match', -1
if parentCtrl = elem.parent().controller('threadFilter')
ctrl.filters parentCtrl.filters()
ctrl.active parentCtrl.active()
parentCtrl.registerChild ctrl
scope.$on '$destroy', -> parentCtrl.unregisterChild ctrl
else
scope.$watch $parse(attrs.threadFilter), (query) ->
unless query then return ctrl.active false
filters = searchfilter.generateFacetedFilter(query)
ctrl.filters filters
ctrl.active true
controller: 'ThreadFilterController'
controllerAs: 'threadFilter'
link: linkFn
require: ['threadFilter', 'thread', '?^deepCount']
]
angular.module('h.directives')
.controller('ThreadFilterController', ThreadFilterController)
.directive('threadFilter', threadFilter)
...@@ -40,6 +40,25 @@ fuzzyTime = (date) -> ...@@ -40,6 +40,25 @@ fuzzyTime = (date) ->
fuzzy = Math.round(delta / year) + ' years ago' fuzzy = Math.round(delta / year) + ' years ago'
fuzzy fuzzy
momentFilter = ->
(value, format) ->
# Determine the timezone name and browser language.
timezone = jstz.determine().name()
userLang = navigator.language || navigator.userLanguage
# Now make a localized date and set the language.
momentDate = moment value
momentDate.lang userLang
# Try to localize to the browser's timezone.
try
momentDate.tz(timezone).format('LLLL')
catch error
# For an invalid timezone, use the default.
momentDate.format('LLLL')
persona = (user, part='username') -> persona = (user, part='username') ->
part = ['term', 'username', 'provider'].indexOf(part) part = ['term', 'username', 'provider'].indexOf(part)
(user?.match /^acct:([^@]+)@(.+)/)?[part] (user?.match /^acct:([^@]+)@(.+)/)?[part]
...@@ -54,5 +73,6 @@ elide = (text, split_length) -> ...@@ -54,5 +73,6 @@ elide = (text, split_length) ->
angular.module('h.filters', []) angular.module('h.filters', [])
.filter('converter', -> (new Converter()).makeHtml) .filter('converter', -> (new Converter()).makeHtml)
.filter('fuzzyTime', -> fuzzyTime) .filter('fuzzyTime', -> fuzzyTime)
.filter('moment', momentFilter)
.filter('persona', -> persona) .filter('persona', -> persona)
.filter('elide', -> elide) .filter('elide', -> elide)
...@@ -23,22 +23,14 @@ class Annotator.Guest extends Annotator ...@@ -23,22 +23,14 @@ class Annotator.Guest extends Annotator
Document: {} Document: {}
# Internal state # Internal state
comments: null
tool: 'comment' tool: 'comment'
visibleHighlights: false visibleHighlights: false
noBack: false
constructor: (element, options, config = {}) -> constructor: (element, options, config = {}) ->
options.noScan = true options.noScan = true
super super
delete @options.noScan delete @options.noScan
# Create an array for holding the comments
@comments = []
# These are in focus (visually marked here, and open in sidebar)
@focusedAnnotations = []
@frame = $('<div></div>') @frame = $('<div></div>')
.appendTo(@wrapper) .appendTo(@wrapper)
.addClass('annotator-frame annotator-outer annotator-collapsed') .addClass('annotator-frame annotator-outer annotator-collapsed')
...@@ -58,7 +50,6 @@ class Annotator.Guest extends Annotator ...@@ -58,7 +50,6 @@ class Annotator.Guest extends Annotator
formatted.document.title = formatted.document.title.slice() formatted.document.title = formatted.document.title.slice()
formatted formatted
onConnect: (source, origin, scope) => onConnect: (source, origin, scope) =>
this.publish "enableAnnotating", @canAnnotate
@panel = this._setupXDM @panel = this._setupXDM
window: source window: source
origin: origin origin: origin
...@@ -80,14 +71,6 @@ class Annotator.Guest extends Annotator ...@@ -80,14 +71,6 @@ class Annotator.Guest extends Annotator
# Scan the document text with the DOM Text libraries # Scan the document text with the DOM Text libraries
this.scanDocument "Guest initialized" this.scanDocument "Guest initialized"
# Watch for deleted comments
this.subscribe 'annotationDeleted', (annotation) =>
if this.isComment annotation
i = @comments.indexOf annotation
if i isnt -1
@comments[i..i] = []
@plugins.Heatmap._update()
# Watch for newly rendered highlights, and update positions in sidebar # Watch for newly rendered highlights, and update positions in sidebar
this.subscribe "highlightsCreated", (highlights) => this.subscribe "highlightsCreated", (highlights) =>
unless Array.isArray highlights unless Array.isArray highlights
...@@ -105,8 +88,9 @@ class Annotator.Guest extends Annotator ...@@ -105,8 +88,9 @@ class Annotator.Guest extends Annotator
# Collect all impacted annotations # Collect all impacted annotations
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.publish "annotationsLoaded", [annotations] this.plugins.Bridge.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) =>
...@@ -127,7 +111,7 @@ class Annotator.Guest extends Annotator ...@@ -127,7 +111,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.publish "annotationsLoaded", [[highlight.annotation]] this.plugins.Bridge.sync([highlight.annotation])
_setupXDM: (options) -> _setupXDM: (options) ->
# jschannel chokes FF and Chrome extension origins. # jschannel chokes FF and Chrome extension origins.
...@@ -144,6 +128,7 @@ class Annotator.Guest extends Annotator ...@@ -144,6 +128,7 @@ class Annotator.Guest extends Annotator
.bind('setDynamicBucketMode', (ctx, value) => .bind('setDynamicBucketMode', (ctx, value) =>
return unless @plugins.Heatmap return unless @plugins.Heatmap
return if @plugins.Heatmap.dynamicBucket is value
@plugins.Heatmap.dynamicBucket = value @plugins.Heatmap.dynamicBucket = value
if value then @plugins.Heatmap._update() if value then @plugins.Heatmap._update()
) )
...@@ -159,11 +144,9 @@ class Annotator.Guest extends Annotator ...@@ -159,11 +144,9 @@ class Annotator.Guest extends Annotator
) )
.bind('setFocusedHighlights', (ctx, tags=[]) => .bind('setFocusedHighlights', (ctx, tags=[]) =>
this.focusedAnnotations = []
for hl in @getHighlights() for hl in @getHighlights()
annotation = hl.annotation annotation = hl.annotation
if annotation.$$tag in tags if annotation.$$tag in tags
this.focusedAnnotations.push annotation
hl.setFocused true, true hl.setFocused true, true
else else
hl.setFocused false, true hl.setFocused false, true
...@@ -177,13 +160,6 @@ class Annotator.Guest extends Annotator ...@@ -177,13 +160,6 @@ class Annotator.Guest extends Annotator
return return
) )
.bind('adderClick', =>
@selectedTargets = @forcedLoginTargets
@onAdderClick @forcedLoginEvent
delete @forcedLoginTargets
delete @forcedLoginEvent
)
.bind('getDocumentInfo', => .bind('getDocumentInfo', =>
return { return {
uri: @plugins.Document.uri() uri: @plugins.Document.uri()
...@@ -191,22 +167,6 @@ class Annotator.Guest extends Annotator ...@@ -191,22 +167,6 @@ class Annotator.Guest extends Annotator
} }
) )
.bind('showAll', =>
# Switch off dynamic mode on the heatmap
if @plugins.Heatmap
@plugins.Heatmap.dynamicBucket = false
# Collect all successfully attached annotations
annotations = []
for page, anchors of @anchors
for anchor in anchors
unless anchor.annotation in annotations
annotations.push anchor.annotation
# Show all the annotations
@updateViewer "Document", annotations
)
.bind('setTool', (ctx, name) => .bind('setTool', (ctx, name) =>
@tool = name @tool = name
this.publish 'setTool', name this.publish 'setTool', name
...@@ -231,12 +191,13 @@ class Annotator.Guest extends Annotator ...@@ -231,12 +191,13 @@ class Annotator.Guest extends Annotator
_setupWrapper: -> _setupWrapper: ->
@wrapper = @element @wrapper = @element
.on 'click', => .on 'click', (event) =>
if @canAnnotate and not @noBack and not @creatingHL if @selectedTargets?.length
setTimeout => if @tool is 'highlight'
unless @selectedTargets?.length # Create the annotation
@hideFrame() annotation = this.setupAnnotation(this.createAnnotation())
delete @creatingHL else
@hideFrame()
this this
# These methods aren't used in the iframe-hosted configuration of Annotator. # These methods aren't used in the iframe-hosted configuration of Annotator.
...@@ -265,40 +226,29 @@ class Annotator.Guest extends Annotator ...@@ -265,40 +226,29 @@ class Annotator.Guest extends Annotator
this.removeEvents() this.removeEvents()
showViewer: (viewName, annotations, focused = false) => showViewer: (annotations) =>
@panel?.notify @panel?.notify
method: "showViewer" method: "showViewer"
params: params: (a.$$tag for a in annotations)
view: viewName
ids: (a.id for a in annotations when a.id)
focused: focused
toggleViewerSelection: (annotations, focused = false) => toggleViewerSelection: (annotations) =>
@panel?.notify @panel?.notify
method: "toggleViewerSelection" method: "toggleViewerSelection"
params: params: (a.$$tag for a in annotations)
ids: (a.id for a in annotations)
focused: focused
updateViewer: (viewName, annotations, focused = false) => updateViewer: (annotations) =>
@panel?.notify @panel?.notify
method: "updateViewer" method: "updateViewer"
params: params: (a.$$tag for a in annotations)
view: viewName
ids: (a.id for a in annotations when a.id)
focused: focused
showEditor: (annotation) => @plugins.Bridge.showEditor annotation
addEmphasis: (annotations) => showEditor: (annotation) =>
@panel?.notify @panel?.notify
method: "addEmphasis" method: "showEditor"
params: (a.id for a in annotations when a.id) params: annotation.$$tag
removeEmphasis: (annotations) => onAnchorMouseover: ->
@panel?.notify onAnchorMouseout: ->
method: "removeEmphasis" onAnchorMousedown: ->
params: (a.id for a in annotations when a.id)
checkForStartSelection: (event) => checkForStartSelection: (event) =>
# Override to prevent Annotator choking when this ties to access the # Override to prevent Annotator choking when this ties to access the
...@@ -317,81 +267,36 @@ class Annotator.Guest extends Annotator ...@@ -317,81 +267,36 @@ class Annotator.Guest extends Annotator
return confirm "You have selected a very short piece of text: only " + length + " chars. Are you sure you want to highlight this?" return confirm "You have selected a very short piece of text: only " + length + " chars. Are you sure you want to highlight this?"
onSuccessfulSelection: (event, immediate) -> onSuccessfulSelection: (event, immediate) ->
return unless @canAnnotate
if @tool is 'highlight' if @tool is 'highlight'
# Describe the selection with targets
@selectedTargets = (@_getTargetFromSelection(s) for s in event.segments)
# Are we allowed to create annotations? Return false if we can't.
unless @canAnnotate
return false
# Do we really want to make this selection? # Do we really want to make this selection?
return false unless this.confirmSelection() return false unless this.confirmSelection()
# Describe the selection with targets
# Add a flag about what's happening @selectedTargets = (@_getTargetFromSelection(s) for s in event.segments)
@creatingHL = true return
super
# Create the annotation
annotation = {inject: true}
this.publish 'beforeAnnotationCreated', annotation
# Set up the annotation
annotation = this.setupAnnotation annotation
this.publish 'annotationCreated', annotation
else
super
# Get the list of annotations impacted by a mouse event
_getImpactedAnnotations: (event) ->
# Get the raw list
annotations = event.data.getAnnotations event
# Are the highlights supposed to be visible?
if (@tool is 'highlight') or @visibleHighlights
# Just use the whole list
annotations
else
# We need to check for focused annotations
a for a in annotations when a in this.focusedAnnotations
onAnchorMouseover: (event) ->
this.addEmphasis this._getImpactedAnnotations event
onAnchorMouseout: (event) ->
this.removeEmphasis this._getImpactedAnnotations event
# When clicking on a highlight in highlighting mode,
# set @noBack to true to prevent the sidebar from closing
onAnchorMousedown: (event) =>
if this._getImpactedAnnotations(event).length
@noBack = true
# Select some annotations. # Select some annotations.
# #
# toggle: should this toggle membership in an existing selection? # toggle: should this toggle membership in an existing selection?
# focus: should these annotation become focused? selectAnnotations: (annotations, toggle) =>
selectAnnotations: (annotations, toggle, focus) =>
# Switch off dynamic mode; we are going to "Selection" scope # Switch off dynamic mode; we are going to "Selection" scope
@plugins.Heatmap.dynamicBucket = false @plugins.Heatmap.dynamicBucket = false
if toggle if toggle
# Tell sidebar to add these annotations to the sidebar # Tell sidebar to add these annotations to the sidebar
this.toggleViewerSelection annotations, focus this.toggleViewerSelection annotations
else else
# Tell sidebar to show the viewer for these annotations # Tell sidebar to show the viewer for these annotations
this.showViewer "Selection", annotations, focus this.showViewer annotations
# When clicking on a highlight in highlighting mode, # When clicking on a highlight in highlighting mode,
# tell the sidebar to bring up the viewer for the relevant annotations # tell the sidebar to bring up the viewer for the relevant annotations
onAnchorClick: (event) => onAnchorClick: (event) =>
annotations = this._getImpactedAnnotations event if @visibleHighlights or @tool is 'highlight'
return unless annotations.length and @noBack event.stopPropagation()
this.selectAnnotations (event.data.getAnnotations event),
this.selectAnnotations annotations, (event.metaKey or event.ctrlKey)
(event.metaKey or event.ctrlKey), true
# We have already prevented closing the sidebar, now reset this flag
@noBack = false
setTool: (name) -> setTool: (name) ->
@panel?.notify @panel?.notify
...@@ -411,24 +316,7 @@ class Annotator.Guest extends Annotator ...@@ -411,24 +316,7 @@ class Annotator.Guest extends Annotator
@element.removeClass markerClass @element.removeClass markerClass
addComment: -> addComment: ->
sel = @selectedTargets # Save the selection this.showEditor(this.createAnnotation())
# Nuke the selection, since we won't be using that.
# We will attach this to the end of the document.
# Our override for setupAnnotation will add that highlight.
@selectedTargets = []
this.onAdderClick() # Open editor (with 0 targets)
@selectedTargets = sel # restore the selection
# Is this annotation a comment?
isComment: (annotation) ->
# No targets and no references means that this is a comment.
not (annotation.inject or annotation.references?.length or annotation.target?.length)
# Override for setupAnnotation, to handle comments
setupAnnotation: (annotation) ->
annotation = super # Set up annotation as usual
if this.isComment annotation then @comments.push annotation
annotation
# Open the sidebar # Open the sidebar
showFrame: -> showFrame: ->
...@@ -443,56 +331,14 @@ class Annotator.Guest extends Annotator ...@@ -443,56 +331,14 @@ class Annotator.Guest extends Annotator
method: 'addToken' method: 'addToken'
params: token params: token
onAdderMousedown: ->
onAdderClick: (event) => onAdderClick: (event) =>
""" event.preventDefault()
Differs from upstream in a few ways: event.stopPropagation()
- Don't fire annotationCreated events: that's the job of the sidebar
- Save the event for retriggering if login interrupts the flow
"""
event?.preventDefault()
# Save the event and targets for restarting edit on forced login
@forcedLoginEvent = event
@forcedLoginTargets = @selectedTargets
# Hide the adder
@adder.hide() @adder.hide()
@inAdderClick = false
position = @adder.position()
# Show a temporary highlight so the user can see what they selected
# Also extract the quotation and serialize the ranges
annotation = this.setupAnnotation(this.createAnnotation()) annotation = this.setupAnnotation(this.createAnnotation())
this.showEditor(annotation)
hl.setTemporary(true) for hl in @getHighlights([annotation])
# Subscribe to the editor events
# Make the highlights permanent if the annotation is saved
save = =>
do cleanup
hl.setTemporary false for hl in @getHighlights [annotation]
# Remove the highlights if the edit is cancelled
cancel = =>
do cleanup
this.deleteAnnotation(annotation)
# Don't leak handlers at the end
cleanup = =>
this.unsubscribe('annotationEditorHidden', cancel)
this.unsubscribe('annotationEditorSubmit', save)
this.subscribe('annotationEditorHidden', cancel)
this.subscribe('annotationEditorSubmit', save)
# Display the editor.
this.showEditor(annotation, position)
# We have to clear the selection.
# (Annotator does this automatically by focusing on
# one of the input fields in the editor.)
Annotator.util.getGlobal().getSelection().removeAllRanges()
onSetTool: (name) -> onSetTool: (name) ->
switch name switch name
...@@ -504,14 +350,3 @@ class Annotator.Guest extends Annotator ...@@ -504,14 +350,3 @@ class Annotator.Guest extends Annotator
onSetVisibleHighlights: (state) => onSetVisibleHighlights: (state) =>
this.visibleHighlights = state this.visibleHighlights = state
this.setVisibleHighlights state, false this.setVisibleHighlights state, false
# TODO: Workaround for double annotation deletion.
# The short story: hiding the editor sometimes triggers
# a spurious annotation delete.
# Uncomment the traces below to investigate this further.
deleteAnnotation: (annotation) ->
if annotation.deleted
return
else
annotation.deleted = true
super
...@@ -43,29 +43,17 @@ class Annotator.Host extends Annotator.Guest ...@@ -43,29 +43,17 @@ class Annotator.Host extends Annotator.Guest
channel channel
.bind('showFrame', (ctx, routeName) => .bind('showFrame', (ctx) =>
unless @drag.enabled unless @drag.enabled
@frame.css 'margin-left': "#{-1 * @frame.width()}px" @frame.css 'margin-left': "#{-1 * @frame.width()}px"
@frame.removeClass 'annotator-no-transition' @frame.removeClass 'annotator-no-transition'
@frame.removeClass 'annotator-collapsed' @frame.removeClass 'annotator-collapsed'
switch routeName
when 'editor'
this.publish 'annotationEditorShown'
when 'viewer'
this.publish 'annotationViewerShown'
) )
.bind('hideFrame', (ctx, routeName) => .bind('hideFrame', (ctx) =>
@frame.css 'margin-left': '' @frame.css 'margin-left': ''
@frame.removeClass 'annotator-no-transition' @frame.removeClass 'annotator-no-transition'
@frame.addClass 'annotator-collapsed' @frame.addClass 'annotator-collapsed'
switch routeName
when 'editor'
this.publish 'annotationEditorHidden'
when 'viewer'
this.publish 'annotationViewerHidden'
) )
.bind('dragFrame', (ctx, screenX) => this._dragUpdate screenX) .bind('dragFrame', (ctx, screenX) => this._dragUpdate screenX)
......
...@@ -9,7 +9,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin ...@@ -9,7 +9,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
'annotationUpdated': 'annotationUpdated' 'annotationUpdated': 'annotationUpdated'
'annotationDeleted': 'annotationDeleted' 'annotationDeleted': 'annotationDeleted'
'annotationsLoaded': 'annotationsLoaded' 'annotationsLoaded': 'annotationsLoaded'
'enableAnnotating': 'enableAnnotating'
# Helper method for merging info from a remote target # Helper method for merging info from a remote target
@_mergeTarget: (local, remote, gateway) => @_mergeTarget: (local, remote, gateway) =>
...@@ -189,10 +188,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin ...@@ -189,10 +188,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
channel = Channel.build(options) channel = Channel.build(options)
## Remote method call bindings ## Remote method call bindings
.bind('setupAnnotation', (txn, annotation) =>
this._format (@annotator.setupAnnotation (this._parse annotation))
)
.bind('beforeCreateAnnotation', (txn, annotation) => .bind('beforeCreateAnnotation', (txn, annotation) =>
annotation = this._parse annotation annotation = this._parse annotation
delete @cache[annotation.$$tag] delete @cache[annotation.$$tag]
...@@ -226,29 +221,14 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin ...@@ -226,29 +221,14 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
res res
) )
## Notifications .bind('sync', (ctx, annotations) =>
.bind('loadAnnotations', (txn, annotations) => (this._format (this._parse a) for a in annotations)
# First, parse the existing ones, for any updates
oldOnes = (this._parse a for a in annotations when @cache[a.tag])
# Announce the changes in old annotations
if oldOnes.length
@selfPublish = true
@annotator.publish 'annotationsLoaded', [oldOnes]
delete @selfPublish
# Then collect the new ones
newOnes = (this._parse a for a in annotations when not @cache[a.tag])
if newOnes.length
@annotator.loadAnnotations newOnes
)
.bind('showEditor', (ctx, annotation) =>
@annotator.showEditor (this._parse annotation)
) )
.bind('enableAnnotating', (ctx, state) => ## Notifications
@annotator.enableAnnotating state, false .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 # Send out a beacon to let other frames know to connect to us
...@@ -288,10 +268,15 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin ...@@ -288,10 +268,15 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
$.when(deferreds...) $.when(deferreds...)
.then (results...) => .then (results...) =>
annotation = {} if Array.isArray(results[0])
for r in results when r isnt null acc = []
$.extend annotation, (this._parse r) foldFn = (_, cur) =>
options.callback? null, annotation (this._parse(a) for a in cur)
else
acc = {}
foldFn = (_, cur) =>
this._parse(cur)
options.callback? null, results.reduce(foldFn, acc)
.fail (failure) => .fail (failure) =>
options.callback? failure options.callback? failure
...@@ -366,14 +351,11 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin ...@@ -366,14 +351,11 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
this this
annotationsLoaded: (annotations) => annotationsLoaded: (annotations) =>
return if @selfPublish annotations = (this._format a for a in annotations when not a.$$tag)
unless annotations.length return unless annotations.length
console.log "Useless call to 'annotationsLoaded()' with an empty list"
console.trace()
return
this._notify this._notify
method: 'loadAnnotations' method: 'loadAnnotations'
params: (this._format a for a in annotations) params: annotations
this this
beforeCreateAnnotation: (annotation, cb) -> beforeCreateAnnotation: (annotation, cb) ->
...@@ -383,13 +365,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin ...@@ -383,13 +365,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
callback: cb callback: cb
annotation annotation
setupAnnotation: (annotation, cb) ->
this._call
method: 'setupAnnotation'
params: this._format annotation
callback: cb
annotation
createAnnotation: (annotation, cb) -> createAnnotation: (annotation, cb) ->
this._call this._call
method: 'createAnnotation' method: 'createAnnotation'
...@@ -411,13 +386,10 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin ...@@ -411,13 +386,10 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
callback: cb callback: cb
annotation annotation
showEditor: (annotation) -> sync: (annotations, cb) ->
this._notify annotations = (this._format a for a in annotations)
method: 'showEditor' this._call
params: this._format annotation method: 'sync'
params: annotations
callback: cb
this this
enableAnnotating: (state) ->
this._notify
method: 'enableAnnotating'
params: state
...@@ -58,16 +58,11 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin ...@@ -58,16 +58,11 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
'annotationsLoaded' 'annotationsLoaded'
] ]
for event in events for event in events
if event is 'annotationCreated' @annotator.subscribe event, this._scheduleUpdate
@annotator.subscribe event, =>
this._scheduleUpdate()
else
@annotator.subscribe event, this._scheduleUpdate
@element.on 'click', (event) => @element.on 'click', (event) =>
event.stopPropagation() event.stopPropagation()
@dynamicBucket = true @annotator.showFrame()
@annotator.showViewer "Screen", this._getDynamicBucket()
@element.on 'mouseup', (event) => @element.on 'mouseup', (event) =>
event.stopPropagation() event.stopPropagation()
...@@ -437,7 +432,6 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin ...@@ -437,7 +432,6 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
annotations = @buckets[bucket].slice() annotations = @buckets[bucket].slice()
annotator.selectAnnotations annotations, annotator.selectAnnotations annotations,
(d3.event.ctrlKey or d3.event.metaKey), (d3.event.ctrlKey or d3.event.metaKey),
(annotations.length is 1) # Only focus if there is only one
tabs.exit().remove() tabs.exit().remove()
...@@ -458,7 +452,7 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin ...@@ -458,7 +452,7 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
if (@buckets[d].length is 0) then 'none' else '' if (@buckets[d].length is 0) then 'none' else ''
if @dynamicBucket if @dynamicBucket
@annotator.updateViewer "Screen", this._getDynamicBucket() @annotator.updateViewer this._getDynamicBucket()
@tabs = tabs @tabs = tabs
...@@ -480,4 +474,4 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin ...@@ -480,4 +474,4 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
# Simulate clicking on the comments tab # Simulate clicking on the comments tab
commentClick: => commentClick: =>
@dynamicBucket = false @dynamicBucket = false
annotator.showViewer "Comments", @buckets[@_getCommentBucket()] annotator.showViewer @buckets[@_getCommentBucket()]
class Annotator.Plugin.Threading extends Annotator.Plugin class Annotator.Plugin.Threading extends Annotator.Plugin
# These events maintain the awareness of annotations between the two
# communicating annotators.
events: events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
'annotationDeleted': 'annotationDeleted' 'annotationDeleted': 'annotationDeleted'
'annotationsLoaded': 'annotationsLoaded' 'annotationsLoaded': 'annotationsLoaded'
'beforeAnnotationCreated': 'beforeAnnotationCreated'
# Cache of annotations which have crossed the bridge for fast, encapsulated root: null
# association of annotations received in arguments to window-local copies.
cache: {}
pluginInit: -> pluginInit: ->
@annotator.threading = mail.messageThread() # Create a root container.
@root = mail.messageContainer()
thread: (annotation) -> # Mix in message thread properties, preserving local overrides.
# Get or create a thread to contain the annotation $.extend(this, mail.messageThread(), thread: this.thread)
thread = (@annotator.threading.getContainer annotation.id)
thread.message = annotation
# Attach the thread to its parent, if any. # TODO: Refactor the jwz API for progressive updates.
if annotation.references?.length # Right now the idTable is wiped when `messageThread.thread()` is called and
prev = annotation.references[annotation.references.length-1] # empty containers are pruned. We want to show empties so that replies attach
@annotator.threading.getContainer(prev).addChild thread # to missing parents and threads can be updates as new data arrives.
thread: (messages) ->
for message in messages
# Get or create a thread to contain the annotation
if message.id
thread = (this.getContainer message.id)
thread.message = message
else
# XXX: relies on outside code to update the idTable if the message
# later acquires an id.
thread = mail.messageContainer(message)
# Expose the thread to the annotation prev = @root
Object.defineProperty annotation, 'thread',
configurable: true
enumerable: false
writable: true
value: thread
# Update the id table references = message.references or []
@annotator.threading.idTable[annotation.id] = thread if typeof(message.references) == 'string'
references = [references]
thread # Build out an ancestry from the root
for reference in references
container = this.getContainer(reference)
unless container.parent? or container.hasDescendant(prev) # no cycles
prev.addChild(container)
prev = container
annotationDeleted: (annotation) => # Attach the thread at its leaf location
parent = annotation.thread.parent unless thread.hasDescendant(prev) # no cycles
annotation.thread.message = null # Break cyclic reference do ->
delete @annotator.threading.idTable[annotation.id] for child in prev.children when child.message is message
delete annotation.thread return # no dupes
if parent? then @annotator.threading.pruneEmpties parent prev.addChild(thread)
annotationsLoaded: (annotations) => this.pruneEmpties(@root)
this.thread a for a in annotations @root
beforeAnnotationCreated: (annotation) => beforeAnnotationCreated: (annotation) =>
# Assign temporary id. Threading relies on the id. this.thread([annotation])
Object.defineProperty annotation, 'id',
configurable: true annotationDeleted: ({id}) =>
enumerable: false container = this.getContainer id
writable: true container.message = null
value: window.btoa Math.random() this.pruneEmpties(@root)
this.thread annotation
annotationsLoaded: (annotations) =>
messages = (@root.flattenChildren() or []).concat(annotations)
this.thread(messages)
...@@ -73,7 +73,7 @@ class SearchFilter ...@@ -73,7 +73,7 @@ class SearchFilter
filter = term.slice 0, term.indexOf ":" filter = term.slice 0, term.indexOf ":"
unless filter? then filter = "" unless filter? then filter = ""
switch filter switch filter
when 'quote' then quote.push term[6..] when 'quote' then quote.push term[6..].toLowerCase()
when 'result' then result.push term[7..] when 'result' then result.push term[7..]
when 'since' when 'since'
# We'll turn this into seconds # We'll turn this into seconds
...@@ -109,44 +109,36 @@ class SearchFilter ...@@ -109,44 +109,36 @@ class SearchFilter
# Time given in year # Time given in year
t = /^(\d+)year$/.exec(time)[1] t = /^(\d+)year$/.exec(time)[1]
since.push t * 60 * 60 * 24 * 365 since.push t * 60 * 60 * 24 * 365
when 'tag' then tag.push term[4..] when 'tag' then tag.push term[4..].toLowerCase()
when 'text' then text.push term[5..] when 'text' then text.push term[5..].toLowerCase()
when 'uri' then uri.push term[4..] when 'uri' then uri.push term[4..].toLowerCase()
when 'user' then user.push term[5..] when 'user' then user.push term[5..].toLowerCase()
else any.push term else any.push term.toLowerCase()
any: any:
terms: any terms: any
operator: 'and' operator: 'and'
lowercase: true
quote: quote:
terms: quote terms: quote
operator: 'and' operator: 'and'
lowercase: true
result: result:
terms: result terms: result
operator: 'min' operator: 'min'
lowercase: false
since: since:
terms: since terms: since
operator: 'and' operator: 'and'
lowercase: false
tag: tag:
terms: tag terms: tag
operator: 'and' operator: 'and'
lowercase: true
text: text:
terms: text terms: text
operator: 'and' operator: 'and'
lowercase: true
uri: uri:
terms: uri terms: uri
operator: 'or' operator: 'or'
lowercase: true
user: user:
terms: user terms: user
operator: 'or' operator: 'or'
lowercase: true
# This class will process the results of search and generate the correct filter # This class will process the results of search and generate the correct filter
...@@ -235,19 +227,6 @@ class QueryParser ...@@ -235,19 +227,6 @@ class QueryParser
and_or: 'and' and_or: 'and'
fields: ['quote', 'tag', 'text', 'uri', 'user'] fields: ['quote', 'tag', 'text', 'uri', 'user']
parseModels: (models) ->
# Cluster facets together
categories = {}
for searchItem in models
category = searchItem.attributes.category
value = searchItem.attributes.value
if category of categories
categories[category].push value
else
categories[category] = [value]
categories
populateFilter: (filter, query) => populateFilter: (filter, query) =>
# Populate a filter with a query object # Populate a filter with a query object
for category, value of query for category, value of query
......
imports = [ ###*
'h.filters', # @ngdoc service
'h.searchfilters' # @name render
# @param {function()} fn A function to execute in a future animation frame.
# @returns {function()} A function to cancel the execution.
# @description
# The render service is a wrapper around `window#requestAnimationFrame()` for
# scheduling sequential updates in successive animation frames. It has the
# same signature as the original function, but will queue successive calls
# for future frames so that at most one callback is handled per animation frame.
# Use this service to schedule DOM-intensive digests.
###
renderFactory = ['$$rAF', ($$rAF) ->
cancel = null
queue = []
render = ->
return cancel = null if queue.length is 0
do queue.shift()
$$rAF(render)
(fn) ->
queue.push fn
unless cancel then cancel = $$rAF(render)
-> queue = (f for f in queue when f isnt fn)
] ]
class Hypothesis extends Annotator class Hypothesis extends Annotator
events: events:
'annotationCreated': 'updateAncestors' 'beforeAnnotationCreated': 'digest'
'annotationUpdated': 'updateAncestors' 'annotationCreated': 'digest'
'annotationDeleted': 'updateAncestors' 'annotationDeleted': 'digest'
'annotationUpdated': 'digest'
'annotationsLoaded': 'digest'
# Plugin configuration # Plugin configuration
options: options:
noDocAccess: true noDocAccess: true
Discovery: {} Discovery: {}
Permissions:
userAuthorize: (action, annotation, user) ->
if annotation.permissions
tokens = annotation.permissions[action] || []
if tokens.length == 0
# Empty or missing tokens array: only admin can perform action.
return false
for token in tokens
if this.userId(user) == token
return true
if token == 'group:__world__'
return true
if token == 'group:__authenticated__' and this.user?
return true
# No tokens matched: action should not be performed.
return false
# Coarse-grained authorization
else if annotation.user
return user and this.userId(user) == this.userId(annotation.user)
# No authorization info on annotation: free-for-all!
true
showEditPermissionsCheckbox: false,
showViewPermissionsCheckbox: false,
Threading: {} Threading: {}
# Internal state # Internal state
...@@ -53,15 +49,13 @@ class Hypothesis extends Annotator ...@@ -53,15 +49,13 @@ class Hypothesis extends Annotator
# Here as a noop just to make the Permissions plugin happy # Here as a noop just to make the Permissions plugin happy
# XXX: Change me when Annotator stops assuming things about viewers # XXX: Change me when Annotator stops assuming things about viewers
editor:
addField: angular.noop
viewer: viewer:
addField: (-> ) addField: angular.noop
this.$inject = [ this.$inject = ['$document', '$window']
'$document', '$location', '$rootScope', '$route', '$window', constructor: ( $document, $window ) ->
]
constructor: (
$document, $location, $rootScope, $route, $window,
) ->
super ($document.find 'body') super ($document.find 'body')
window.annotator = this window.annotator = this
...@@ -79,20 +73,13 @@ class Hypothesis extends Annotator ...@@ -79,20 +73,13 @@ class Hypothesis extends Annotator
# Set up the bridge plugin, which bridges the main annotation methods # Set up the bridge plugin, which bridges the main annotation methods
# between the host page and the panel widget. # between the host page and the panel widget.
whitelist = [ whitelist = ['target', 'document', 'uri']
'diffHTML', 'inject', 'quote', 'ranges', 'target', 'id', 'references',
'uri', 'diffCaseOnly', 'document',
]
this.addPlugin 'Bridge', this.addPlugin 'Bridge',
gateway: true gateway: true
formatter: (annotation) => formatter: (annotation) =>
formatted = {} formatted = {}
for k, v of annotation when k in whitelist for k, v of annotation when k in whitelist
formatted[k] = v formatted[k] = v
if annotation.thread? and annotation.thread?.children.length
formatted.reply_count = annotation.thread.flattenChildren().length
else
formatted.reply_count = 0
formatted formatted
parser: (annotation) => parser: (annotation) =>
parsed = {} parsed = {}
...@@ -141,96 +128,7 @@ class Hypothesis extends Annotator ...@@ -141,96 +128,7 @@ class Hypothesis extends Annotator
unless annotation.highlights? unless annotation.highlights?
annotation.highlights = [] annotation.highlights = []
# Register it with the draft service, except when it's an injection
# This is an injection. Delete the marker.
if annotation.inject
# Set permissions for private
permissions = @plugins.Permissions
userId = permissions.options.userId permissions.user
annotation.permissions =
read: [userId]
admin: [userId]
update: [userId]
delete: [userId]
# Set default owner permissions on all annotations
for event in ['beforeAnnotationCreated', 'beforeAnnotationUpdated']
this.subscribe event, (annotation) =>
permissions = @plugins.Permissions
if permissions.user?
userId = permissions.options.userId(permissions.user)
for action, roles of annotation.permissions
unless userId in roles then roles.push userId
# Track the visible annotations in the root scope
$rootScope.annotations = []
$rootScope.search_annotations = []
$rootScope.focused = []
$rootScope.focus = (annotation,
announceToDoc = false,
announceToCards = false
) =>
unless annotation
console.log "Warning: trying to focus on null annotation"
return
return if annotation in $rootScope.focused
# Put this on the list
$rootScope.focused.push annotation
# Tell the document, if we have to
this._broadcastFocusInfo() if announceToDoc
# Tell to the annotation cards, if we have to
this._scheduleFocusUpdate() if announceToCards
$rootScope.unFocus = (annotation,
announceToDoc = false,
announceToCards = false
) =>
index = $rootScope.focused.indexOf annotation
return if index is -1
# Remove from the list
$rootScope.focused.splice index, 1
# Tell the document, if we have to
this._broadcastFocusInfo() if announceToDoc
# Tell to the annotation cards, if we have to
this._scheduleFocusUpdate() if announceToCards
# Add new annotations to the view when they are created
this.subscribe 'annotationCreated', (a) =>
unless a.references?
$rootScope.annotations.unshift a
# Remove annotations from the application when they are deleted
this.subscribe 'annotationDeleted', (a) =>
$rootScope.annotations = $rootScope.annotations.filter (b) -> b isnt a
$rootScope.search_annotations = $rootScope.search_annotations.filter (b) -> b.message?
_broadcastFocusInfo: ->
$rootScope = @element.injector().get '$rootScope'
for p in @providers
p.channel.notify
method: 'setFocusedHighlights'
params: (a.$$tag for a in $rootScope.focused)
# Schedule the broadcasting of the focusChanged signal
# to annotation cards
_scheduleFocusUpdate: ->
return if @_focusUpdatePending
@_focusUpdatePending = true
$timeout = @element.injector().get('$timeout')
$rootScope = @element.injector().get('$rootScope')
$timeout (=>
# Announce focus changes
$rootScope.$broadcast 'focusChange'
delete @_focusUpdatePending
), 100
_setupXDM: (options) -> _setupXDM: (options) ->
$rootScope = @element.injector().get '$rootScope'
# jschannel chokes FF and Chrome extension origins. # jschannel chokes FF and Chrome extension origins.
if (options.origin.match /^chrome-extension:\/\//) or if (options.origin.match /^chrome-extension:\/\//) or
(options.origin.match /^resource:\/\//) (options.origin.match /^resource:\/\//)
...@@ -250,55 +148,47 @@ class Hypothesis extends Annotator ...@@ -250,55 +148,47 @@ class Hypothesis extends Annotator
.bind('back', => .bind('back', =>
# Navigate "back" out of the interface. # Navigate "back" out of the interface.
$rootScope.$apply => @element.scope().$apply => this.hide()
return unless this.discardDrafts()
this.hide()
) )
.bind('open', => .bind('open', =>
# Pop out the sidebar # Pop out the sidebar
$rootScope.$apply => this.show() @element.scope().$apply => this.show()
) )
.bind('showViewer', (ctx, {view, ids, focused}) => .bind('showEditor', (ctx, tag) =>
ids ?= [] @element.scope().$apply =>
return unless this.discardDrafts() this.showEditor this._getLocalAnnotation(tag)
$rootScope.$apply =>
this.showViewer view, this._getAnnotationsFromIDs(ids), focused
) )
.bind('updateViewer', (ctx, {view, ids, focused}) => .bind('showViewer', (ctx, tags=[]) =>
ids ?= [] @element.scope().$apply =>
$rootScope.$apply => this.showViewer this._getLocalAnnotations(tags)
this.updateViewer view, this._getAnnotationsFromIDs(ids), focused
) )
.bind('toggleViewerSelection', (ctx, {ids, focused}) => .bind('updateViewer', (ctx, tags=[]) =>
$rootScope.$apply => @element.scope().$apply =>
this.toggleViewerSelection this._getAnnotationsFromIDs(ids), focused this.updateViewer this._getLocalAnnotations(tags)
) )
.bind('setTool', (ctx, name) => .bind('toggleViewerSelection', (ctx, tags=[]) =>
$rootScope.$apply => this.setTool name @element.scope().$apply =>
) this.toggleViewerSelection this._getLocalAnnotations(tags)
.bind('setVisibleHighlights', (ctx, state) =>
$rootScope.$apply => this.setVisibleHighlights state
) )
.bind('addEmphasis', (ctx, ids=[]) => .bind('setTool', (ctx, name) =>
this.addEmphasis this._getAnnotationsFromIDs ids @element.scope().$apply => this.setTool name
) )
.bind('removeEmphasis', (ctx, ids=[]) => .bind('setVisibleHighlights', (ctx, state) =>
this.removeEmphasis this._getAnnotationsFromIDs ids @element.scope().$apply => this.setVisibleHighlights state
) )
# Look up an annotation based on the ID # Look up an annotation based on its bridge tag
_getAnnotationFromID: (id) -> @threading.getContainer(id)?.message _getLocalAnnotation: (tag) -> @plugins.Bridge.cache[tag]
# Look up a list of annotations, based on their IDs # Look up a list of annotations, based on their bridge tags
_getAnnotationsFromIDs: (ids) -> this._getAnnotationFromID id for id in ids _getLocalAnnotations: (tags) -> this._getLocalAnnotation t for t in tags
_setupWrapper: -> _setupWrapper: ->
@wrapper = @element.find('#wrapper') @wrapper = @element.find('#wrapper')
...@@ -321,158 +211,63 @@ class Hypothesis extends Annotator ...@@ -321,158 +211,63 @@ class Hypothesis extends Annotator
_setupDocumentAccessStrategies: -> this _setupDocumentAccessStrategies: -> this
_scan: -> this _scan: -> this
# (Optionally) put some HTML formatting around a quote
getHtmlQuote: (quote) -> quote
# Just some debug output
loadAnnotations: (annotations) ->
console.log "Loaded", annotations.length, "annotations."
super
# Do nothing in the app frame, let the host handle it. # Do nothing in the app frame, let the host handle it.
setupAnnotation: (annotation) -> setupAnnotation: (annotation) ->
annotation.highlights = [] annotation.highlights = []
annotation annotation
sortAnnotations: (a, b) -> toggleViewerSelection: (annotations=[]) ->
a_upd = if a.updated? then new Date(a.updated) else new Date() scope = @element.scope()
b_upd = if b.updated? then new Date(b.updated) else new Date() scope.search.query = ''
a_upd.getTime() - b_upd.getTime()
selected = scope.selectedAnnotations or {}
buildReplyList: (annotations=[]) => for a in annotations
$filter = @element.injector().get '$filter' if selected[a.id]
for annotation in annotations delete selected[a.id]
if annotation? else
thread = @threading.getContainer annotation.id selected[a.id] = true
children = (r.message for r in (thread.children or []))
annotation.reply_list = children.sort(@sortAnnotations).reverse() count = Object.keys(selected).length
@buildReplyList children scope.selectedAnnotationsCount = count
toggleViewerSelection: (annotations=[], focused) => if count
annotations = annotations.filter (a) -> a? scope.selectedAnnotations = selected
@element.injector().invoke [ else
'$rootScope', scope.selectedAnnotations = null
($rootScope) =>
if $rootScope.viewState.view is "Selection"
# We are already in selection mode; just XOR this list
# to the current selection
@buildReplyList annotations
list = $rootScope.annotations
for a in annotations
index = list.indexOf a
if index isnt -1
list.splice index, 1
$rootScope.unFocus a, true, true
else
list.push a
if focused
$rootScope.focus a, true, true
else
# We are not in selection mode,
# so we switch to it, and make this list
# the new selection
$rootScope.viewState.view = "Selection"
$rootScope.annotations = annotations
]
this
updateViewer: (viewName, annotations=[], focused = false) =>
annotations = annotations.filter (a) -> a?
@element.injector().invoke [
'$rootScope',
($rootScope) =>
@buildReplyList annotations
# Do we have to replace the focused list with this?
if focused
# Nuke the old focus list
$rootScope.focused = []
# Add the new elements
for a in annotations
$rootScope.focus a, true, true
else
# Go over the old list, and unfocus the ones
# that are not on this list
for a in $rootScope.focused.slice() when a not in annotations
$rootScope.unFocus a, true, true
# Update the main annotations list
$rootScope.annotations = annotations
unless $rootScope.viewState.view is viewName
# We are changing the view
$rootScope.viewState.view = viewName
$rootScope.showViewSort true, true
]
this this
showViewer: (viewName, annotations=[], focused = false) => updateViewer: (annotations=[]) ->
this.show() # TODO: re-implement
@element.injector().invoke [ this
'$location',
($location) =>
$location.path('/viewer').replace()
]
this.updateViewer viewName, annotations, focused
addEmphasis: (annotations=[]) =>
annotations = annotations.filter (a) -> a? # Filter out null annotations
for a in annotations
a.$emphasis = true
@element.injector().get('$rootScope').$digest()
removeEmphasis: (annotations=[]) => showViewer: (annotations=[]) ->
annotations = annotations.filter (a) -> a? # Filter out null annotations scope = @element.scope()
scope.search.query = ''
selected = {}
for a in annotations for a in annotations
delete a.$emphasis selected[a.id] = true
@element.injector().get('$rootScope').$digest() scope.selectedAnnotations = selected
scope.selectedAnnotationsCount = Object.keys(selected).length
clickAdder: => this.show()
for p in @providers this
p.channel.notify
method: 'adderClick'
showEditor: (annotation) => showEditor: (annotation) ->
delete @element.scope().selectedAnnotations
this.show() this.show()
@element.injector().invoke [
'$location', '$rootScope', 'drafts', 'identity',
($location, $rootScope, drafts, identity) =>
@ongoing_edit = annotation
unless this.plugins.Auth? and this.plugins.Auth.haveValidToken()
$rootScope.$apply ->
identity.request()
for p in @providers
p.channel.notify method: 'onEditorHide'
return
# Set the path
search =
id: annotation.id
action: 'create'
$location.path('/editor').search(search)
# Store the draft
drafts.add annotation
# Digest the change
$rootScope.$digest()
]
this this
show: => show: ->
@element.scope().frame.visible = true @host.notify method: 'showFrame'
hide: => hide: ->
@element.scope().frame.visible = false @host.notify method: 'hideFrame'
isOpen: => digest: ->
@element.scope().frame.visible @element.scope().$evalAsync angular.noop
patch_store: -> patch_store: ->
$location = @element.injector().get '$location' scope = @element.scope()
$rootScope = @element.injector().get '$rootScope'
Store = Annotator.Plugin.Store Store = Annotator.Plugin.Store
# When the Store plugin is first instantiated, don't load annotations. # When the Store plugin is first instantiated, don't load annotations.
...@@ -498,36 +293,26 @@ class Hypothesis extends Annotator ...@@ -498,36 +293,26 @@ class Hypothesis extends Annotator
# if the annotation has a newly-assigned id and ensures that the id # if the annotation has a newly-assigned id and ensures that the id
# is enumerable. # is enumerable.
Store.prototype.updateAnnotation = (annotation, data) => Store.prototype.updateAnnotation = (annotation, data) =>
unless Object.keys(data).length
return
if annotation.id? and annotation.id != data.id
# Update the id table for the threading
thread = @threading.getContainer annotation.id
thread.id = data.id
@threading.idTable[data.id] = thread
delete @threading.idTable[annotation.id]
# The id is no longer temporary and should be serialized
# on future Store requests.
Object.defineProperty annotation, 'id',
configurable: true
enumerable: true
writable: true
# If the annotation is loaded in a view, switch the view
# to reference the new id.
search = $location.search()
if search? and search.id == annotation.id
search.id = data.id
$location.search(search).replace()
# Update the annotation with the new data # Update the annotation with the new data
annotation = angular.extend annotation, data annotation = angular.extend annotation, data
@plugins.Bridge?.updateAnnotation annotation
# Give angular a chance to react # Update the thread table
$rootScope.$digest() update = (parent) ->
for child in parent.children when child.message is annotation
scope.threading.idTable[data.id] = child
return true
return false
# Check its references
references = annotation.references or []
if typeof(annotation.references) == 'string' then references = []
for ref in references.slice().reverse()
container = scope.threading.idTable[ref]
continue unless container?
break if update container
# Check the root
update scope.threading.root
considerSocialView: (query) -> considerSocialView: (query) ->
switch @socialView.name switch @socialView.name
...@@ -545,31 +330,10 @@ class Hypothesis extends Annotator ...@@ -545,31 +330,10 @@ class Hypothesis extends Annotator
else else
console.warn "Unsupported Social View: '" + @socialView.name + "'!" console.warn "Unsupported Social View: '" + @socialView.name + "'!"
# Bubbles updates through the thread so that guests see accurate setTool: (name) ->
# reply counts.
updateAncestors: (annotation) =>
for ref in (annotation.references?.slice().reverse() or [])
rel = (@threading.getContainer ref).message
if rel?
$timeout = @element.injector().get('$timeout')
$timeout (=> @plugins.Bridge.updateAnnotation rel), 10
this.updateAncestors(rel)
break # Only the nearest existing ancestor, the rest is by induction.
setTool: (name) =>
return if name is @tool return if name is @tool
return unless this.discardDrafts()
if name is 'highlight' if name is 'highlight'
# Check login state first
unless @plugins.Permissions?.user
scope = @element.scope()
# If we are not logged in, start the auth process
scope.ongoingHighlightSwitch = true
@element.injector().get('identity').request()
this.show()
return
this.socialView.name = 'single-player' this.socialView.name = 'single-player'
else else
this.socialView.name = 'none' this.socialView.name = 'none'
...@@ -581,7 +345,7 @@ class Hypothesis extends Annotator ...@@ -581,7 +345,7 @@ class Hypothesis extends Annotator
method: 'setTool' method: 'setTool'
params: name params: name
setVisibleHighlights: (state) => setVisibleHighlights: (state) ->
return if state is @visibleHighlights return if state is @visibleHighlights
@visibleHighlights = state @visibleHighlights = state
this.publish 'setVisibleHighlights', state this.publish 'setVisibleHighlights', state
...@@ -590,61 +354,49 @@ class Hypothesis extends Annotator ...@@ -590,61 +354,49 @@ class Hypothesis extends Annotator
method: 'setVisibleHighlights' method: 'setVisibleHighlights'
params: state params: state
# Is this annotation a comment?
isComment: (annotation) ->
# No targets and no references means that this is a comment
not (annotation.references?.length or annotation.target?.length)
# Is this annotation a reply?
isReply: (annotation) ->
# The presence of references means that this is a reply
annotation.references?.length
# Discard all drafts, deleting unsaved annotations from the annotator
discardDrafts: ->
return @element.injector().get('drafts').discard()
class DraftProvider class DraftProvider
drafts: null _drafts: null
constructor: -> constructor: ->
this.drafts = [] @_drafts = []
$get: -> this $get: -> this
add: (draft, cb) -> @drafts.push {draft, cb} all: -> draft for {draft} in @_drafts
add: (draft, cb) -> @_drafts.push {draft, cb}
remove: (draft) -> remove: (draft) ->
remove = [] remove = []
for d, i in @drafts for d, i in @_drafts
remove.push i if d.draft is draft remove.push i if d.draft is draft
while remove.length while remove.length
@drafts.splice(remove.pop(), 1) @_drafts.splice(remove.pop(), 1)
contains: (draft) -> contains: (draft) ->
for d in @drafts for d in @_drafts
if d.draft is draft then return true if d.draft is draft then return true
return false return false
isEmpty: -> @drafts.length is 0 isEmpty: -> @_drafts.length is 0
discard: -> discard: ->
text = text =
switch @drafts.length switch @_drafts.length
when 0 then null when 0 then null
when 1 when 1
"""You have an unsaved reply. """You have an unsaved reply.
Do you really want to discard this draft?""" Do you really want to discard this draft?"""
else else
"""You have #{@drafts.length} unsaved replies. """You have #{@_drafts.length} unsaved replies.
Do you really want to discard these drafts?""" Do you really want to discard these drafts?"""
if @drafts.length is 0 or confirm text if @_drafts.length is 0 or confirm text
discarded = @drafts.slice() discarded = @_drafts.slice()
@drafts = [] @_drafts = []
d.cb?() for d in discarded d.cb?() for d in discarded
true true
else else
...@@ -689,12 +441,6 @@ class ViewFilter ...@@ -689,12 +441,6 @@ class ViewFilter
any: any:
fields: ['quote', 'text', 'tag', 'user'] fields: ['quote', 'text', 'tag', 'user']
this.$inject = ['searchfilter']
constructor: (searchfilter) ->
@searchfilter = searchfilter
_matches: (filter, value, match) -> _matches: (filter, value, match) ->
matches = true matches = true
...@@ -737,17 +483,17 @@ class ViewFilter ...@@ -737,17 +483,17 @@ class ViewFilter
value = checker.value annotation value = checker.value annotation
if angular.isArray value if angular.isArray value
if filter.lowercase then value = value.map (e) -> e.toLowerCase() if typeof(value[0]) == 'string'
value = value.map (v) -> v.toLowerCase()
return @_arrayMatches filter, value, checker.match return @_arrayMatches filter, value, checker.match
else else
value = value.toLowerCase() if filter.lowercase value = value.toLowerCase() if typeof(value) == 'string'
return @_matches filter, value, checker.match return @_matches filter, value, checker.match
# Filters a set of annotations, according to a given query. # Filters a set of annotations, according to a given query.
# Inputs: # Inputs:
# annotations is the input list of annotations (array) # annotations is the input list of annotations (array)
# query is the query string. It will be converted to faceted filter by the SearchFilter # filters is the query is a faceted filter generated by SearchFilter
# #
# It'll handle the annotation matching by the returned facet configuration (operator, lowercase, etc.) # It'll handle the annotation matching by the returned facet configuration (operator, lowercase, etc.)
# and the here configured @checkers. This @checkers object contains instructions how to verify the match. # and the here configured @checkers. This @checkers object contains instructions how to verify the match.
...@@ -765,84 +511,35 @@ class ViewFilter ...@@ -765,84 +511,35 @@ class ViewFilter
# matched annotation IDs list, # matched annotation IDs list,
# the faceted filters # the faceted filters
# ] # ]
filter: (annotations, query) => filter: (annotations, filters) ->
filters = @searchfilter.generateFacetedFilter query limit = Math.min((filters.result?.terms or [])...)
results = [] count = 0
# Check for given limit results = for annotation in annotations
# Find the minimal break if count >= limit
limit = 0
if filters.result.terms.length
limit = filter.result.terms[0]
for term in filter.result.terms
if limit > term then limit = term
# Convert terms to lowercase if needed
for _, filter of filters
if filter.lowercase then filter.terms.map (e) -> e.toLowerCase()
# Now that this filter is called with the top level annotations, we have to add the children too
annotationsWithChildren = []
for annotation in annotations
annotationsWithChildren.push annotation
children = annotation.thread?.flattenChildren()
if children?.length > 0
for child in children
annotationsWithChildren.push child
for annotation in annotationsWithChildren
matches = true
#ToDo: What about given zero limit?
# Limit reached
if limit and results.length >= limit then break
match = true
for category, filter of filters for category, filter of filters
break unless matches break unless match
terms = filter.terms continue unless filter.terms.length
# No condition for this category
continue unless terms.length
switch category switch category
when 'result'
# Handled above
continue
when 'any' when 'any'
# Special case categoryMatch = false
matchterms = []
matchterms.push false for term in terms
for field in @checkers.any.fields for field in @checkers.any.fields
conf = @checkers[field] if @_checkMatch(filter, annotation, @checkers[field])
categoryMatch = true
continue if conf.autofalse? and conf.autofalse annotation break
value = conf.value annotation match = categoryMatch
if angular.isArray value
if filter.lowercase
value = value.map (e) -> e.toLowerCase()
else
value = value.toLowerCase() if filter.lowercase
matchresult = @_anyMatches filter, value, conf.match
matchterms = matchterms.map (t, i) -> t or matchresult[i]
# Now let's see what we got.
matched = 0
for _, value of matchterms
matched++ if value
if (filter.operator is 'or' and matched > 0) or (filter.operator is 'and' and matched is terms.length)
matches = true
else
matches = false
else else
# For all other categories match = @_checkMatch filter, annotation, @checkers[category]
matches = @_checkMatch filter, annotation, @checkers[category]
if matches
results.push annotation.id
[results, filters]
continue unless match
count++
annotation.id
angular.module('h.services', imports) angular.module('h.services', [])
.factory('render', renderFactory)
.provider('drafts', DraftProvider) .provider('drafts', DraftProvider)
.service('annotator', Hypothesis) .service('annotator', Hypothesis)
.service('viewFilter', ViewFilter) .service('viewFilter', ViewFilter)
...@@ -9,13 +9,18 @@ imports = [ ...@@ -9,13 +9,18 @@ imports = [
class StreamSearch class StreamSearch
this.inject = [ this.inject = [
'$scope', '$rootScope', '$scope', '$rootScope', '$routeParams',
'queryparser', 'searchfilter', 'streamfilter' 'annotator', 'queryparser', 'searchfilter', 'streamfilter'
] ]
constructor: ( constructor: (
$scope, $rootScope, $scope, $rootScope, $routeParams
queryparser, searchfilter, streamfilter annotator, queryparser, searchfilter, streamfilter
) -> ) ->
# Clear out loaded annotations and threads
# XXX: Resolve threading, storage, and updater better for all routes.
annotator.plugins.Threading?.pluginInit()
annotator.plugins.Store?.annotations = []
# Initialize the base filter # Initialize the base filter
streamfilter streamfilter
.resetFilter() .resetFilter()
...@@ -23,18 +28,21 @@ class StreamSearch ...@@ -23,18 +28,21 @@ class StreamSearch
.setPastDataHits(50) .setPastDataHits(50)
# Apply query clauses # Apply query clauses
$scope.search.query = $routeParams.q
terms = searchfilter.generateFacetedFilter $scope.search.query terms = searchfilter.generateFacetedFilter $scope.search.query
queryparser.populateFilter streamfilter, terms queryparser.populateFilter streamfilter, terms
$scope.updater?.then (sock) -> $scope.isEmbedded = false
filter = streamfilter.getFilter() $scope.isStream = true
sock.send(JSON.stringify({filter}))
$scope.sort.name = 'Newest'
$rootScope.annotations = [] $scope.shouldShowThread = (container) -> true
$rootScope.applyView "Document" # Non-sensical, but best for the moment
$rootScope.applySort "Newest"
$scope.openDetails = (annotation) -> $scope.$watch 'updater', (updater) ->
updater?.then (sock) ->
filter = streamfilter.getFilter()
sock.send(JSON.stringify({filter}))
angular.module('h.streamsearch', imports, configure) angular.module('h.streamsearch', imports, configure)
......
// http://my.opera.com/emoller/blog/2011/12/20/requestanimationframe-for-smart-er-animating
(function() {
var pfx = ['ms', 'moz', 'webkit', 'o'];
for (var i = 0; i < pfx.length && !window.requestAnimationFrame ; i++) {
requestAnimationFrame = window[pfx[i]+'RequestAnimationFrame'];
cancelAnimationFrame = window[pfx[i]+'CancelRequestAnimationFrame'];
if (!cancelAnimationFrame) {
cancelAnimationFrame = window[pfx[i]+'CancelAnimationFrame'];
}
window.requestAnimationFrame = requestAnimationFrame;
window.cancelAnimationFrame = cancelAnimationFrame;
}
if (!window.requestAnimationFrame)
var lastTime = 0;
window.requestAnimationFrame = function(callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function() {
callback(currTime + timeToCall);
}, timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function(id) {
clearTimeout(id);
};
}())
//ANNOTATION//////////////////////////////// //ANNOTATION////////////////////////////////
//This is for everything that is formatted as an annotation. //This is for everything that is formatted as an annotation.
.annotation { .annotation {
@include pie-clearfix;
font-family: $sans-font-family; font-family: $sans-font-family;
font-weight: 300; font-weight: 300;
position: relative; position: relative;
&:hover fuzzytime a, &:hover .reply-count { &:hover .annotation-timestamp, &:hover .reply-count {
color: $link-color; color: $link-color;
} }
.magicontrol.dropdown { .annotation-timestamp {
top: 4px;
}
fuzzytime {
line-height: 2; line-height: 2;
a {
color: $text-color;
&:hover { color: $link-color-hover; }
&:focus { outline: 0; }
}
}
.reply-count {
color: $text-color; color: $text-color;
&:hover { color: $link-color-hover; } &:hover { color: $link-color-hover; }
&:focus { outline: 0; } &:focus { outline: 0; }
} }
.annotation-header { margin-bottom: .8em }
.annotation-section { margin: .8em 0 }
.annotation-footer { margin-top: .8em }
.user { .user {
font-weight: bold; font-weight: bold;
font-size: 1.1em; font-size: 1.1em;
...@@ -39,80 +29,37 @@ ...@@ -39,80 +29,37 @@
text-decoration: underline; text-decoration: underline;
} }
} }
.buttonbar {
@include pie-clearfix;
margin: .25em 0;
.btn {
margin-right: .5em;
}
}
.tip {
@extend .small;
float: right;
}
.quote {
font-style: italic
}
}
// Temporary hack until sidebar and displayer are unified
.annotation-displayer {
.user {
&:hover {
color: $link-color-hover;
cursor: pointer;
text-decoration: underline;
}
}
}
//EXCERPT////////////////////////////////
.excerpt {
margin-bottom: 1em;
position: relative;
.more, .less {
font-size: .9em;
font-family: $sans-font-family;
font-weight: 300;
display: block;
text-align: right;
}
} }
.annotation-quote { .annotation-quote {
@include quote; @include quote;
} }
.annotation-citation-domain {
color: $gray-light;
font-size: .923em;
}
.icon-markdown { .icon-markdown {
color: $text-color; color: $text-color;
position: absolute; line-height: 1.4;
left: 9em; margin-left: .5em;
line-height: 1.5;
top: 0;
} }
//MAGICONTROL////////////////////////////////
.magicontrol { .magicontrol {
@include transition(opacity); margin-right: .8em;
@include transition-duration(.15s); color: $gray-lighter;
margin: 0 .4em;
opacity: 0;
&.open, :hover > & { &.dropdown {
@include transition-duration(.15s); top: 4px;
opacity: 1; }
.annotation:hover & {
color: $link-color;
} }
} }
.share-dialog { .share-dialog {
display: none;
a { a {
float: left; float: left;
line-height: 1.4; line-height: 1.4;
...@@ -129,13 +76,3 @@ ...@@ -129,13 +76,3 @@
width: 100%; width: 100%;
} }
} }
.stream-list {
& > * {
margin-bottom: .72em;
}
.card-emphasis {
@include box-shadow(6px 6px 8px -2px $gray-light);
}
}
...@@ -150,6 +150,7 @@ $input-border-radius: 2px; ...@@ -150,6 +150,7 @@ $input-border-radius: 2px;
@mixin quote { @mixin quote {
color: $gray; color: $gray;
font-family: $serif-font-family; font-family: $serif-font-family;
font-style: italic;
padding: 0 .615em; padding: 0 .615em;
border-left: 3px solid $gray-lighter; border-left: 3px solid $gray-lighter;
} }
......
...@@ -337,7 +337,6 @@ html { ...@@ -337,7 +337,6 @@ html {
} }
} }
//SEARCH HIGHLIGHTS//////////////////////////////// //SEARCH HIGHLIGHTS////////////////////////////////
.search-hl-active { .search-hl-active {
background: $highlight-color; background: $highlight-color;
...@@ -355,44 +354,3 @@ html { ...@@ -355,44 +354,3 @@ html {
box-shadow:3px 3px 4px #999999; box-shadow:3px 3px 4px #999999;
} }
} }
// View and Sort tabs ////////////////////
.viewsort {
@include single-transition(top, .25s);
@include transition-timing-function(cubic-bezier(0, 1, .55, 1));
width: 100%;
text-align: center;
position: fixed;
top: 29px;
right: 0;
z-index: 4;
&.ng-hide {
@include transition-timing-function(cubic-bezier(1, .55, 0, 1));
top: 0;
display:block!important;
overflow: hidden;
}
}
.viewsort > .dropdown {
@include smallshadow(0);
border-bottom-right-radius: 24px 72px;
border-bottom-left-radius: 24px 72px;
font-family: $sans-font-family;
font-weight: 300;
background: $white;
border: solid 1px $gray-lighter;
padding: 0 10px;
margin: 0 3px;
display: inline-block;
.dropdown-menu {
margin-top: 0;
&:before, &:after {
display: none;
}
}
}
@import 'base';
$thread-padding: 1em; $thread-padding: 1em;
$threadexp-width: .6em; $threadexp-width: .6em;
.stream-list {
& > * {
margin-bottom: .72em;
}
.card-emphasis {
box-shadow: 6px 6px 8px -2px $gray-light;
}
}
.thread-list {
margin-top: 0.5em;
}
.thread { .thread {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
& > * {
@include pie-clearfix;
}
& > ul { & > ul {
padding-left: $thread-padding + .15em; padding-left: $thread-padding + .15em;
margin-left: -$thread-padding; margin-left: -$thread-padding;
} }
.load-more { .reply-count {
@include pie-clearfix; color: $text-color;
font-family: $sans-font-family; &:focus { outline: 0; }
font-weight:bold; }
font-size: .8em;
&:hover .reply-count {
color: $link-color;
&:hover, &:focus {
color: $link-color-hover;
}
} }
.thread { .thread {
border-left: 1px dotted $gray-light; border-left: 1px dotted $gray-light;
height: 100%;
padding: 0; padding: 0;
padding-left: $thread-padding; padding-left: $thread-padding;
&.collapsed { &.thread-collapsed {
border-color: transparent; border-color: transparent;
& > .annotation { & > article markdown {
.body { display: none;
display: none;
}
.magicontrol {
display: none;
}
.reply-count {
font-style: italic;
}
} }
} }
} }
...@@ -73,43 +76,32 @@ $threadexp-width: .6em; ...@@ -73,43 +76,32 @@ $threadexp-width: .6em;
} }
} }
.annotation { &.thread-collapsed {
&.squished {
padding-left: 0;
}
}
&.collapsed {
&:hover { &:hover {
background-color: $gray-lightest; background-color: $gray-lightest;
} }
& > ul { & > ul {
max-height: 0; display: none;
overflow: hidden;
} }
& > .annotation { & > .thread-message {
markdown > div > * { .styled-text > * { display: none }
display: none; .styled-text *:first-child {
}
markdown > div > *:first-child {
display: block; display: block;
margin: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
-o-text-overflow: ellipsis; -o-text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.indicators { .thread &, .thread & header, .thread & section { margin: 0 }
margin-right: .25em; .thread & footer { display: none }
}
} }
} }
} }
.annotation-citation-domain { .thread-message {
color: $gray-light; margin-bottom: .8em;
font-size: .923em;
} }
...@@ -40,7 +40,6 @@ module.exports = function(config) { ...@@ -40,7 +40,6 @@ module.exports = function(config) {
'h/static/scripts/vendor/moment-timezone.js', 'h/static/scripts/vendor/moment-timezone.js',
'h/static/scripts/vendor/moment-timezone-data.js', 'h/static/scripts/vendor/moment-timezone-data.js',
'h/static/scripts/vendor/Markdown.Converter.js', 'h/static/scripts/vendor/Markdown.Converter.js',
'h/static/scripts/vendor/polyfills/raf.js',
'h/static/scripts/vendor/sockjs-0.3.4.js', 'h/static/scripts/vendor/sockjs-0.3.4.js',
'h/static/scripts/vendor/uuid.js', 'h/static/scripts/vendor/uuid.js',
'h/static/scripts/hypothesis-auth.js', 'h/static/scripts/hypothesis-auth.js',
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
"karma-ng-html2js-preprocessor": "^0.1.0", "karma-ng-html2js-preprocessor": "^0.1.0",
"karma-phantomjs-launcher": "^0.1.4", "karma-phantomjs-launcher": "^0.1.4",
"mocha": "^1.20.1", "mocha": "^1.20.1",
"phantomjs": "1.9.7-10" "phantomjs": "^1.9.7"
}, },
"engines": { "engines": {
"node": "0.10.x" "node": "0.10.x"
......
assert = chai.assert assert = chai.assert
sandbox = sinon.sandbox.create()
describe 'h.directives.annotation', -> describe 'h.directives.annotation', ->
$scope = null $scope = null
annotator = null
annotation = null annotation = null
createController = null createController = null
flash = null
beforeEach module('h.directives.annotation') beforeEach module('h.directives')
beforeEach inject ($controller, $rootScope) -> beforeEach inject ($controller, $rootScope) ->
$scope = $rootScope.$new() $scope = $rootScope.$new()
$scope.model = $scope.annotationGet = (locals) -> annotation
annotator = {plugins: {}, publish: sandbox.spy()}
annotation =
id: 'deadbeef'
document: document:
title: 'A special document' title: 'A special document'
target: [{}] target: [{}]
uri: 'http://example.com' uri: 'http://example.com'
flash = sinon.spy()
createController = -> createController = ->
$controller 'AnnotationController', $controller 'AnnotationController',
$scope: $scope $scope: $scope
$element: null annotator: annotator
$location: {} flash: flash
$rootScope: $rootScope
$sce: null afterEach ->
$timeout: sinon.spy() sandbox.restore()
$window: {}
annotator: {plugins: {}}
documentHelpers: {baseURI: '', absoluteURI: sinon.spy()}
drafts: null
it 'provides a document title', -> it 'provides a document title', ->
controller = createController() controller = createController()
assert.equal($scope.document.title, 'A special document') $scope.$digest()
assert.equal(controller.document.title, 'A special document')
it 'truncates long titles', -> it 'truncates long titles', ->
$scope.model.document.title = '''A very very very long title that really annotation.document.title = '''A very very very long title that really
shouldn't be found on a page on the internet.''' shouldn't be found on a page on the internet.'''
controller = createController() controller = createController()
assert.equal($scope.document.title, 'A very very very long title th…') $scope.$digest()
assert.equal(controller.document.title, 'A very very very long title th…')
it 'provides a document uri', -> it 'provides a document uri', ->
controller = createController() controller = createController()
assert.equal($scope.document.uri, 'http://example.com') $scope.$digest()
assert.equal(controller.document.uri, 'http://example.com')
it 'provides an extracted domain from the uri', -> it 'provides an extracted domain from the uri', ->
controller = createController() controller = createController()
assert.equal($scope.document.domain, 'example.com') $scope.$digest()
assert.equal(controller.document.domain, 'example.com')
it 'uses the domain for the title if the title is not present', -> it 'uses the domain for the title if the title is not present', ->
delete $scope.model.document.title delete annotation.document.title
controller = createController() controller = createController()
assert.equal($scope.document.title, 'example.com') $scope.$digest()
assert.equal(controller.document.title, 'example.com')
it 'skips the document object if no document is present on the annotation', -> it 'skips the document object if no document is present on the annotation', ->
delete $scope.model.document delete annotation.document
controller = createController() controller = createController()
assert.isUndefined($scope.document) $scope.$digest()
assert.isNull(controller.document)
it 'skips the document object if the annotation has no targets', -> it 'skips the document object if the annotation has no targets', ->
$scope.model.target = [] annotation.target = []
controller = createController() controller = createController()
assert.isUndefined($scope.document) $scope.$digest()
assert.isNull(controller.document)
describe '#reply', ->
controller = null
container = null
beforeEach ->
controller = createController()
it 'creates a new reply with the proper uri and references', ->
controller.reply()
match = sinon.match {references: [annotation.id], uri: annotation.uri}
assert.calledWith(annotator.publish, 'beforeAnnotationCreated', match)
describe '#toggleShared', ->
it 'sets the shared property', ->
controller = createController()
before = controller.shared
controller.toggleShared()
after = controller.shared
assert.equal(before, !after)
...@@ -4,6 +4,7 @@ describe 'h.directives', -> ...@@ -4,6 +4,7 @@ describe 'h.directives', ->
$scope = null $scope = null
$compile = null $compile = null
fakeWindow = null fakeWindow = null
isolate = null
beforeEach module('h.directives') beforeEach module('h.directives')
...@@ -20,23 +21,24 @@ describe 'h.directives', -> ...@@ -20,23 +21,24 @@ describe 'h.directives', ->
template= ''' template= '''
<div class="simpleSearch" <div class="simpleSearch"
query="query" query="query"
onsearch="update(this)" on-search="update(query)"
onclear="clear()"> on-clear="clear()">
</div> </div>
''' '''
$element = $compile(angular.element(template))($scope) $element = $compile(angular.element(template))($scope)
$scope.$digest() $scope.$digest()
isolate = $element.isolateScope()
it 'updates the search-bar', -> it 'updates the search-bar', ->
$scope.query = "Test query" $scope.query = "Test query"
$scope.$digest() $scope.$digest()
assert.equal($scope.searchtext, $scope.query) assert.equal(isolate.searchtext, $scope.query)
it 'calls the given search function', -> it 'calls the given search function', ->
$scope.query = "Test query" isolate.searchtext = "Test query"
$scope.$digest() isolate.$digest()
$element.triggerHandler('submit') $element.find('form').triggerHandler('submit')
sinon.assert.calledWith($scope.update, "Test query") sinon.assert.calledWith($scope.update, "Test query")
it 'calls the given clear function', -> it 'calls the given clear function', ->
...@@ -44,10 +46,18 @@ describe 'h.directives', -> ...@@ -44,10 +46,18 @@ describe 'h.directives', ->
assert($scope.clear.called) assert($scope.clear.called)
it 'clears the search-bar', -> it 'clears the search-bar', ->
isolate.searchtext = "Test query"
isolate.$digest()
$element.find('.simple-search-clear').click()
assert.equal(isolate.searchtext, '')
it 'invokes callbacks when the input model changes', ->
$scope.query = "Test query" $scope.query = "Test query"
$scope.$digest() $scope.$digest()
$element.find('.simple-search-clear').click() sinon.assert.calledOnce($scope.update)
assert.equal($scope.searchtext, '') $scope.query = ""
$scope.$digest()
sinon.assert.calledOnce($scope.clear)
it 'adds a class to the form when there is no input value', -> it 'adds a class to the form when there is no input value', ->
$form = $element.find('.simple-search-form') $form = $element.find('.simple-search-form')
......
assert = chai.assert
describe 'h.directives.thread', ->
$attrs = null
$scope = null
$element = null
container = null
createController = null
flash = null
beforeEach module('h.directives')
beforeEach inject ($controller, $rootScope) ->
$scope = $rootScope.$new()
flash = sinon.spy()
createController = ->
controller = $controller 'ThreadController'
controller
describe '#toggleCollapsed', ->
it 'sets the collapsed property', ->
controller = createController()
before = controller.collapsed
controller.toggleCollapsed()
after = controller.collapsed
assert.equal(before, !after)
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