Commit 957ad49c authored by Randall Leeds's avatar Randall Leeds

Overhaul the threading, sort, and view further

Lots of changes to massively simplify things and make them much more
performant.

- The thread directive gets a few changes
  - Cleared up confusing tuple unpacking in favor of named properties
    in the render queue as per the suggestion of @aron
  - The replies and annotation are set at once. Since the replies in
    turn render lazily, this should be fast enough for all but an
    unwieldy number of threads. If we hit that wall, we'll have to do
    some filtering / pagination.
  - The directive is now triggered only on attributes and parses the
    attribute value to get the thread
- The threads are rendered from a single thread root rather than using
  a global list of annotations that must be watched for its threads to
  be extracted. This results in a huge performance gain because this
  thread container has a stable set of children.
  - When the first `annotationsLoaded` callback arrives, we set a new
    root from the result of asking the threading plugin to thread them
  - Lots of hackery during store reloads goes away because we just nuke
    the thread root and reload
- The application controller does not have to modify a root annotation
  set at all anymore
- Use of the root scope in the controllers goes away entirely
- The ongoing edit is now stored in the scope again so that it can be
  be pinned to the top of the thread list
- The editor view and controller go away completely because the top
  level edit is now pinned to the top of the list, removing the need
  to re-render annotations between editing and viewing
- The selected annotations are now a hash of annotation ids to true
  values if the annotation is selected, or null if the selection is
  empty
- An ng-show is used to show/hide the selected/unselected annotations,
  further reducing re-renders
- The view state is gone complete and now there is only sort
- The sort is not applied programmatically in the controller, but a
  simple watch on the name of the sort is used to set the predicate
  and reverse settings as scope properties that feed into an orderBy
  filter on the thread list, reducing the number of extra watches we
  have to do and further reducing the code
- The sort is no longer an animated dropdown, but sits at the top of
  the list always (I assume we can style this better, so have at it)
- Unnecessary helper and logging functions are removed from the
  annotator service because silence is golden
parent dc477032
......@@ -38,9 +38,6 @@ configure = [
$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'
......
......@@ -11,14 +11,14 @@ imports = [
class App
this.$inject = [
'$element', '$filter', '$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'
]
constructor: (
$element, $filter, $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
) ->
{plugins, host, providers} = annotator
......@@ -40,7 +40,6 @@ class App
switch action
when 'create', 'update'
plugins.Store?._onLoadAnnotations data
$scope.$digest()
when 'delete'
for annotation in data
container = annotator.threading.getContainer annotation.id
......@@ -51,7 +50,7 @@ class App
plugins.Store.annotations[index..index] = [] if index > -1
annotator.deleteAnnotation container.message
$rootScope.$digest()
$scope.$digest()
initIdentity = (persona) ->
"""Initialize identity callbacks."""
......@@ -80,13 +79,10 @@ class App
initStore = ->
"""Initialize the storage component."""
Store = plugins.Store
delete plugins.Store
delete $scope.threadRoot
annotator.addPlugin 'Store', annotator.options.Store
annotator.threading.thread []
annotator.threading.idTable = {}
_id = $route.current.params.id
_promise = null
......@@ -114,36 +110,6 @@ 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
initUpdater = (failureCount=0) ->
"""Initialize the websocket used for realtime updates."""
......@@ -226,9 +192,6 @@ 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'
......@@ -248,22 +211,31 @@ class App
initStore()
initUpdater()
annotator.subscribe 'annotationCreated', (annotation) ->
unless annotation.thread.parent
$scope.threadRoot.addChild annotation.thread
if annotation is $scope.ongoingEdit
delete $scope.ongoingEdit
for p in providers
p.channel.notify method: 'onEditorSubmit'
p.channel.notify method: 'onEditorHide'
annotator.subscribe 'annotationDeleted', (annotation) ->
if annotation is $scope.ongoingEdit
delete $scope.ongoingEdit
for p in providers
p.channel.notify method: 'onEditorHide'
annotator.subscribe 'annotationsLoaded', (annotations) ->
$rootScope.$evalAsync -> # Give plugins time to react
$rootScope.annotations = plugins.Store.annotations
$rootScope.applyView $rootScope.viewState.view
$rootScope.applySort $rootScope.viewState.sort
$scope.threadRoot ?= annotator.threading.thread(annotations)
$scope.$digest()
annotator.subscribe 'serviceDiscovery', (options) ->
annotator.options.Store ?= {}
angular.extend annotator.options.Store, options
storeReady.resolve()
$scope.$watch 'annotations', (annotations=[]) ->
threading = annotator.threading
$scope.threads = for a in annotations when a.thread
if a.thread.parent then continue else a.thread
$scope.$watch 'persona', initIdentity
$scope.$watch 'socialView.name', (newValue, oldValue) ->
......@@ -285,6 +257,14 @@ class App
for p in annotator.providers
p.channel.notify method: 'setActiveHighlights'
$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
......@@ -297,66 +277,6 @@ class App
filter = streamfilter.getFilter()
sock.send(JSON.stringify({filter}))
$rootScope.viewState =
sort: 'Location'
view: 'Document'
# "View" -- which annotations are shown
$rootScope.applyView = (view) ->
$rootScope.viewState.view = view
switch view
when 'Document'
if $rootScope.viewState.sort is 'Location'
dynamic = true
else
dynamic = false
when 'Selection'
if $scope.annotations.length is 0
return $rootScope.applyView 'Document'
dynamic = false
when 'Search'
dynamic = false
else
throw new Error "Unknown view requested: " + view
for p in providers
p.channel.notify method: 'setDynamicBucketMode', params: dynamic
# "Sort" -- order annotations are shown
$rootScope.applySort = (sort) ->
$rootScope.viewState.sort = sort
[predicate, reverse] = switch sort
when 'Newest' then ['updated', true]
when 'Oldest' then ['updated', false]
when 'Location' then ['target[0].pos.top', false]
if sort is 'Location'
dynamic = $rootScope.viewState.view is 'Document'
byComment = angular.bind(annotator, annotator.isComment)
sorted = $filter('orderBy')(sorted, byComment)
else
dynamic = false
delete $scope.annotations
sorted = $filter('orderBy')($scope.annotations, predicate, reverse)
$scope.annotations = sorted
for p in providers
p.channel.notify method: 'setDynamicBucketMode', params: dynamic
$rootScope.$on '$routeChangeSuccess', (event, next, current) ->
unless next.$$route? then return
$scope.search.query = $location.search()['q']
unless next.$$route.originalPath is '/stream'
if current and next.$$route.originalPath is '/a/:id'
$scope.reloadAnnotations()
$scope.loadMore = (number) ->
unless $scope.updater? then return
sockmsg =
......@@ -367,17 +287,23 @@ class App
sock.send(JSON.stringify(sockmsg))
$scope.authTimeout = ->
delete annotator.ongoing_edit
delete $scope.ongoingEdit
$scope.ongoingHighlightSwitch = false
flash 'info',
'For your security, the forms have been reset due to inactivity.'
$scope.clearSelection = ->
$scope.search.query = ''
$scope.selectedAnnotations = null
$scope.frame = visible: false
$scope.id = identity
$scope.model = persona: undefined
$scope.search =
query: $location.search()['q']
clear: ->
$location.search('q', null)
......@@ -387,57 +313,7 @@ class App
$location.search('q', query or null)
$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.thread = message: 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
......@@ -464,11 +340,11 @@ class AnnotationViewer
class Viewer
this.$inject = [
'$location', '$rootScope', '$routeParams', '$scope',
'$location', '$routeParams', '$scope',
'annotator'
]
constructor: (
$location, $rootScope, $routeParams, $scope,
$location, $routeParams, $scope,
annotator
) ->
if $routeParams.q
......@@ -493,16 +369,13 @@ class Viewer
class Search
this.$inject = ['$filter', '$location', '$rootScope', '$routeParams', '$sce', '$scope',
this.$inject = ['$filter', '$location', '$routeParams', '$sce', '$scope',
'annotator', 'viewFilter']
constructor: ($filter, $location, $rootScope, $routeParams, $sce, $scope,
constructor: ($filter, $location, $routeParams, $sce, $scope,
annotator, viewFilter) ->
unless $routeParams.q
$rootScope.applyView 'Document'
return $location.path('/viewer').replace()
$rootScope.applyView 'Search'
{providers, threading} = annotator
$scope.highlighter = '<span class="search-hl-active">$&</span>'
......@@ -570,7 +443,8 @@ class Search
refresh = =>
query = $routeParams.q
[$scope.matches, $scope.filters] = viewFilter.filter $rootScope.annotations, query
annotations = $scope.threadRoot.flattenChildren()
[$scope.matches, $scope.filters] = viewFilter.filter annotations, query
# Create the regexps for highlighting the matches inside the annotations' bodies
$scope.text_tokens = $scope.filters.text.terms.slice()
$scope.text_regexp = []
......@@ -606,7 +480,7 @@ class Search
# 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
unless root_annotation in 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
......@@ -686,8 +560,6 @@ class Search
hidden += 1
if last_shown? then $scope.ann_info.more_bottom_num[last_shown] = hidden
$scope.threads = threads
$scope.$on '$routeUpdate', refresh
$scope.getThreadId = (id) ->
......@@ -739,7 +611,6 @@ class Search
angular.module('h.controllers', imports)
.controller('AppController', App)
.controller('EditorController', Editor)
.controller('ViewerController', Viewer)
.controller('AnnotationViewerController', AnnotationViewer)
.controller('SearchController', Search)
......@@ -175,7 +175,7 @@ tabReveal = ['$parse', ($parse) ->
]
thread = ['$$rAF', '$filter', '$window', ($$rAF, $filter, $window) ->
thread = ['$$rAF', '$parse', '$window', ($$rAF, $parse, $window) ->
# Helper -- true if selection ends inside the target and is non-empty
ignoreClick = (event) ->
sel = $window.getSelection()
......@@ -193,9 +193,9 @@ thread = ['$$rAF', '$filter', '$window', ($$rAF, $filter, $window) ->
renderFrame = $$rAF ->
renderFrame = null
[scope, data] = renderQueue.shift()
while renderQueue[0]?[0] is scope
angular.extend data, renderQueue.shift()[1]
{scope, data} = renderQueue.shift()
while renderQueue[0]?.scope is scope
angular.extend data, renderQueue.shift().data
angular.extend scope, data
scope.$digest()
......@@ -204,6 +204,7 @@ thread = ['$$rAF', '$filter', '$window', ($$rAF, $filter, $window) ->
link: (scope, elem, attr, ctrl) ->
childrenEditing = {}
threadWatch = $parse(attr.thread)
scope.annotation = null
scope.replies = null
......@@ -214,7 +215,7 @@ thread = ['$$rAF', '$filter', '$window', ($$rAF, $filter, $window) ->
scope.collapsed = !scope.collapsed
scope.$on 'destroy', ->
renderQueue = ([s, _] for [s, _] in renderQueue is s isnt scope)
renderQueue = (item for item in renderQueue if item.scope isnt scope)
scope.$on 'toggleEditing', (event) ->
{$id, editing} = event.targetScope
......@@ -227,19 +228,15 @@ thread = ['$$rAF', '$filter', '$window', ($$rAF, $filter, $window) ->
else
delete childrenEditing[$id]
scope.$watch 'thread', (thread) ->
scope.$watchCollection threadWatch, (thread) ->
return unless thread
annotation = thread.message
renderQueue.push [scope, {annotation}]
replies = thread.children
data = {annotation, replies}
renderQueue.push {scope, data}
render()
scope.$watchCollection 'thread.children', (children) ->
return unless children
replies = $filter('orderBy')(children, 'message.updated', true)
renderQueue.push [scope, {replies}]
render()
restrict: 'C'
scope: true
]
......
......@@ -61,8 +61,12 @@ class Annotation
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
unless (
annotation.references?.length or
annotation.target?.length or
annotation.tags?.length or
annotation.text
)
$window.alert "You can not add a comment without adding some text, or at least a tag."
return
......@@ -146,12 +150,8 @@ class Annotation
$scope.$watch 'model.id', (id) ->
if id?
$scope.thread = annotator.threading.getContainer(id)
# Check if this is a brand new annotation
if drafts.contains $scope.model
$scope.editing = true
$scope.thread = $scope.model.thread
$scope.editing = drafts.contains $scope.model
link = documentHelpers.absoluteURI("/a/#{$scope.model.id}")
$scope.shared_link = link
......
......@@ -159,11 +159,6 @@ class Hypothesis extends Annotator
for action, roles of annotation.permissions
unless userId in roles then roles.push userId
# Remove annotations from the view when they are deleted
this.subscribe 'annotationDeleted', (a) =>
scope = @element.scope()
scope.annotations = scope.annotations.filter (b) -> b isnt a
_setupXDM: (options) ->
$rootScope = @element.injector().get '$rootScope'
......@@ -257,53 +252,37 @@ 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
toggleViewerSelection: (annotations=[]) =>
annotations = annotations.filter (a) -> a?
scope = @element.scope()
# XOR this list to the current selection
list = scope.annotations = scope.annotations.slice()
selected = scope.selectedAnnotations or {}
for a in annotations
index = list.indexOf a
if index isnt -1
list.splice index, 1
if selected[a.id]
delete selected[a.id]
else
list.push a
# View and sort the selection
scope.applyView "Selection"
scope.applySort scope.viewState.sort
selected[a.id] = true
if Object.keys(selected).length
scope.selectedAnnotations = selected
else
scope.selectedAnnotations = null
this
updateViewer: (annotations=[]) =>
annotations = annotations.filter (a) -> a?
scope = @element.scope()
commentFilter = angular.bind(this, this.isComment)
comments = (scope.$root.annotations or []).filter(commentFilter)
scope.annotations = annotations
scope.applySort scope.viewState.sort
scope.annotations = [scope.annotations..., comments...]
# TODO: re-implement
this
showViewer: (annotations=[]) =>
location = @element.injector().get('$location')
location.path('/viewer').replace()
scope = @element.scope()
scope.annotations = annotations
scope.applyView 'Selection'
scope.applySort scope.viewState.sort
selected = {}
for a in annotations
selected[a.id] = true
scope.selectedAnnotations = selected
this.show()
this
addEmphasis: (annotations=[]) =>
annotations = annotations.filter (a) -> a? # Filter out null annotations
......@@ -323,31 +302,9 @@ class Hypothesis extends Annotator
method: 'adderClick'
showEditor: (annotation) =>
@element.injector().get('drafts').add(annotation)
@element.scope().ongoingEdit = annotation
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: =>
......@@ -469,16 +426,6 @@ 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()
......
......@@ -26,13 +26,7 @@ class StreamSearch
terms = searchfilter.generateFacetedFilter $scope.search.query
queryparser.populateFilter streamfilter, terms
$scope.updater?.then (sock) ->
filter = streamfilter.getFilter()
sock.send(JSON.stringify({filter}))
annotator.plugins.Store?.annotations = []
$rootScope.applyView 'Document' # Non-sensical but works for now
$rootScope.applySort 'Newest'
$scope.sort.name = 'Newest'
angular.module('h.streamsearch', imports, configure)
......
......@@ -354,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;
}
}
}
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