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'
......
This diff is collapsed.
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,7 +224,8 @@ repeatAnim = ->
username = ['$filter', '$window', ($filter, $window) ->
link: (scope, elem, attr) ->
scope.$watch 'user', ->
scope.$watch 'user', (user) ->
if user
scope.uname = $filter('persona')(scope.user, 'username')
scope.uclick = (event) ->
......@@ -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)
......
###*
# @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.$watch 'query', (query) ->
return if query is undefined
scope.searchtext = query
_search(scope, {"this": scope.searchtext})
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)
This diff is collapsed.
......@@ -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
@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
......
This diff is collapsed.
......@@ -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
$rootScope.annotations = []
$rootScope.applyView "Document" # Non-sensical, but best for the moment
$rootScope.applySort "Newest"
$scope.sort.name = 'Newest'
$scope.openDetails = (annotation) ->
$scope.shouldShowThread = (container) -> true
$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;
......@@ -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;
&.dropdown {
top: 4px;
}
&.open, :hover > & {
@include transition-duration(.15s);
opacity: 1;
.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 {
& > article markdown {
display: none;
}
.reply-count {
font-style: italic;
}
}
}
}
......@@ -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;
}
& > .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()
$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()
assert.isUndefined($scope.document)
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