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 = [
'h.controllers'
'h.controllers.AccountManagement'
'h.directives'
'h.directives.annotation'
'h.filters'
'h.identity'
'h.streamsearch'
......@@ -14,39 +13,17 @@ imports = [
configure = [
'$locationProvider', '$provide', '$routeProvider', '$sceDelegateProvider',
($locationProvider, $provide, $routeProvider, $sceDelegateProvider) ->
'$locationProvider', '$routeProvider', '$sceDelegateProvider',
($locationProvider, $routeProvider, $sceDelegateProvider) ->
$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',
controller: 'AnnotationViewerController'
templateUrl: 'viewer.html'
$routeProvider.when '/editor',
controller: 'EditorController'
templateUrl: 'editor.html'
$routeProvider.when '/viewer',
controller: 'ViewerController'
templateUrl: 'viewer.html'
$routeProvider.when '/page_search',
controller: 'SearchController'
templateUrl: 'page_search.html'
reloadOnSearch: false
$routeProvider.when '/stream',
controller: 'StreamSearchController'
templateUrl: 'viewer.html'
......
......@@ -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
this.$inject = [
'$element', '$location', '$q', '$rootScope', '$route', '$scope', '$timeout',
'annotator', 'flash', 'identity', 'queryparser', 'socket',
'streamfilter', 'viewFilter', 'documentHelpers'
'$location', '$q', '$route', '$scope', '$timeout',
'annotator', 'flash', 'identity', 'socket', 'streamfilter',
'documentHelpers', 'drafts'
]
constructor: (
$element, $location, $q, $rootScope, $route, $scope, $timeout
annotator, flash, identity, queryparser, socket,
streamfilter, viewFilter, documentHelpers
$location, $q, $route, $scope, $timeout
annotator, flash, identity, socket, streamfilter,
documentHelpers, drafts
) ->
{plugins, host, providers} = annotator
......@@ -37,71 +63,15 @@ class App
if action == 'past'
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
when 'create'
# Sorting the data for updates.
# 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 'create', 'update'
plugins.Store?._onLoadAnnotations data
when 'delete'
for annotation in data
# Remove it from the rootScope too
for ann, index in $rootScope.annotations
if ann.id is annotation.id
$rootScope.annotations.splice(index, 1)
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
annotation = plugins.Threading.idTable[annotation.id]?.message
continue unless annotation?
plugins.Store?.unregisterAnnotation(annotation)
annotator.deleteAnnotation(annotation)
initIdentity = (persona) ->
"""Initialize identity callbacks."""
......@@ -121,7 +91,7 @@ class App
onlogin(assertion)
onlogout: ->
onlogout()
else if annotator.discardDrafts()
else if drafts.discard()
if claimedUser
identity.request()
else
......@@ -130,30 +100,22 @@ class App
initStore = ->
"""Initialize the storage component."""
Store = plugins.Store
delete plugins.Store
annotator.addPlugin 'Store', annotator.options.Store
annotator.threading.thread []
annotator.threading.idTable = {}
if $scope.persona or annotator.socialView.name is 'none'
annotator.addPlugin 'Store', annotator.options.Store
$scope.$root.annotations = []
$scope.store = plugins.Store
$scope.store = plugins.Store
_id = $route.current.params.id
_promise = null
_id = $route.current.params.id
_promise = null
# Load any initial annotations that should be displayed
if _id
# XXX: Two requests here is less than ideal
_promise = plugins.Store.loadAnnotationsFromSearch({_id})
.then ->
# Load any initial annotations that should be displayed
if _id
# XXX: Two requests here is less than ideal
plugins.Store.loadAnnotationsFromSearch({_id}).then ->
plugins.Store.loadAnnotationsFromSearch({references: _id})
$q.when _promise, ->
thread = annotator.threading.getContainer _id
$scope.$root.annotations = [thread.message]
return unless Store
Store.destroy()
......@@ -172,36 +134,33 @@ class App
Store._onLoadAnnotations = angular.noop
# * Make the update function into a noop.
Store.updateAnnotation = angular.noop
# * Remove the plugin and re-add it to the annotator.
# Even though most operations on the old Store are now noops the Annotator
# itself may still be setting up previously fetched annotatiosn. We may
# delete annotations which are later set up in the DOM again, causing
# issues with the viewer and heatmap. As these are loaded, we can delete
# them, but the threading plugin will get confused and break threading.
# Here, we cleanup these annotations as they are set up by the Annotator,
# preserving the existing threading. This is all a bit paranoid, but
# important when many annotations are loading as authentication is
# changing. It's all so ugly it makes me cry, though. Someone help
# restore sanity?
annotations = Store.annotations.slice()
cleanup = (loaded) ->
$rootScope.$evalAsync -> # Let plugins react
deleted = []
for l in loaded
if l in annotations
# If this annotation still exists, we'll need to thread it again
# since the delete will mangle the threading data structures.
existing = annotator.threading.idTable[l.id]?.message
annotator.deleteAnnotation(l)
deleted.push l
if existing
plugins.Threading.thread existing
annotations = (a for a in annotations when a not in deleted)
if annotations.length is 0
annotator.unsubscribe 'annotationsLoaded', cleanup
cleanup (a for a in annotations when a.thread)
annotator.subscribe 'annotationsLoaded', cleanup
# Sort out which annotations should remain in place.
user = $scope.persona
view = annotator.socialView.name
cull = (acc, annotation) ->
if view is 'single-player' and annotation.user != user
acc.drop.push annotation
else if authorizeAction 'read', annotation, user
acc.keep.push annotation
else
acc.drop.push annotation
acc
{keep, drop} = Store.annotations.reduce cull, {keep: [], drop: []}
Store.annotations = []
if plugins.Store?
plugins.Store.annotations = keep
else
drop = drop.concat keep
# Clean up the ones that should be removed.
do cleanup = (drop) ->
return if drop.length == 0
[first, rest...] = drop
annotator.deleteAnnotation first
$timeout -> cleanup rest
initUpdater = (failureCount=0) ->
"""Initialize the websocket used for realtime updates."""
......@@ -246,13 +205,11 @@ class App
else
applyUpdates action, data
$scope.$digest()
_dfdSock.promise
onlogin = (assertion) ->
# Delete any old Auth plugin.
plugins.Auth?.destroy()
delete plugins.Auth
# Configure the Auth plugin with the issued assertion as refresh token.
annotator.addPlugin 'Auth',
tokenUrl: documentHelpers.absoluteURI(
......@@ -260,22 +217,27 @@ class App
# Set the user from the token.
plugins.Auth.withToken (token) ->
plugins.Permissions._setAuthFromToken(token)
loggedInUser = plugins.Permissions.user.replace /^acct:/, ''
annotator.addPlugin 'Permissions',
user: token.userId
userAuthorize: authorizeAction
permissions:
read: [token.userId]
update: [token.userId]
delete: [token.userId]
admin: [token.userId]
loggedInUser = token.userId.replace /^acct:/, ''
reset()
onlogout = ->
return unless drafts.discard()
plugins.Auth?.element.removeData('annotator:headers')
plugins.Auth?.destroy()
delete plugins.Auth
plugins.Permissions.setUser(null)
# XXX: Temporary workaround until Annotator v2.0 or v1.2.10
plugins.Permissions.options.permissions =
read: []
update: []
delete: []
admin: []
plugins.Permissions?.setUser(null)
plugins.Permissions?.destroy()
delete plugins.Permissions
loggedInUser = null
reset()
......@@ -284,14 +246,9 @@ class App
# Do not rely on the identity service to invoke callbacks within an
# angular digest cycle.
$scope.$evalAsync ->
if annotator.ongoing_edit
annotator.clickAdder()
if $scope.ongoingHighlightSwitch
$scope.ongoingHighlightSwitch = false
annotator.setTool 'highlight'
else
annotator.setTool 'comment'
# Update any edits in progress.
for draft in drafts.all()
annotator.publish 'beforeAnnotationCreated', draft
# Convert the verified user id to the format used by the API.
persona = loggedInUser
......@@ -315,22 +272,19 @@ class App
$scope.$watch 'socialView.name', (newValue, oldValue) ->
return if newValue is oldValue
if $scope.persona
initStore()
else if newValue is 'single-player'
identity.request()
$scope.$watch 'frame.visible', (newValue, oldValue) ->
routeName = $location.path().replace /^\//, ''
if newValue
initStore()
if newValue is 'single-player' and not $scope.persona
annotator.show()
annotator.host.notify method: 'showFrame', params: routeName
else if oldValue
annotator.hide()
annotator.host.notify method: 'hideFrame', params: routeName
for p in annotator.providers
p.channel.notify method: 'setActiveHighlights'
flash 'info',
'You will need to sign in for your highlights to be saved.'
$scope.$watch 'sort.name', (name) ->
return unless name
[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) ->
return if entities is oldEntities
......@@ -340,85 +294,9 @@ class App
.resetFilter()
.addClause('/uri', 'one_of', entities)
$scope.updater.then (sock) ->
filter = streamfilter.getFilter()
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.updater.then (sock) ->
filter = streamfilter.getFilter()
sock.send(JSON.stringify({filter}))
$scope.loadMore = (number) ->
unless streamfilter.getPastData().hits then return
......@@ -431,78 +309,33 @@ class App
sock.send(JSON.stringify(sockmsg))
$scope.authTimeout = ->
delete annotator.ongoing_edit
$scope.ongoingHighlightSwitch = false
flash 'info',
'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.model = persona: undefined
$scope.threading = plugins.Threading
$scope.search =
query: $location.search()['q']
clear: ->
$location.search('q', null)
update: (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
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
$scope.sort = name: 'Location'
class AnnotationViewer
......@@ -510,11 +343,13 @@ class AnnotationViewer
constructor: ($routeParams, $scope, streamfilter) ->
# Tells the view that these annotations are standalone
$scope.isEmbedded = false
$scope.isStream = false
# Provide no-ops until these methods are moved elsewere. They only apply
# to annotations loaded into the stream.
$scope.activate = angular.noop
$scope.openDetails = angular.noop
$scope.shouldShowThread = -> true
$scope.$watch 'updater', (updater) ->
if updater?
......@@ -530,298 +365,35 @@ class AnnotationViewer
class Viewer
this.$inject = [
'$location', '$rootScope', '$routeParams', '$scope',
'annotator'
'$filter', '$routeParams', '$sce', '$scope',
'annotator', 'searchfilter', 'viewFilter'
]
constructor: (
$location, $rootScope, $routeParams, $scope,
annotator
$filter, $routeParams, $sce, $scope,
annotator, searchfilter, viewFilter
) ->
if $routeParams.q
return $location.path('/page_search').replace()
# Tells the view that these annotations are embedded into the owner doc
$scope.isEmbedded = true
{providers, threading} = annotator
$scope.isStream = true
$scope.activate = (annotation) ->
if angular.isArray annotation
highlights = (a.$$tag for a in annotation when a?)
else if angular.isObject annotation
if angular.isObject annotation
highlights = [annotation.$$tag]
else
highlights = []
for p in providers
for p in annotator.providers
p.channel.notify
method: 'setActiveHighlights'
params: highlights
$scope.openDetails = (annotation) ->
for p in providers
p.channel.notify
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]
$scope.shouldShowThread = (container) ->
if $scope.selectedAnnotations? and not container.parent.parent
$scope.selectedAnnotations[container.message?.id]
else
highlights = []
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()
true
angular.module('h.controllers', imports)
.controller('AppController', App)
.controller('EditorController', Editor)
.controller('ViewerController', Viewer)
.controller('AnnotationViewerController', AnnotationViewer)
.controller('SearchController', Search)
imports = [
'ngSanitize'
'ngTagsInput'
'h.helpers.documentHelpers'
'h.services'
]
formInput = ->
link: (scope, elem, attr, [form, model, validator]) ->
return unless form?.$name and model?.$name and validator
......@@ -79,7 +87,6 @@ markdown = ['$filter', '$timeout', ($filter, $timeout) ->
unless readonly then $timeout -> input.focus()
require: '?ngModel'
restrict: 'E'
scope:
readonly: '@'
required: '@'
......@@ -131,36 +138,6 @@ privacy = ->
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) ->
compile: (tElement, tAttrs, transclude) ->
panes = []
......@@ -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.
showAccount = ->
restrict: 'A'
......@@ -298,8 +224,9 @@ repeatAnim = ->
username = ['$filter', '$window', ($filter, $window) ->
link: (scope, elem, attr) ->
scope.$watch 'user', ->
scope.uname = $filter('persona')(scope.user, 'username')
scope.$watch 'user', (user) ->
if user
scope.uname = $filter('persona')(scope.user, 'username')
scope.uclick = (event) ->
event.preventDefault()
......@@ -312,50 +239,6 @@ username = ['$filter', '$window', ($filter, $window) ->
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 = ->
link: (scope, elem, attr) ->
......@@ -378,15 +261,12 @@ match = ->
require: 'ngModel'
angular.module('h.directives', ['ngSanitize', 'ngTagsInput'])
angular.module('h.directives', imports)
.directive('formInput', formInput)
.directive('formValidate', formValidate)
.directive('fuzzytime', fuzzytime)
.directive('markdown', markdown)
.directive('privacy', privacy)
.directive('recursive', recursive)
.directive('tabReveal', tabReveal)
.directive('thread', thread)
.directive('username', username)
.directive('showAccount', showAccount)
.directive('repeatAnim', repeatAnim)
......
imports = [
'ngSanitize'
'h.helpers.documentHelpers'
'h.services'
]
### global -extractURIComponent, -validate ###
# Use an anchor tag to extract specific components within a uri.
extractURIComponent = (uri, component) ->
......@@ -13,252 +8,303 @@ extractURIComponent = (uri, component) ->
extractURIComponent.a[component]
class Annotation
this.$inject = [
'$element', '$location', '$rootScope', '$sce', '$scope', '$timeout',
'$window',
'annotator', 'documentHelpers', 'drafts'
]
constructor: (
$element, $location, $rootScope, $sce, $scope, $timeout,
$window,
annotator, documentHelpers, drafts
) ->
model = $scope.model
{plugins, threading} = annotator
$scope.action = 'create'
$scope.editing = false
$scope.preview = 'no'
if model.document and model.target.length
domain = extractURIComponent(model.uri, 'hostname')
title = model.document.title or domain
if title.length > 30
title = title.slice(0, 30) + '…'
$scope.document =
uri: model.uri
domain: domain
title: title
$scope.cancel = ($event) ->
$event?.stopPropagation()
$scope.editing = false
drafts.remove $scope.model
switch $scope.action
when 'create'
annotator.deleteAnnotation $scope.model
else
$scope.model.text = $scope.origText
$scope.model.tags = $scope.origTags
$scope.action = 'create'
$scope.save = ($event) ->
$event?.stopPropagation()
annotation = $scope.model
# Forbid saving comments without a body (text or tags)
if annotator.isComment(annotation) and not annotation.text and
not annotation.tags?.length
$window.alert "You can not add a comment without adding some text, or at least a tag."
return
# Forbid the publishing of annotations
# without a body (text or tags)
if $scope.form.privacy.$viewValue is "Public" and
$scope.action isnt "delete" and
not annotation.text and not annotation.tags?.length
$window.alert "You can not make this annotation public without adding some text, or at least a tag."
return
$scope.rebuildHighlightText()
$scope.editing = false
drafts.remove annotation
switch $scope.action
# Validate an annotation.
# Annotations must be attributed to a user or marked as deleted.
# A public annotation is valid only if they have a body.
# A non-public annotation requires only a target (e.g. a highlight).
validate = (value) ->
return unless angular.isObject value
worldReadable = 'group:__world__' in (value.permissions?.read or [])
(value.tags?.length or value.text?.length) or
(value.target?.length and not worldReadable)
###*
# @ngdoc type
# @name annotation.AnnotationController
#
# @property {Object} annotation The annotation view model.
# @property {Object} document The document metadata view model.
# @property {string} action One of 'view', 'edit', 'create' or 'delete'.
# @property {string} preview If previewing an edit then 'yes', else 'no'.
# @property {boolean} editing True if editing components are shown.
# @property {boolean} embedded True if the annotation is an embedded widget.
# @property {boolean} shared True if the share link is visible.
#
# @description
#
# `AnnotationController` provides an API for the annotation directive. It
# manages the interaction between the domain and view models and uses the
# {@link annotator annotator service} for persistence.
###
AnnotationController = [
'$scope', 'annotator', 'drafts', 'flash'
($scope, annotator, drafts, flash) ->
@annotation = {}
@action = 'view'
@document = null
@preview = 'no'
@editing = false
@embedded = false
@shared = false
highlight = annotator.tool is 'highlight'
model = $scope.annotationGet()
original = null
###*
# @ngdoc method
# @name annotation.AnnotationController#isComment.
# @returns {boolean} True if the annotation is a comment.
###
this.isComment = ->
not (model.target?.length or model.references?.length)
###*
# @ngdoc method
# @name annotation.AnnotationController#isHighlight.
# @returns {boolean} True if the annotation is a highlight.
###
this.isHighlight = ->
model.target?.length and not model.references?.length and
not (model.text or model.deleted or model.tags?.length)
###*
# @ngdoc method
# @name annotation.AnnotationController#isPrivate
# @returns {boolean} True if the annotation is private to the current user.
###
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'
# First, focus on the newly created annotation
unless annotator.isReply annotation
$rootScope.focus annotation, true
if annotator.isComment(annotation) and
$rootScope.viewState.view isnt "Comments"
$rootScope.applyView "Comments"
else if not annotator.isReply(annotation) and
$rootScope.viewState.view not in ["Document", "Selection"]
$rootScope.applyView "Screen"
annotator.publish 'annotationCreated', annotation
when 'delete'
root = $scope.$root.annotations
root = (a for a in root when a isnt root)
annotation.deleted = true
unless annotation.text? then annotation.text = ''
annotator.updateAnnotation annotation
else
annotator.updateAnnotation annotation
annotator.publish 'annotationCreated', model
when 'delete', 'edit'
annotator.publish 'annotationUpdated', model
@editing = false
@action = 'view'
###*
# @ngdoc method
# @name annotation.AnnotationController#reply
# @description
# Creates a new message in reply to this annotation.
###
this.reply = ->
unless model.id?
return flash 'error', 'Please save this annotation before replying.'
# Extract the references value from this container.
{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) ->
$event?.stopPropagation()
unless plugins.Auth? and plugins.Auth.haveValidToken()
$window.alert "In order to reply, you need to sign in."
return
# Discard the draft if the scope goes away.
$scope.$on '$destroy', ->
drafts.remove model
references =
if $scope.thread.message.references
[$scope.thread.message.references..., $scope.thread.message.id]
# Render on updates.
$scope.$watch (-> model.updated), (updated) =>
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
[$scope.thread.message.id]
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
drafts.add model, => this.revert()
else
$scope.action = 'delete'
$scope.editing = true
$scope.origText = $scope.model.text
$scope.origTags = $scope.model.tags
$scope.model.text = ''
$scope.model.tags = ''
$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
this.render()
# Start editing brand new annotations immediately
unless model.id? or (highlight and this.isHighlight()) then this.edit()
this
]
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'
require: '?ngModel'
restrict: 'C'
controllerAs: 'vm'
link: linkFn
require: ['annotation', '?^thread', '?^threadFilter', '?^deepCount']
scope:
model: '=ngModel'
mode: '@'
replies: '@'
annotationGet: '&annotation'
templateUrl: 'annotation.html'
]
angular.module('h.directives.annotation', imports)
.controller('AnnotationController', Annotation)
angular.module('h.directives')
.controller('AnnotationController', AnnotationController)
.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) ->
uuid = 0
link: (scope, elem, attr, ctrl) ->
_search = $parse(attr.onsearch)
_clear = $parse(attr.onclear)
scope.viewId = uuid++
scope.dosearch = ->
_search(scope, {"this": scope.searchtext})
scope.reset = (event) ->
event.preventDefault()
scope.searchtext = ''
_clear(scope) if attr.onclear
scope.query = ''
scope.search = (event) ->
event.preventDefault()
scope.query = scope.searchtext
scope.$watch attr.query, (query) ->
if query?
scope.searchtext = query
_search(scope, {"this": scope.searchtext})
scope.$watch 'query', (query) ->
return if query is undefined
scope.searchtext = query
if query
scope.onSearch?(query: scope.searchtext)
else
scope.onClear?()
restrict: 'C'
scope:
query: '='
onSearch: '&'
onClear: '&'
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…" />
<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)">
......
### 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) ->
fuzzy = Math.round(delta / year) + ' years ago'
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') ->
part = ['term', 'username', 'provider'].indexOf(part)
(user?.match /^acct:([^@]+)@(.+)/)?[part]
......@@ -54,5 +73,6 @@ elide = (text, split_length) ->
angular.module('h.filters', [])
.filter('converter', -> (new Converter()).makeHtml)
.filter('fuzzyTime', -> fuzzyTime)
.filter('moment', momentFilter)
.filter('persona', -> persona)
.filter('elide', -> elide)
......@@ -23,22 +23,14 @@ class Annotator.Guest extends Annotator
Document: {}
# Internal state
comments: null
tool: 'comment'
visibleHighlights: false
noBack: false
constructor: (element, options, config = {}) ->
options.noScan = true
super
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>')
.appendTo(@wrapper)
.addClass('annotator-frame annotator-outer annotator-collapsed')
......@@ -58,7 +50,6 @@ class Annotator.Guest extends Annotator
formatted.document.title = formatted.document.title.slice()
formatted
onConnect: (source, origin, scope) =>
this.publish "enableAnnotating", @canAnnotate
@panel = this._setupXDM
window: source
origin: origin
......@@ -80,14 +71,6 @@ class Annotator.Guest extends Annotator
# Scan the document text with the DOM Text libraries
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
this.subscribe "highlightsCreated", (highlights) =>
unless Array.isArray highlights
......@@ -105,8 +88,9 @@ class Annotator.Guest extends Annotator
# Collect all impacted annotations
annotations = (hl.annotation for hl in highlights)
# 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
this.subscribe "highlightRemoved", (highlight) =>
......@@ -127,7 +111,7 @@ class Annotator.Guest extends Annotator
delete highlight.anchor.target.pos
# Announce the new positions, so that the sidebar knows
this.publish "annotationsLoaded", [[highlight.annotation]]
this.plugins.Bridge.sync([highlight.annotation])
_setupXDM: (options) ->
# jschannel chokes FF and Chrome extension origins.
......@@ -144,6 +128,7 @@ class Annotator.Guest extends Annotator
.bind('setDynamicBucketMode', (ctx, value) =>
return unless @plugins.Heatmap
return if @plugins.Heatmap.dynamicBucket is value
@plugins.Heatmap.dynamicBucket = value
if value then @plugins.Heatmap._update()
)
......@@ -159,11 +144,9 @@ class Annotator.Guest extends Annotator
)
.bind('setFocusedHighlights', (ctx, tags=[]) =>
this.focusedAnnotations = []
for hl in @getHighlights()
annotation = hl.annotation
if annotation.$$tag in tags
this.focusedAnnotations.push annotation
hl.setFocused true, true
else
hl.setFocused false, true
......@@ -177,13 +160,6 @@ class Annotator.Guest extends Annotator
return
)
.bind('adderClick', =>
@selectedTargets = @forcedLoginTargets
@onAdderClick @forcedLoginEvent
delete @forcedLoginTargets
delete @forcedLoginEvent
)
.bind('getDocumentInfo', =>
return {
uri: @plugins.Document.uri()
......@@ -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) =>
@tool = name
this.publish 'setTool', name
......@@ -231,12 +191,13 @@ class Annotator.Guest extends Annotator
_setupWrapper: ->
@wrapper = @element
.on 'click', =>
if @canAnnotate and not @noBack and not @creatingHL
setTimeout =>
unless @selectedTargets?.length
@hideFrame()
delete @creatingHL
.on 'click', (event) =>
if @selectedTargets?.length
if @tool is 'highlight'
# Create the annotation
annotation = this.setupAnnotation(this.createAnnotation())
else
@hideFrame()
this
# These methods aren't used in the iframe-hosted configuration of Annotator.
......@@ -265,40 +226,29 @@ class Annotator.Guest extends Annotator
this.removeEvents()
showViewer: (viewName, annotations, focused = false) =>
showViewer: (annotations) =>
@panel?.notify
method: "showViewer"
params:
view: viewName
ids: (a.id for a in annotations when a.id)
focused: focused
params: (a.$$tag for a in annotations)
toggleViewerSelection: (annotations, focused = false) =>
toggleViewerSelection: (annotations) =>
@panel?.notify
method: "toggleViewerSelection"
params:
ids: (a.id for a in annotations)
focused: focused
params: (a.$$tag for a in annotations)
updateViewer: (viewName, annotations, focused = false) =>
updateViewer: (annotations) =>
@panel?.notify
method: "updateViewer"
params:
view: viewName
ids: (a.id for a in annotations when a.id)
focused: focused
showEditor: (annotation) => @plugins.Bridge.showEditor annotation
params: (a.$$tag for a in annotations)
addEmphasis: (annotations) =>
showEditor: (annotation) =>
@panel?.notify
method: "addEmphasis"
params: (a.id for a in annotations when a.id)
method: "showEditor"
params: annotation.$$tag
removeEmphasis: (annotations) =>
@panel?.notify
method: "removeEmphasis"
params: (a.id for a in annotations when a.id)
onAnchorMouseover: ->
onAnchorMouseout: ->
onAnchorMousedown: ->
checkForStartSelection: (event) =>
# Override to prevent Annotator choking when this ties to access the
......@@ -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?"
onSuccessfulSelection: (event, immediate) ->
return unless @canAnnotate
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?
return false unless this.confirmSelection()
# Add a flag about what's happening
@creatingHL = true
# 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
# Describe the selection with targets
@selectedTargets = (@_getTargetFromSelection(s) for s in event.segments)
return
super
# Select some annotations.
#
# toggle: should this toggle membership in an existing selection?
# focus: should these annotation become focused?
selectAnnotations: (annotations, toggle, focus) =>
selectAnnotations: (annotations, toggle) =>
# Switch off dynamic mode; we are going to "Selection" scope
@plugins.Heatmap.dynamicBucket = false
if toggle
# Tell sidebar to add these annotations to the sidebar
this.toggleViewerSelection annotations, focus
this.toggleViewerSelection annotations
else
# 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,
# tell the sidebar to bring up the viewer for the relevant annotations
onAnchorClick: (event) =>
annotations = this._getImpactedAnnotations event
return unless annotations.length and @noBack
this.selectAnnotations annotations,
(event.metaKey or event.ctrlKey), true
# We have already prevented closing the sidebar, now reset this flag
@noBack = false
if @visibleHighlights or @tool is 'highlight'
event.stopPropagation()
this.selectAnnotations (event.data.getAnnotations event),
(event.metaKey or event.ctrlKey)
setTool: (name) ->
@panel?.notify
......@@ -411,24 +316,7 @@ class Annotator.Guest extends Annotator
@element.removeClass markerClass
addComment: ->
sel = @selectedTargets # Save the selection
# 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
this.showEditor(this.createAnnotation())
# Open the sidebar
showFrame: ->
......@@ -443,56 +331,14 @@ class Annotator.Guest extends Annotator
method: 'addToken'
params: token
onAdderMousedown: ->
onAdderClick: (event) =>
"""
Differs from upstream in a few ways:
- 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
event.preventDefault()
event.stopPropagation()
@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())
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()
this.showEditor(annotation)
onSetTool: (name) ->
switch name
......@@ -504,14 +350,3 @@ class Annotator.Guest extends Annotator
onSetVisibleHighlights: (state) =>
this.visibleHighlights = state
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
channel
.bind('showFrame', (ctx, routeName) =>
.bind('showFrame', (ctx) =>
unless @drag.enabled
@frame.css 'margin-left': "#{-1 * @frame.width()}px"
@frame.removeClass 'annotator-no-transition'
@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.removeClass 'annotator-no-transition'
@frame.addClass 'annotator-collapsed'
switch routeName
when 'editor'
this.publish 'annotationEditorHidden'
when 'viewer'
this.publish 'annotationViewerHidden'
)
.bind('dragFrame', (ctx, screenX) => this._dragUpdate screenX)
......
......@@ -9,7 +9,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
'annotationUpdated': 'annotationUpdated'
'annotationDeleted': 'annotationDeleted'
'annotationsLoaded': 'annotationsLoaded'
'enableAnnotating': 'enableAnnotating'
# Helper method for merging info from a remote target
@_mergeTarget: (local, remote, gateway) =>
......@@ -189,10 +188,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
channel = Channel.build(options)
## Remote method call bindings
.bind('setupAnnotation', (txn, annotation) =>
this._format (@annotator.setupAnnotation (this._parse annotation))
)
.bind('beforeCreateAnnotation', (txn, annotation) =>
annotation = this._parse annotation
delete @cache[annotation.$$tag]
......@@ -226,29 +221,14 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
res
)
## Notifications
.bind('loadAnnotations', (txn, 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('sync', (ctx, annotations) =>
(this._format (this._parse a) for a in annotations)
)
.bind('enableAnnotating', (ctx, state) =>
@annotator.enableAnnotating state, false
## Notifications
.bind('loadAnnotations', (txn, annotations) =>
annotations = (this._parse a for a in annotations)
@annotator.loadAnnotations annotations
)
# Send out a beacon to let other frames know to connect to us
......@@ -288,10 +268,15 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
$.when(deferreds...)
.then (results...) =>
annotation = {}
for r in results when r isnt null
$.extend annotation, (this._parse r)
options.callback? null, annotation
if Array.isArray(results[0])
acc = []
foldFn = (_, cur) =>
(this._parse(a) for a in cur)
else
acc = {}
foldFn = (_, cur) =>
this._parse(cur)
options.callback? null, results.reduce(foldFn, acc)
.fail (failure) =>
options.callback? failure
......@@ -366,14 +351,11 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
this
annotationsLoaded: (annotations) =>
return if @selfPublish
unless annotations.length
console.log "Useless call to 'annotationsLoaded()' with an empty list"
console.trace()
return
annotations = (this._format a for a in annotations when not a.$$tag)
return unless annotations.length
this._notify
method: 'loadAnnotations'
params: (this._format a for a in annotations)
params: annotations
this
beforeCreateAnnotation: (annotation, cb) ->
......@@ -383,13 +365,6 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
callback: cb
annotation
setupAnnotation: (annotation, cb) ->
this._call
method: 'setupAnnotation'
params: this._format annotation
callback: cb
annotation
createAnnotation: (annotation, cb) ->
this._call
method: 'createAnnotation'
......@@ -411,13 +386,10 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
callback: cb
annotation
showEditor: (annotation) ->
this._notify
method: 'showEditor'
params: this._format annotation
sync: (annotations, cb) ->
annotations = (this._format a for a in annotations)
this._call
method: 'sync'
params: annotations
callback: cb
this
enableAnnotating: (state) ->
this._notify
method: 'enableAnnotating'
params: state
......@@ -58,16 +58,11 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
'annotationsLoaded'
]
for event in events
if event is 'annotationCreated'
@annotator.subscribe event, =>
this._scheduleUpdate()
else
@annotator.subscribe event, this._scheduleUpdate
@annotator.subscribe event, this._scheduleUpdate
@element.on 'click', (event) =>
event.stopPropagation()
@dynamicBucket = true
@annotator.showViewer "Screen", this._getDynamicBucket()
@annotator.showFrame()
@element.on 'mouseup', (event) =>
event.stopPropagation()
......@@ -437,7 +432,6 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
annotations = @buckets[bucket].slice()
annotator.selectAnnotations annotations,
(d3.event.ctrlKey or d3.event.metaKey),
(annotations.length is 1) # Only focus if there is only one
tabs.exit().remove()
......@@ -458,7 +452,7 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
if (@buckets[d].length is 0) then 'none' else ''
if @dynamicBucket
@annotator.updateViewer "Screen", this._getDynamicBucket()
@annotator.updateViewer this._getDynamicBucket()
@tabs = tabs
......@@ -480,4 +474,4 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
# Simulate clicking on the comments tab
commentClick: =>
@dynamicBucket = false
annotator.showViewer "Comments", @buckets[@_getCommentBucket()]
annotator.showViewer @buckets[@_getCommentBucket()]
class Annotator.Plugin.Threading extends Annotator.Plugin
# These events maintain the awareness of annotations between the two
# communicating annotators.
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
'annotationDeleted': 'annotationDeleted'
'annotationsLoaded': 'annotationsLoaded'
'beforeAnnotationCreated': 'beforeAnnotationCreated'
# Cache of annotations which have crossed the bridge for fast, encapsulated
# association of annotations received in arguments to window-local copies.
cache: {}
root: null
pluginInit: ->
@annotator.threading = mail.messageThread()
# Create a root container.
@root = mail.messageContainer()
thread: (annotation) ->
# Get or create a thread to contain the annotation
thread = (@annotator.threading.getContainer annotation.id)
thread.message = annotation
# Mix in message thread properties, preserving local overrides.
$.extend(this, mail.messageThread(), thread: this.thread)
# Attach the thread to its parent, if any.
if annotation.references?.length
prev = annotation.references[annotation.references.length-1]
@annotator.threading.getContainer(prev).addChild thread
# TODO: Refactor the jwz API for progressive updates.
# Right now the idTable is wiped when `messageThread.thread()` is called and
# empty containers are pruned. We want to show empties so that replies attach
# 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
Object.defineProperty annotation, 'thread',
configurable: true
enumerable: false
writable: true
value: thread
prev = @root
# Update the id table
@annotator.threading.idTable[annotation.id] = thread
references = message.references or []
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) =>
parent = annotation.thread.parent
annotation.thread.message = null # Break cyclic reference
delete @annotator.threading.idTable[annotation.id]
delete annotation.thread
if parent? then @annotator.threading.pruneEmpties parent
# Attach the thread at its leaf location
unless thread.hasDescendant(prev) # no cycles
do ->
for child in prev.children when child.message is message
return # no dupes
prev.addChild(thread)
annotationsLoaded: (annotations) =>
this.thread a for a in annotations
this.pruneEmpties(@root)
@root
beforeAnnotationCreated: (annotation) =>
# Assign temporary id. Threading relies on the id.
Object.defineProperty annotation, 'id',
configurable: true
enumerable: false
writable: true
value: window.btoa Math.random()
this.thread annotation
this.thread([annotation])
annotationDeleted: ({id}) =>
container = this.getContainer id
container.message = null
this.pruneEmpties(@root)
annotationsLoaded: (annotations) =>
messages = (@root.flattenChildren() or []).concat(annotations)
this.thread(messages)
......@@ -73,7 +73,7 @@ class SearchFilter
filter = term.slice 0, term.indexOf ":"
unless filter? then 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 'since'
# We'll turn this into seconds
......@@ -109,44 +109,36 @@ class SearchFilter
# Time given in year
t = /^(\d+)year$/.exec(time)[1]
since.push t * 60 * 60 * 24 * 365
when 'tag' then tag.push term[4..]
when 'text' then text.push term[5..]
when 'uri' then uri.push term[4..]
when 'user' then user.push term[5..]
else any.push term
when 'tag' then tag.push term[4..].toLowerCase()
when 'text' then text.push term[5..].toLowerCase()
when 'uri' then uri.push term[4..].toLowerCase()
when 'user' then user.push term[5..].toLowerCase()
else any.push term.toLowerCase()
any:
terms: any
operator: 'and'
lowercase: true
quote:
terms: quote
operator: 'and'
lowercase: true
result:
terms: result
operator: 'min'
lowercase: false
since:
terms: since
operator: 'and'
lowercase: false
tag:
terms: tag
operator: 'and'
lowercase: true
text:
terms: text
operator: 'and'
lowercase: true
uri:
terms: uri
operator: 'or'
lowercase: true
user:
terms: user
operator: 'or'
lowercase: true
# This class will process the results of search and generate the correct filter
......@@ -235,19 +227,6 @@ class QueryParser
and_or: 'and'
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) =>
# Populate a filter with a query object
for category, value of query
......
imports = [
'h.filters',
'h.searchfilters'
###*
# @ngdoc service
# @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
events:
'annotationCreated': 'updateAncestors'
'annotationUpdated': 'updateAncestors'
'annotationDeleted': 'updateAncestors'
'beforeAnnotationCreated': 'digest'
'annotationCreated': 'digest'
'annotationDeleted': 'digest'
'annotationUpdated': 'digest'
'annotationsLoaded': 'digest'
# Plugin configuration
options:
noDocAccess: true
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: {}
# Internal state
......@@ -53,15 +49,13 @@ class Hypothesis extends Annotator
# Here as a noop just to make the Permissions plugin happy
# XXX: Change me when Annotator stops assuming things about viewers
editor:
addField: angular.noop
viewer:
addField: (-> )
this.$inject = [
'$document', '$location', '$rootScope', '$route', '$window',
]
constructor: (
$document, $location, $rootScope, $route, $window,
) ->
addField: angular.noop
this.$inject = ['$document', '$window']
constructor: ( $document, $window ) ->
super ($document.find 'body')
window.annotator = this
......@@ -79,20 +73,13 @@ class Hypothesis extends Annotator
# Set up the bridge plugin, which bridges the main annotation methods
# between the host page and the panel widget.
whitelist = [
'diffHTML', 'inject', 'quote', 'ranges', 'target', 'id', 'references',
'uri', 'diffCaseOnly', 'document',
]
whitelist = ['target', 'document', 'uri']
this.addPlugin 'Bridge',
gateway: true
formatter: (annotation) =>
formatted = {}
for k, v of annotation when k in whitelist
formatted[k] = v
if annotation.thread? and annotation.thread?.children.length
formatted.reply_count = annotation.thread.flattenChildren().length
else
formatted.reply_count = 0
formatted
parser: (annotation) =>
parsed = {}
......@@ -141,96 +128,7 @@ class Hypothesis extends Annotator
unless 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) ->
$rootScope = @element.injector().get '$rootScope'
# jschannel chokes FF and Chrome extension origins.
if (options.origin.match /^chrome-extension:\/\//) or
(options.origin.match /^resource:\/\//)
......@@ -250,55 +148,47 @@ class Hypothesis extends Annotator
.bind('back', =>
# Navigate "back" out of the interface.
$rootScope.$apply =>
return unless this.discardDrafts()
this.hide()
@element.scope().$apply => this.hide()
)
.bind('open', =>
# Pop out the sidebar
$rootScope.$apply => this.show()
@element.scope().$apply => this.show()
)
.bind('showViewer', (ctx, {view, ids, focused}) =>
ids ?= []
return unless this.discardDrafts()
$rootScope.$apply =>
this.showViewer view, this._getAnnotationsFromIDs(ids), focused
.bind('showEditor', (ctx, tag) =>
@element.scope().$apply =>
this.showEditor this._getLocalAnnotation(tag)
)
.bind('updateViewer', (ctx, {view, ids, focused}) =>
ids ?= []
$rootScope.$apply =>
this.updateViewer view, this._getAnnotationsFromIDs(ids), focused
.bind('showViewer', (ctx, tags=[]) =>
@element.scope().$apply =>
this.showViewer this._getLocalAnnotations(tags)
)
.bind('toggleViewerSelection', (ctx, {ids, focused}) =>
$rootScope.$apply =>
this.toggleViewerSelection this._getAnnotationsFromIDs(ids), focused
.bind('updateViewer', (ctx, tags=[]) =>
@element.scope().$apply =>
this.updateViewer this._getLocalAnnotations(tags)
)
.bind('setTool', (ctx, name) =>
$rootScope.$apply => this.setTool name
)
.bind('setVisibleHighlights', (ctx, state) =>
$rootScope.$apply => this.setVisibleHighlights state
.bind('toggleViewerSelection', (ctx, tags=[]) =>
@element.scope().$apply =>
this.toggleViewerSelection this._getLocalAnnotations(tags)
)
.bind('addEmphasis', (ctx, ids=[]) =>
this.addEmphasis this._getAnnotationsFromIDs ids
.bind('setTool', (ctx, name) =>
@element.scope().$apply => this.setTool name
)
.bind('removeEmphasis', (ctx, ids=[]) =>
this.removeEmphasis this._getAnnotationsFromIDs ids
.bind('setVisibleHighlights', (ctx, state) =>
@element.scope().$apply => this.setVisibleHighlights state
)
# Look up an annotation based on the ID
_getAnnotationFromID: (id) -> @threading.getContainer(id)?.message
# Look up an annotation based on its bridge tag
_getLocalAnnotation: (tag) -> @plugins.Bridge.cache[tag]
# Look up a list of annotations, based on their IDs
_getAnnotationsFromIDs: (ids) -> this._getAnnotationFromID id for id in ids
# Look up a list of annotations, based on their bridge tags
_getLocalAnnotations: (tags) -> this._getLocalAnnotation t for t in tags
_setupWrapper: ->
@wrapper = @element.find('#wrapper')
......@@ -321,158 +211,63 @@ class Hypothesis extends Annotator
_setupDocumentAccessStrategies: -> 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.
setupAnnotation: (annotation) ->
annotation.highlights = []
annotation
sortAnnotations: (a, 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()
buildReplyList: (annotations=[]) =>
$filter = @element.injector().get '$filter'
for annotation in annotations
if annotation?
thread = @threading.getContainer annotation.id
children = (r.message for r in (thread.children or []))
annotation.reply_list = children.sort(@sortAnnotations).reverse()
@buildReplyList children
toggleViewerSelection: (annotations=[], focused) =>
annotations = annotations.filter (a) -> a?
@element.injector().invoke [
'$rootScope',
($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
toggleViewerSelection: (annotations=[]) ->
scope = @element.scope()
scope.search.query = ''
selected = scope.selectedAnnotations or {}
for a in annotations
if selected[a.id]
delete selected[a.id]
else
selected[a.id] = true
count = Object.keys(selected).length
scope.selectedAnnotationsCount = count
if count
scope.selectedAnnotations = selected
else
scope.selectedAnnotations = null
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
showViewer: (viewName, annotations=[], focused = false) =>
this.show()
@element.injector().invoke [
'$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()
updateViewer: (annotations=[]) ->
# TODO: re-implement
this
removeEmphasis: (annotations=[]) =>
annotations = annotations.filter (a) -> a? # Filter out null annotations
showViewer: (annotations=[]) ->
scope = @element.scope()
scope.search.query = ''
selected = {}
for a in annotations
delete a.$emphasis
@element.injector().get('$rootScope').$digest()
clickAdder: =>
for p in @providers
p.channel.notify
method: 'adderClick'
selected[a.id] = true
scope.selectedAnnotations = selected
scope.selectedAnnotationsCount = Object.keys(selected).length
this.show()
this
showEditor: (annotation) =>
showEditor: (annotation) ->
delete @element.scope().selectedAnnotations
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
show: =>
@element.scope().frame.visible = true
show: ->
@host.notify method: 'showFrame'
hide: =>
@element.scope().frame.visible = false
hide: ->
@host.notify method: 'hideFrame'
isOpen: =>
@element.scope().frame.visible
digest: ->
@element.scope().$evalAsync angular.noop
patch_store: ->
$location = @element.injector().get '$location'
$rootScope = @element.injector().get '$rootScope'
scope = @element.scope()
Store = Annotator.Plugin.Store
# When the Store plugin is first instantiated, don't load annotations.
......@@ -498,36 +293,26 @@ class Hypothesis extends Annotator
# if the annotation has a newly-assigned id and ensures that the id
# is enumerable.
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
annotation = angular.extend annotation, data
@plugins.Bridge?.updateAnnotation annotation
# Give angular a chance to react
$rootScope.$digest()
# Update the thread table
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) ->
switch @socialView.name
......@@ -545,31 +330,10 @@ class Hypothesis extends Annotator
else
console.warn "Unsupported Social View: '" + @socialView.name + "'!"
# Bubbles updates through the thread so that guests see accurate
# 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) =>
setTool: (name) ->
return if name is @tool
return unless this.discardDrafts()
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'
else
this.socialView.name = 'none'
......@@ -581,7 +345,7 @@ class Hypothesis extends Annotator
method: 'setTool'
params: name
setVisibleHighlights: (state) =>
setVisibleHighlights: (state) ->
return if state is @visibleHighlights
@visibleHighlights = state
this.publish 'setVisibleHighlights', state
......@@ -590,61 +354,49 @@ class Hypothesis extends Annotator
method: 'setVisibleHighlights'
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
drafts: null
_drafts: null
constructor: ->
this.drafts = []
@_drafts = []
$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 = []
for d, i in @drafts
for d, i in @_drafts
remove.push i if d.draft is draft
while remove.length
@drafts.splice(remove.pop(), 1)
@_drafts.splice(remove.pop(), 1)
contains: (draft) ->
for d in @drafts
for d in @_drafts
if d.draft is draft then return true
return false
isEmpty: -> @drafts.length is 0
isEmpty: -> @_drafts.length is 0
discard: ->
text =
switch @drafts.length
switch @_drafts.length
when 0 then null
when 1
"""You have an unsaved reply.
Do you really want to discard this draft?"""
else
"""You have #{@drafts.length} unsaved replies.
"""You have #{@_drafts.length} unsaved replies.
Do you really want to discard these drafts?"""
if @drafts.length is 0 or confirm text
discarded = @drafts.slice()
@drafts = []
if @_drafts.length is 0 or confirm text
discarded = @_drafts.slice()
@_drafts = []
d.cb?() for d in discarded
true
else
......@@ -689,12 +441,6 @@ class ViewFilter
any:
fields: ['quote', 'text', 'tag', 'user']
this.$inject = ['searchfilter']
constructor: (searchfilter) ->
@searchfilter = searchfilter
_matches: (filter, value, match) ->
matches = true
......@@ -737,17 +483,17 @@ class ViewFilter
value = checker.value annotation
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
else
value = value.toLowerCase() if filter.lowercase
value = value.toLowerCase() if typeof(value) == 'string'
return @_matches filter, value, checker.match
# Filters a set of annotations, according to a given query.
# Inputs:
# 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.)
# and the here configured @checkers. This @checkers object contains instructions how to verify the match.
......@@ -765,84 +511,35 @@ class ViewFilter
# matched annotation IDs list,
# the faceted filters
# ]
filter: (annotations, query) =>
filters = @searchfilter.generateFacetedFilter query
results = []
# Check for given limit
# Find the minimal
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
filter: (annotations, filters) ->
limit = Math.min((filters.result?.terms or [])...)
count = 0
results = for annotation in annotations
break if count >= limit
match = true
for category, filter of filters
break unless matches
terms = filter.terms
# No condition for this category
continue unless terms.length
break unless match
continue unless filter.terms.length
switch category
when 'result'
# Handled above
continue
when 'any'
# Special case
matchterms = []
matchterms.push false for term in terms
categoryMatch = false
for field in @checkers.any.fields
conf = @checkers[field]
continue if conf.autofalse? and conf.autofalse annotation
value = conf.value annotation
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
if @_checkMatch(filter, annotation, @checkers[field])
categoryMatch = true
break
match = categoryMatch
else
# For all other categories
matches = @_checkMatch filter, annotation, @checkers[category]
if matches
results.push annotation.id
[results, filters]
match = @_checkMatch filter, annotation, @checkers[category]
continue unless match
count++
annotation.id
angular.module('h.services', imports)
angular.module('h.services', [])
.factory('render', renderFactory)
.provider('drafts', DraftProvider)
.service('annotator', Hypothesis)
.service('viewFilter', ViewFilter)
......@@ -9,13 +9,18 @@ imports = [
class StreamSearch
this.inject = [
'$scope', '$rootScope',
'queryparser', 'searchfilter', 'streamfilter'
'$scope', '$rootScope', '$routeParams',
'annotator', 'queryparser', 'searchfilter', 'streamfilter'
]
constructor: (
$scope, $rootScope,
queryparser, searchfilter, streamfilter
$scope, $rootScope, $routeParams
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
streamfilter
.resetFilter()
......@@ -23,18 +28,21 @@ class StreamSearch
.setPastDataHits(50)
# Apply query clauses
$scope.search.query = $routeParams.q
terms = searchfilter.generateFacetedFilter $scope.search.query
queryparser.populateFilter streamfilter, terms
$scope.updater?.then (sock) ->
filter = streamfilter.getFilter()
sock.send(JSON.stringify({filter}))
$scope.isEmbedded = false
$scope.isStream = true
$scope.sort.name = 'Newest'
$rootScope.annotations = []
$rootScope.applyView "Document" # Non-sensical, but best for the moment
$rootScope.applySort "Newest"
$scope.shouldShowThread = (container) -> true
$scope.openDetails = (annotation) ->
$scope.$watch 'updater', (updater) ->
updater?.then (sock) ->
filter = streamfilter.getFilter()
sock.send(JSON.stringify({filter}))
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////////////////////////////////
//This is for everything that is formatted as an annotation.
.annotation {
@include pie-clearfix;
font-family: $sans-font-family;
font-weight: 300;
position: relative;
&:hover fuzzytime a, &:hover .reply-count {
&:hover .annotation-timestamp, &:hover .reply-count {
color: $link-color;
}
.magicontrol.dropdown {
top: 4px;
}
fuzzytime {
.annotation-timestamp {
line-height: 2;
a {
color: $text-color;
&:hover { color: $link-color-hover; }
&:focus { outline: 0; }
}
}
.reply-count {
color: $text-color;
&:hover { color: $link-color-hover; }
&:focus { outline: 0; }
}
.annotation-header { margin-bottom: .8em }
.annotation-section { margin: .8em 0 }
.annotation-footer { margin-top: .8em }
.user {
font-weight: bold;
font-size: 1.1em;
......@@ -39,80 +29,37 @@
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 {
@include quote;
}
.annotation-citation-domain {
color: $gray-light;
font-size: .923em;
}
.icon-markdown {
color: $text-color;
position: absolute;
left: 9em;
line-height: 1.5;
top: 0;
line-height: 1.4;
margin-left: .5em;
}
//MAGICONTROL////////////////////////////////
.magicontrol {
@include transition(opacity);
@include transition-duration(.15s);
margin: 0 .4em;
opacity: 0;
margin-right: .8em;
color: $gray-lighter;
&.open, :hover > & {
@include transition-duration(.15s);
opacity: 1;
&.dropdown {
top: 4px;
}
.annotation:hover & {
color: $link-color;
}
}
.share-dialog {
display: none;
a {
float: left;
line-height: 1.4;
......@@ -129,13 +76,3 @@
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;
@mixin quote {
color: $gray;
font-family: $serif-font-family;
font-style: italic;
padding: 0 .615em;
border-left: 3px solid $gray-lighter;
}
......
......@@ -337,7 +337,6 @@ html {
}
}
//SEARCH HIGHLIGHTS////////////////////////////////
.search-hl-active {
background: $highlight-color;
......@@ -355,44 +354,3 @@ html {
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;
$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 {
cursor: pointer;
position: relative;
& > * {
@include pie-clearfix;
}
& > ul {
padding-left: $thread-padding + .15em;
margin-left: -$thread-padding;
}
.load-more {
@include pie-clearfix;
font-family: $sans-font-family;
font-weight:bold;
font-size: .8em;
.reply-count {
color: $text-color;
&:focus { outline: 0; }
}
&:hover .reply-count {
color: $link-color;
&:hover, &:focus {
color: $link-color-hover;
}
}
.thread {
border-left: 1px dotted $gray-light;
height: 100%;
padding: 0;
padding-left: $thread-padding;
&.collapsed {
&.thread-collapsed {
border-color: transparent;
& > .annotation {
.body {
display: none;
}
.magicontrol {
display: none;
}
.reply-count {
font-style: italic;
}
& > article markdown {
display: none;
}
}
}
......@@ -73,43 +76,32 @@ $threadexp-width: .6em;
}
}
.annotation {
&.squished {
padding-left: 0;
}
}
&.collapsed {
&.thread-collapsed {
&:hover {
background-color: $gray-lightest;
}
& > ul {
max-height: 0;
overflow: hidden;
display: none;
}
& > .annotation {
markdown > div > * {
display: none;
}
markdown > div > *:first-child {
& > .thread-message {
.styled-text > * { display: none }
.styled-text *:first-child {
display: block;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
-o-text-overflow: ellipsis;
white-space: nowrap;
}
.indicators {
margin-right: .25em;
}
.thread &, .thread & header, .thread & section { margin: 0 }
.thread & footer { display: none }
}
}
}
.annotation-citation-domain {
color: $gray-light;
font-size: .923em;
.thread-message {
margin-bottom: .8em;
}
......@@ -40,7 +40,6 @@ module.exports = function(config) {
'h/static/scripts/vendor/moment-timezone.js',
'h/static/scripts/vendor/moment-timezone-data.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/uuid.js',
'h/static/scripts/hypothesis-auth.js',
......
......@@ -16,7 +16,7 @@
"karma-ng-html2js-preprocessor": "^0.1.0",
"karma-phantomjs-launcher": "^0.1.4",
"mocha": "^1.20.1",
"phantomjs": "1.9.7-10"
"phantomjs": "^1.9.7"
},
"engines": {
"node": "0.10.x"
......
assert = chai.assert
sandbox = sinon.sandbox.create()
describe 'h.directives.annotation', ->
$scope = null
annotator = null
annotation = null
createController = null
flash = null
beforeEach module('h.directives.annotation')
beforeEach module('h.directives')
beforeEach inject ($controller, $rootScope) ->
$scope = $rootScope.$new()
$scope.model =
$scope.annotationGet = (locals) -> annotation
annotator = {plugins: {}, publish: sandbox.spy()}
annotation =
id: 'deadbeef'
document:
title: 'A special document'
target: [{}]
uri: 'http://example.com'
flash = sinon.spy()
createController = ->
$controller 'AnnotationController',
$scope: $scope
$element: null
$location: {}
$rootScope: $rootScope
$sce: null
$timeout: sinon.spy()
$window: {}
annotator: {plugins: {}}
documentHelpers: {baseURI: '', absoluteURI: sinon.spy()}
drafts: null
annotator: annotator
flash: flash
afterEach ->
sandbox.restore()
it 'provides a document title', ->
controller = createController()
assert.equal($scope.document.title, 'A special document')
$scope.$digest()
assert.equal(controller.document.title, 'A special document')
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.'''
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', ->
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', ->
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', ->
delete $scope.model.document.title
delete annotation.document.title
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', ->
delete $scope.model.document
delete annotation.document
controller = createController()
assert.isUndefined($scope.document)
$scope.$digest()
assert.isNull(controller.document)
it 'skips the document object if the annotation has no targets', ->
$scope.model.target = []
annotation.target = []
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', ->
$scope = null
$compile = null
fakeWindow = null
isolate = null
beforeEach module('h.directives')
......@@ -20,23 +21,24 @@ describe 'h.directives', ->
template= '''
<div class="simpleSearch"
query="query"
onsearch="update(this)"
onclear="clear()">
on-search="update(query)"
on-clear="clear()">
</div>
'''
$element = $compile(angular.element(template))($scope)
$scope.$digest()
isolate = $element.isolateScope()
it 'updates the search-bar', ->
$scope.query = "Test query"
$scope.$digest()
assert.equal($scope.searchtext, $scope.query)
assert.equal(isolate.searchtext, $scope.query)
it 'calls the given search function', ->
$scope.query = "Test query"
$scope.$digest()
$element.triggerHandler('submit')
isolate.searchtext = "Test query"
isolate.$digest()
$element.find('form').triggerHandler('submit')
sinon.assert.calledWith($scope.update, "Test query")
it 'calls the given clear function', ->
......@@ -44,10 +46,18 @@ describe 'h.directives', ->
assert($scope.clear.called)
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.$digest()
$element.find('.simple-search-clear').click()
assert.equal($scope.searchtext, '')
sinon.assert.calledOnce($scope.update)
$scope.query = ""
$scope.$digest()
sinon.assert.calledOnce($scope.clear)
it 'adds a class to the form when there is no input value', ->
$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