Commit 794c80ec authored by Randall Leeds's avatar Randall Leeds

Move the heatmap outside the sidebar

Inject the heatmap into the host frame using the default embed script.

It will be possible after this to make vanilla embed codes available
which can be used for headless operation or inserting the heatmap in
guest frames.

Fundamentally, the heatmap is altered to behave as though it's inside
the same frame as the annotations. That makes it easier to access
the highlights directly, rather than the pre-resolved jquery that
was being serialized across the bridge before.

Since it is now necessary for the reply counts to be known outside the
sidebar, the bridge is changed to support updating annotations and the
sidebar application is changed to bubble updates through ancestors.

Since iframes cannot have visible overflow the z-index constraints
required to have the heatmap still appear attached to the sidebar
dictated that the controlbar move outside as well.

Adds query parameters when embedding to create embed codes that have
no plugins loaded. This is useful for custom embeddings.

Lastly, I took this opportunity to improve the up/down buckets with the
help of the jquery scrollintoview plugin, which should ensure that
this function works even in situations with nested scrollable panes.

Also, closes #464.
parent a03af03f
......@@ -510,34 +510,6 @@ blockquote {
}
}
//Controlbar Icons
.alwaysonhighlights-icon {
@include fonticon("\e01b", left);
color: rgb(211, 211, 211);
font-size: 18px;
bottom: 1px;
left: 3px;
position: absolute;
}
.highlighter-icon {
@include fonticon("\e01a", left);
color: rgb(211, 211, 211);
font-size: 18px;
bottom: 1px;
left: 3px;
position: absolute;
}
.commenter-icon {
@include fonticon("\e01e", left);
color: rgb(211, 211, 211);
font-size: 18px;
bottom: 1px;
left: 3px;
position: absolute;
}
//VISIBILITY
.visibility {
.dropdown-toggle {
......
@import 'base';
@import 'compass/css3/user-interface';
@import 'compass/layout/stretching';
@import 'compass/reset/utilities';
......@@ -64,6 +66,137 @@
}
//HEATMAP STUFF////////////////////////////////
.annotator-heatmap {
font-size: $baseFontSize;
line-height: $baseLineHeight;
margin-top: 2px;
position: absolute;
z-index: 2;
overflow: hidden;
height: 100%;
width: $heatmap-width + 17px;
top: 2em;
left: 0;
& > * {
margin-top: -2em;
}
svg {
@include stretch-y;
background: hsla(0, 0, 0, .1);
border-left: solid 1px $grayLighter;
height: 100%;
left: 17px;
width: $heatmap-width;
}
}
.heatmap-pointer {
@include user-select(none);
@include transition-property(left);
@include transition-duration(.2s);
font-size: $baseFontSize;
line-height: $baseLineHeight;
margin-top: -3em; // TODO: Less janking v-align
// color: rgb(238, 238, 238);
color: #666;
left: 0;
position: absolute;
height: 20.1px;
width: 40.1px;
vertical-align: middle;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
text-align: center;
cursor: pointer;
.svg {
width: 100%;
height: 100%;
background-image: url("../images/side_tab.svg");
background-position: center center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.label {
font-weight: bold;
font-family: $sansFontFamily;
font-size: 13.1px;
left: 5px;
right: 2px;
position: absolute;
bottom: 1px;
}
&:hover {
left: 2px;
}
&.lower, &.upper {
@include single-transition(margin-top, .2s);
left: 7px;
width: 33.1px;
height: 26.1px;
.label {
left: 0;
right: 0;
}
&:hover {
left: 7px;
}
}
&.upper {
.svg {
background-image: url("../images/up_tab.svg");
}
.label {
bottom: 1px;
}
&:hover {
margin-top: -3.2em;
}
}
&.lower {
.svg {
background-image: url("../images/down_tab.svg");
}
.label {
top: 1px;
}
&:hover {
margin-top: -2.8em;
}
}
&.flip {
@include rotateY(180deg);
.label {
@include rotateY(180deg);
}
}
&.commenter {
@include single-transition(margin-top, .2s);
left: 3px;
width: 37px;
.svg {
background-image: url("../images/comment_tab.svg");
}
.label {
bottom: 1px;
}
&:hover {
margin-top: -3.2em;
}
}
}
//HIGHLIGHTS////////////////////////////////
.annotator-highlights-always-on .annotator-hl,
......@@ -96,7 +229,8 @@
display: none;
}
//IFRAME////////////////////////////////
// Sidebar
.annotator-frame {
@include reset-box-model;
height: 100%;
......@@ -109,6 +243,14 @@
&.annotator-collapsed {
margin-left: -$heatmap-width - 17px;
}
& > iframe {
@include reset-box-model;
height: 100%;
width: 100%;
position: absolute;
}
}
.annotator-no-transition {
......@@ -116,6 +258,101 @@
}
//CONTROLBAR STUFF////////////////////////////////
.annotator-toolbar {
@include single-transition(height, .25s, ease, .25s);
font-size: $baseFontSize;
line-height: $baseLineHeight;
position: absolute;
overflow: hidden;
width: 40px;
height: 100px;
z-index: 1;
left: 7px;
top: 2em;
z-index: 3;
a, a:hover, a:active, a:visited {
color: $grayLighter;
text-decoration: none;
}
ul, li {
@include box-sizing(border-box);
@include reset-box-model;
@include reset-list-style;
font-size: $baseFontSize;
line-height: $baseLineHeight;
}
}
.annotator-toolbar.annotator-hide {
height: 0px;
&:hover {
height: 100px;
}
}
.annotator-toolbar li {
position: relative;
left: 1px;
width: 25px;
height: 25px;
padding: 5px;
margin-top: 5px;
box-shadow: 1px 1px 1px 1px;
background-color: white;
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
&:active {
top:1px;
box-shadow: 1px 1px 1px 0px;
}
a.pushed {
color: $hypothered;
}
}
// Toolbar Icons
.alwaysonhighlights-icon {
@include fonticon("\e01b", left);
color: rgb(211, 211, 211);
font-size: 18px;
bottom: 1px;
left: 3px;
position: absolute;
// text-shadow: -0.1em -0.1em #999;
}
.highlighter-icon {
@include fonticon("\e01a", left);
color: rgb(211, 211, 211);
font-size: 18px;
bottom: 1px;
left: 3px;
position: absolute;
// text-shadow: -0.1em -0.1em #999;
}
.commenter-icon {
@include fonticon("\e01e", left);
color: rgb(211, 211, 211);
font-size: 18px;
bottom: 1px;
left: 3px;
position: absolute;
// text-shadow: -0.1em -0.1em #999;
}
/*
Mobile layout
240-479 px
......
@import 'compass';
@import 'compass/css3/transform';
@import 'compass/css3/user-interface';
@import 'compass/layout/stretching';
@import 'compass/reset/utilities';
......@@ -151,169 +150,6 @@ svg { -webkit-tap-highlight-color: rgba(255, 255, 255, 0); }
}
}
//CONTROLBAR STUFF////////////////////////////////
.controlbar {
position: absolute;
overflow: hidden;
width: 40px;
height: 100px;
z-index: 1;
left: 1px;
top: 100%;
-moz-transition:height .25s, -moz-transform .25s;
-webkit-transition:height .25s, -webkit-transform .25s;
-o-transition:height .25s, -o-transform .25s;
transition:height .25s, transform .25s;
}
.hiddencontrolbar {
@extend .controlbar;
height: 0px;
}
.controlbarbutton {
position: relative;
left: 1px;
width: 25px;
height: 25px;
padding: 5px;
margin-top: 5px;
box-shadow: 1px 1px 1px 1px;
color: gray;
background-color: white;
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
&:active {
top:1px;
box-shadow: 1px 1px 1px 0px;
}
a.pushed {
color: $hypothered;
}
}
//HEATMAP STUFF////////////////////////////////
.annotator-heatmap {
@include stretch-y;
height: 100%;
svg {
@include stretch-y;
background: hsla(0, 0, 0, .1);
border-left: solid 1px $grayLighter;
height: 100%;
left: 17px;
width: $heatmap-width;
}
}
.heatmap-pointer {
@include user-select(none);
@include transition-property(left);
@include transition-duration(.2s);
margin-top: -1em; // TODO: Less janking v-align
// color: rgb(238, 238, 238);
color: #666;
left: 0;
position: absolute;
height: 20.1px;
width: 40.1px;
vertical-align: middle;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
text-align: center;
cursor: pointer;
.svg {
width: 100%;
height: 100%;
background-image: url("../images/side_tab.svg");
background-position: center center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.label {
font-weight: bold;
font-family: $sansFontFamily;
font-size: 13.1px;
left: 5px;
right: 2px;
position: absolute;
bottom: 1px;
}
&:hover {
left: 2px;
}
&.lower, &.upper {
@include single-transition(margin-top, .2s);
left: 7px;
width: 33.1px;
height: 26.1px;
.label {
left: 0;
right: 0;
}
&:hover {
left: 7px;
}
}
&.upper {
.svg {
background-image: url("../images/up_tab.svg");
}
.label {
bottom: 1px;
}
&:hover {
margin-top: -1.2em;
}
}
&.lower {
.svg {
background-image: url("../images/down_tab.svg");
}
.label {
top: 1px;
}
&:hover {
margin-top: -.8em;
}
}
&.flip {
@include rotateY(180deg);
.label {
@include rotateY(180deg);
}
}
&.commenter {
@include single-transition(margin-top, .2s);
left: 3px;
width: 37px;
.svg {
background-image: url("../images/comment_tab.svg");
}
.label {
bottom: 1px;
}
&:hover {
margin-top: -1.2em;
}
}
}
//SHEET////////////////////////////////
//This comes down from under the toolbar
......@@ -323,10 +159,7 @@ svg { -webkit-tap-highlight-color: rgba(255, 255, 255, 0); }
max-height: 30em;
overflow: hidden;
position: absolute;
top: 100%;
width: 100%;
left: 4px;
left: $heatmap-width + 8px;
right: 0;
top: 2px;
......@@ -337,7 +170,7 @@ svg { -webkit-tap-highlight-color: rgba(255, 255, 255, 0); }
}
.nav-tabs {
padding-left: 1.3em;
padding-left: 1em;
}
&.collapsed {
......
......@@ -24,86 +24,6 @@ class App
$scope.baseUrl = baseUrl
{plugins, host, providers} = annotator
heatmap = annotator.plugins.Heatmap
$scope.dynamicBucket = true
heatmap.element.bind 'click', =>
return unless drafts.discard()
$location.search('id', null).replace()
$scope.dynamicBucket = true
annotator.showViewer()
heatmap.publish 'updated'
$scope.$digest()
heatmap.subscribe 'updated', =>
elem = d3.select(heatmap.element[0])
return unless elem?.datum()
data = {highlights, offset} = elem.datum()
tabs = elem.selectAll('div').data data
height = $(window).outerHeight(true)
pad = height * .2
{highlights, offset} = elem.datum()
visible = $scope.frame.visible
if $scope.dynamicBucket and visible and $location.path() == '/viewer'
bottom = offset + heatmap.element.height()
annotations = highlights.reduce (acc, hl) =>
if hl.offset.top >= offset and hl.offset.top <= bottom
if hl.data not in acc
acc.push hl.data
acc
, []
annotator.updateViewer annotations
elem.selectAll('.heatmap-pointer')
# Creates highlights corresponding bucket when mouse is hovered
.on 'mouseover', (bucket) =>
unless $location.path() == '/viewer' and $location.search()?.id?
for p in providers
p.channel.notify
method: 'setActiveHighlights'
params: heatmap.buckets[bucket]?.map (a) => a.$$tag
# Gets rid of them after
.on 'mouseout', =>
if $location.path() == '/viewer' and not $location.search()?.id?
for p in providers
p.channel.notify
method: 'setActiveHighlights'
# Does one of a few things when a tab is clicked depending on type
.on 'click', (bucket) =>
d3.event.stopPropagation()
# If it's the upper tab, switch to dynamic bucket mode,
# and scroll to next bucket above
if heatmap.isUpper bucket
$scope.dynamicBucket = true
threshold = offset + heatmap.index[0]
next = highlights.reduce (next, hl) ->
if next < hl.offset.top < threshold then hl.offset.top else next
, 0
console.log "XXX: not scrollTop"
#provider.notify method: 'scrollTop', params: next - pad
# If it's the lower tab, switch do dynamic bucket mode,
# and scroll to next bucket below
else if heatmap.isLower bucket
$scope.dynamicBucket = true
threshold = offset + heatmap.index[0] + height - pad
next = highlights.reduce (next, hl) ->
if threshold < hl.offset.top < next then hl.offset.top else next
, Number.MAX_VALUE
console.log "XXX: not scrollTop"
#provider.notify method: 'scrollTop', params: next - pad
# If it's neither of the above, load the bucket into the viewer
else
return unless drafts.discard()
$scope.dynamicBucket = false
$location.search({'id' : null })
annotator.showViewer heatmap.buckets[bucket]
$scope.$watch 'sheet.collapsed', (newValue) ->
$scope.sheet.tab = if newValue then null else 'login'
......@@ -872,7 +792,6 @@ class Search
refresh = =>
$scope.search_filter = $routeParams.matched
heatmap = annotator.plugins.Heatmap
# Create the regexps for highlighting the matches inside the annotations' bodies
$scope.text_tokens = $routeParams.in_body_text.split ' '
......@@ -889,30 +808,29 @@ class Search
threads = []
$scope.render_order = {}
# Choose the root annotations to work with
for bucket in heatmap.buckets
for annotation in bucket
# The annotation itself is a hit.
thread = annotation.thread
if annotation.id in $scope.search_filter
threads.push thread
$scope.render_order[annotation.id] = []
buildRenderOrder(annotation.id, [thread])
continue
# Maybe it has a child we were looking for
children = thread.flattenChildren()
has_search_result = false
if children?
for child in children
if child.id in $scope.search_filter
has_search_result = true
break
if has_search_result
threads.push thread
$scope.render_order[annotation.id] = []
buildRenderOrder(annotation.id, [thread])
for annotation in $scope.annotations
# The annotation itself is a hit.
thread = annotation.thread
if annotation.id in $scope.search_filter
threads.push thread
$scope.render_order[annotation.id] = []
buildRenderOrder(annotation.id, [thread])
continue
# Maybe it has a child we were looking for
children = thread.flattenChildren()
has_search_result = false
if children?
for child in children
if child.id in $scope.search_filter
has_search_result = true
break
if has_search_result
threads.push thread
$scope.render_order[annotation.id] = []
buildRenderOrder(annotation.id, [thread])
# Re-construct exact order the annotation threads will be shown
......
......@@ -13,12 +13,20 @@ class Annotator.Guest extends Annotator
Document: {}
constructor: (element, options) ->
Gettext.prototype.parse_locale_data annotator_locale_data
super
# Load plugins
for own name, opts of @options
if not @plugins[name]
this.addPlugin(name, opts)
@frame = $('<div></div>')
.appendTo(@wrapper)
.addClass('annotator-frame annotator-outer annotator-collapsed')
unless @options.light
@toolbar = new Annotator.Toolbar()
@toolbar.element.appendTo(@frame)
delete @options.app
delete @options.light
this.addPlugin 'Bridge',
formatter: (annotation) =>
......@@ -36,6 +44,11 @@ class Annotator.Guest extends Annotator
onReady: =>
console.log "Guest functions are ready for #{origin}"
# Load plugins
for own name, opts of @options
if not @plugins[name]
this.addPlugin(name, opts)
# Scan the document text with the DOM Text libraries
this.scanDocument "Annotator initialized"
......@@ -47,20 +60,6 @@ class Annotator.Guest extends Annotator
.bind('onEditorHide', this.onEditorHide)
.bind('onEditorSubmit', this.onEditorSubmit)
.bind('getHighlights', =>
console.log "XXX: Returning early from getHighlights"
return
highlights: $(@wrapper).find('.annotator-hl')
.filter ->
this.offsetWidth > 0 || this.offsetHeight > 0
.map ->
offset: $(this).offset()
height: $(this).outerHeight(true)
data: $(this).data('annotation').$$tag
.get()
offset: $(window).scrollTop()
)
.bind('setActiveHighlights', (ctx, tags=[]) =>
@wrapper.find('.annotator-hl')
.each ->
......@@ -70,17 +69,6 @@ class Annotator.Guest extends Annotator
$(this).removeClass('annotator-hl-active')
)
.bind('setAlwaysOnMode', (ctx, value) =>
@alwaysOnMode = value
this.setPersistentHighlights()
)
.bind('setHighlightingMode', (ctx, value) =>
@highlightingMode = value
if @highlightingMode then @adder.hide()
this.setPersistentHighlights()
)
.bind('adderClick', =>
@onAdderClick @event
)
......@@ -104,72 +92,22 @@ class Annotator.Guest extends Annotator
_setupWrapper: ->
@wrapper = @element
.on 'mouseup', =>
.on 'click', =>
if not @ignoreMouseup
setTimeout =>
unless @selectedRanges?.length then @panel?.notify method: 'back'
unless @selectedRanges?.length
@panel?.notify method: 'back'
this._setupMatching()
@domMatcher.setRootNode @wrapper[0]
this
_setupDocumentEvents: ->
tick = false
timeout = null
touch = false
update = =>
if touch
# Defer updates on mobile until after touch events are over
if timeout then cancelTimeout timeout
timeout = setTimeout =>
timeout = null
do updateFrame
, 400
else
do updateFrame
updateFrame = =>
unless tick
tick = true
requestAnimationFrame =>
tick = false
if touch
# CSS "position: fixed" is hell of broken on most mobile devices
@frame?.css
display: ''
height: $(window).height()
position: 'absolute'
top: $(window).scrollTop()
@panel?.notify method: 'publish', params: 'hostUpdated'
$(window).on 'resize scroll', update
$(document.body).on 'resize scroll', '*', update
if window.PDFView?
# XXX: PDF.js hack
$(PDFView.container).on 'scroll', update
super
# These methods aren't used in the iframe-hosted configuration of Annotator.
_setupViewer: -> this
_setupEditor: -> this
_dragRefresh: =>
d = @drag.delta
@drag.delta = 0
@drag.tick = false
m = parseInt (getComputedStyle @frame[0]).marginLeft
w = -1 * m
m += d
w -= d
@frame.addClass 'annotator-no-transition'
@frame.css
'margin-left': "#{m}px"
width: "#{w}px"
showViewer: (annotation) => @plugins.Bridge.showViewer annotation
showEditor: (annotation) => @plugins.Bridge.showEditor annotation
updateViewer: (annotations) => @plugins.Bridge.updateViewer annotations
checkForStartSelection: (event) =>
# Override to prevent Annotator choking when this ties to access the
......@@ -177,6 +115,7 @@ class Annotator.Guest extends Annotator
# is needed for preventing the panel from closing while annotating.
unless event and this.isAnnotator(event.target)
@mouseIsDown = true
confirmSelection: ->
return true unless @selectedRanges.length is 1
......@@ -203,16 +142,14 @@ class Annotator.Guest extends Annotator
# annotation = this.createAnnotation()
#
# Create an empty annotation manually instead
annotation = {}
annotation = {inject: true}
annotation = this.setupAnnotation annotation
$(annotation.highlights).addClass 'annotator-hl'
# Tell the sidebar about the new annotation
@plugins.Bridge.injectAnnotation annotation
# Switch view to show the new annotation
this.showViewer [ annotation ]
# Notify listeners
this.publish 'beforeAnnotationCreated', annotation
this.publish 'annotationCreated', annotation
else
super event
......@@ -238,7 +175,7 @@ class Annotator.Guest extends Annotator
# Tell sidebar to show the viewer for these annotations
this.showViewer annotations
setPersistentHighlights: ->
setPersistentHighlights: (state) ->
body = $('body')
markerClass = 'annotator-highlights-always-on'
if @alwaysOnMode or @highlightingMode
......@@ -246,6 +183,51 @@ class Annotator.Guest extends Annotator
else
body.removeClass markerClass
addComment: ->
sel = @selectedRanges # 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.
@selectedRanges = []
this.onAdderClick() # Open editor (with 0 targets)
@selectedRanges = sel # restore the selection
createFakeCommentRange: ->
posSelector =
type: "TextPositionSelector"
start: @domMapper.corpus.length - 1
end: @domMapper.corpus.length
anchor = this.findAnchorFromPositionSelector selector: [posSelector]
anchor.range
# Override for setupAnnotation
setupAnnotation: (annotation) ->
# Set up annotation as usual
annotation = super(annotation)
# Does it have proper highlights?
unless annotation.highlights?.length or annotation.references?.length or annotation.target?.length
# No highlights and no references means that this is a comment,
# or re-attachment has failed, but we'll skip orphaned annotations.
# Get a fake range at the end of the document, and highlight it
range = this.createFakeCommentRange()
hl = this.highlightRange range
# Register this highlight for the annotation, and vica versa
$.merge annotation.highlights, hl
$(hl).data('annotation', annotation)
annotation
# Open the sidebar
showFrame: ->
@panel?.notify method: 'open'
# Close the sidebar
hideFrame: ->
@panel?.notify method: 'back'
addToken: (token) =>
@api.notify
method: 'addToken'
......
......@@ -9,13 +9,6 @@ class Annotator.Host extends Annotator.Guest
tick: false
constructor: (element, options) ->
Gettext.prototype.parse_locale_data annotator_locale_data
@app = options.app
delete options.app
super
# Create the iframe
if document.baseURI and window.PDFView?
# XXX: Hack around PDF.js resource: origin. Bug in jschannel?
......@@ -25,12 +18,18 @@ class Annotator.Host extends Annotator.Guest
# XXX: Hack for missing window.location.origin in FF
hostOrigin ?= window.location.protocol + "//" + window.location.host
@frame = $('<iframe></iframe>')
.css(display: 'none')
.attr('src', "#{@app}#/?xdm=#{encodeURIComponent(hostOrigin)}")
.appendTo(@wrapper)
.addClass('annotator-frame annotator-outer annotator-collapsed')
.bind 'load', => @frame.css('display', '')
app = $('<iframe></iframe>')
.attr('src', "#{options.app}#/?xdm=#{encodeURIComponent(hostOrigin)}")
super
app.appendTo(@frame)
if @toolbar
@toolbar.hide()
app
.on('mouseenter', => @toolbar.show())
.on('mouseleave', => @toolbar.hide())
_setupXDM: (options) ->
channel = super
......@@ -63,22 +62,6 @@ class Annotator.Host extends Annotator.Guest
window.requestAnimationFrame this._dragRefresh
)
.bind('addComment', (ctx) =>
sel = @selectedRanges # Save the selection
adderShown = @adder.is ":visible" # Save the state of adder icon
# 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.
@selectedRanges = []
this.onAdderClick() # Open editor (with 0 targets)
setTimeout (=> # At some point, later
@selectedRanges = sel # restore the selection
if adderShown then @adder.show() # restore the state of addder icon
), 200
)
.bind('getMaxBottom', =>
sel = '*' + (":not(.annotator-#{x})" for x in [
'adder', 'outer', 'notice', 'filter', 'frame'
......@@ -100,10 +83,6 @@ class Annotator.Host extends Annotator.Guest
Math.max.apply(Math, all)
)
.bind('scrollTop', (ctx, y) =>
$('html, body').stop().animate {scrollTop: y}, 600
)
.bind('setDrag', (ctx, drag) =>
@drag.enabled = drag
@drag.last = null
......@@ -222,4 +201,4 @@ class Annotator.Host extends Annotator.Guest
this.subscribe('annotationEditorSubmit', save)
# Display the editor.
this.showEditor(annotation, position)
\ No newline at end of file
this.showEditor(annotation, position)
......@@ -49,6 +49,9 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
# Connected bridge links
links: null
# Annotations currently being updated -- used to avoid event callback loops
updating: null
constructor: (elem, options) ->
if options.window?
# Pull the option out and restore it after the super constructor is
......@@ -63,6 +66,7 @@ class Annotator.Plugin.Bridge extends Annotator.Plugin
@cache = {}
@links = []
@updating = {}
pluginInit: ->
$(window).on 'message', this._onMessage
......
$ = Annotator.$
class Annotator.Plugin.Heatmap extends Annotator.Plugin
# prototype constants
BUCKET_THRESHOLD_PAD: 40
......@@ -38,10 +40,40 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
# index for fast hit detection in the buckets
index: []
# whether to update the viewer as the window is scrolled
dynamicBucket: true
constructor: (element, options) ->
super $(@html), options
this._rebaseUrls()
@element.appendTo element
if @options.container?
$(@options.container).append @element
else
$(element).append @element
pluginInit: ->
return unless d3?
events = [
'annotationCreated', 'annotationUpdated', 'annotationDeleted',
'annotationsLoaded'
]
for event in events
@annotator.subscribe event, this._update
@element.on 'click', (event) =>
event.stopPropagation()
this._fillDynamicBucket()
@dynamicBucket = true
@annotator.showFrame()
$(window).on 'resize scroll', this._update
$(document.body).on 'resize scroll', '*', this._update
if window.PDFView?
# XXX: PDF.js hack
$(PDFView.container).on 'scroll', this._update
_rebaseUrls: ->
# We can't rely on browsers to implement the xml:base property correctly.
......@@ -62,7 +94,7 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
rect.attr('fill', fill)
rect.attr('filter', filter)
_collate: (a, b) =>
_collate: (a, b) ->
for i in [0..a.length-1]
if a[i] < b[i]
return -1
......@@ -77,11 +109,10 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
.interpolate(d3.interpolateHcl)
c(v).toString()
updateHeatmap: (data) =>
return unless d3?
wrapper = this.element.offsetParent()
{highlights, offset} = data
_update: =>
wrapper = @annotator.wrapper
highlights = wrapper.find('.annotator-hl')
defaultView = wrapper[0].ownerDocument.defaultView
# Keep track of buckets of annotations above and below the viewport
above = []
......@@ -89,10 +120,10 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
comments = []
# Construct control points for the heatmap highlights
points = highlights.reduce (points, hl, i) =>
x = hl.offset.top - wrapper.offset().top - offset
h = hl.height
d = hl.data
points = highlights.toArray().reduce (points, hl, i) =>
d = $(hl).data('annotation')
x = $(hl).offset().top - wrapper.offset().top - defaultView.pageYOffset
h = $(hl).outerHeight(true)
# XXX: Hacky stuff before unattached annotations V2
# Detect comments and push them into a separate bucket
......@@ -240,7 +271,7 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
[offsets[1], i, 1, 1e-6] ]
# Update the data bindings
element = d3.select(@element[0]).datum(data)
element = d3.select(@element[0]).datum(highlights)
# Update gradient stops
opacity = d3.scale.pow().domain([0, max]).range([.1, .6]).exponent(2)
......@@ -273,6 +304,71 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
tabs.enter().append('div')
.classed('heatmap-pointer', true)
# Creates highlights corresponding bucket when mouse is hovered
.on 'mousemove', (bucket) =>
highlights = wrapper.find('.annotator-hl')
highlights.toArray().forEach (hl) =>
if $(hl).data('annotation') in @buckets[bucket]
$(hl).addClass('annotator-hl-active')
else if not $(hl).hasClass('annotator-hl-temporary')
$(hl).removeClass('annotator-hl-active')
# Gets rid of them after
.on 'mouseout', =>
highlights = wrapper.find('.annotator-hl')
highlights.removeClass('annotator-hl-active')
# Does one of a few things when a tab is clicked depending on type
.on 'click', (bucket) =>
highlights = wrapper.find('.annotator-hl')
pad = defaultView.innerHeight * .2
# If it's the upper tab, scroll to next bucket above
if @isUpper bucket
threshold = defaultView.pageYOffset
{next} = highlights.toArray().reduce (acc, hl) ->
{pos, next} = acc
if pos < $(hl).offset().top < threshold
pos: $(hl).offset().top
next: $(hl)
else
acc
, {pos: 0, next: null}
next?.scrollintoview
complete: ->
if this.parentNode is this.ownerDocument
scrollable = $(this.ownerDocument.body)
else
scrollable = $(this)
top = scrollable.scrollTop()
scrollable.stop().animate {scrollTop: top - pad}, 300
# If it's the lower tab, scroll to next bucket below
else if @isLower bucket
threshold = defaultView.pageYOffset + defaultView.innerHeight - pad
{next} = highlights.toArray().reduce (acc, hl) ->
{pos, next} = acc
if threshold < $(hl).offset().top < pos
pos: $(hl).offset().top
next: $(hl)
else
acc
, {pos: Number.MAX_VALUE, next: null}
next?.scrollintoview
complete: ->
if this.parentNode is this.ownerDocument
scrollable = $(this.ownerDocument.body)
else
scrollable = $(this)
top = scrollable.scrollTop()
scrollable.stop().animate {scrollTop: top + pad}, 300
# If it's neither of the above, load the bucket into the viewer
else
d3.event.stopPropagation()
@dynamicBucket = false
annotator.showViewer @buckets[bucket]
tabs.exit().remove()
tabs
......@@ -289,7 +385,24 @@ class Annotator.Plugin.Heatmap extends Annotator.Plugin
.style 'display', (d) =>
if (@buckets[d].length is 0) then 'none' else ''
this.publish('updated')
if @dynamicBucket
this._fillDynamicBucket()
_fillDynamicBucket: =>
top = window.pageYOffset
bottom = top + $(window).innerHeight()
highlights = @annotator.wrapper.find('.annotator-hl')
visible = highlights.toArray().reduce (acc, hl) =>
if $(hl).offset().top >= top and $(hl).offset().top <= bottom
if $(hl).data('annotation') not in acc
acc.push $(hl).data('annotation')
else
annotation = $(hl).data('annotation')
if not (annotation.target?.length or annotation.references?.length)
acc.push annotation
acc
, []
@annotator.updateViewer visible
isUpper: (i) => i == 1
isLower: (i) => i == @index.length - 3
......
class Hypothesis extends Annotator
events:
serviceDiscovery: 'serviceDiscovery'
'annotationCreated': 'updateAncestors'
'annotationUpdated': 'updateAncestors'
'annotationDeleted': 'updateAncestors'
'serviceDiscovery': 'serviceDiscovery'
# Plugin configuration
options:
noMatching: true
Discovery: {}
Heatmap: {}
Permissions:
permissions:
read: ['group:__world__']
......@@ -83,13 +85,20 @@ 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', 'diffCaseOnly', 'quote', 'ranges', 'target', 'id', 'uri']
whitelist = [
'diffHTML', 'inject', 'quote', 'ranges', 'target', 'id', 'references',
'uri', 'diffCaseOnly'
]
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 = {}
......@@ -168,30 +177,6 @@ class Hypothesis extends Annotator
this.subscribe 'annotationDeleted', (a) =>
$rootScope.annotations = $rootScope.annotations.filter (b) -> b isnt a
# Update the heatmap when the host is updated or annotations are loaded
bridge = @plugins.Bridge
heatmap = @plugins.Heatmap
threading = @threading
updateOn = [
'hostUpdated'
'annotationsLoaded'
'annotationCreated'
'annotationDeleted'
]
for event in updateOn
this.subscribe event, =>
console.log "XXX: ignoring annotator event"
return
@provider.call
method: 'getHighlights'
success: ({highlights, offset}) ->
heatmap.updateHeatmap
highlights:
for hl in highlights when hl.data
annotation = bridge.cache[hl.data]
angular.extend hl, data: annotation
offset: offset
# Reload the route after annotations are loaded
this.subscribe 'annotationsLoaded', -> $route.reload()
......@@ -294,7 +279,9 @@ class Hypothesis extends Annotator
getHtmlQuote: (quote) -> quote
# Do nothing in the app frame, let the host handle it.
setupAnnotation: (annotation) -> annotation
setupAnnotation: (annotation) ->
annotation.highlights = []
annotation
sortAnnotations: (a, b) ->
a_upd = if a.updated? then new Date(a.updated) else new Date()
......@@ -437,6 +424,15 @@ 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?
@element.injector().get('$timeout') (=> this.updateAnnotation rel), 10
break # Only the nearest existing ancestor, the rest is by induction.
serviceDiscovery: (options) =>
@options.Store ?= {}
angular.extend @options.Store, options
......
$ = Annotator.$
class Annotator.Toolbar extends Annotator.Widget
html: '<div class="annotator-toolbar"></div>'
options:
items: [
"title": "Show Annotations"
"class": "alwaysonhighlights-icon"
"click": (event) ->
event.preventDefault()
event.stopPropagation()
state = not window.annotator.alwaysOnMode
window.annotator.alwaysOnMode = state
window.annotator.setPersistentHighlights()
if state
$(event.target).addClass('pushed')
else
$(event.target).removeClass('pushed')
,
"title": "Highlighting Mode"
"class": "highlighter-icon"
"click": (event) ->
event.preventDefault()
event.stopPropagation()
state = not window.annotator.highlightingMode
window.annotator.highlightingMode = state
window.annotator.setPersistentHighlights()
if state
$(event.target).addClass('pushed')
else
$(event.target).removeClass('pushed')
,
"title": "New Comment"
"class": "commenter-icon"
"click": (event) ->
event.preventDefault()
event.stopPropagation()
window.annotator.addComment()
]
constructor: (options) ->
super $(@html)[0], options
@buttons = @options.items.reduce (buttons, item) =>
button = $('<a></a>')
.attr('href', '')
.attr('title', item.title)
.on('click', item.click)
.addClass(item.class)
.data('state', false)
buttons.add button
, $()
@element
.append(@buttons)
.wrapInner('<ul></ul>')
@buttons.wrap('<li></li>')
show: ->
@element.removeClass @classes.hide
this
hide: ->
@element.addClass @classes.hide
this
/*!
* jQuery scrollintoview() plugin and :scrollable selector filter
*
* Version 1.8 (14 Jul 2011)
* Requires jQuery 1.4 or newer
*
* Copyright (c) 2011 Robert Koritnik
* Licensed under the terms of the MIT license
* http://www.opensource.org/licenses/mit-license.php
*/
(function ($) {
var converter = {
vertical: { x: false, y: true },
horizontal: { x: true, y: false },
both: { x: true, y: true },
x: { x: true, y: false },
y: { x: false, y: true }
};
var settings = {
duration: "fast",
direction: "both"
};
var rootrx = /^(?:html)$/i;
// gets border dimensions
var borders = function (domElement, styles) {
styles = styles || (document.defaultView && document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(domElement, null) : domElement.currentStyle);
var px = document.defaultView && document.defaultView.getComputedStyle ? true : false;
var b = {
top: (parseFloat(px ? styles.borderTopWidth : $.css(domElement, "borderTopWidth")) || 0),
left: (parseFloat(px ? styles.borderLeftWidth : $.css(domElement, "borderLeftWidth")) || 0),
bottom: (parseFloat(px ? styles.borderBottomWidth : $.css(domElement, "borderBottomWidth")) || 0),
right: (parseFloat(px ? styles.borderRightWidth : $.css(domElement, "borderRightWidth")) || 0)
};
return {
top: b.top,
left: b.left,
bottom: b.bottom,
right: b.right,
vertical: b.top + b.bottom,
horizontal: b.left + b.right
};
};
var dimensions = function ($element) {
var win = $(window);
var isRoot = rootrx.test($element[0].nodeName);
return {
border: isRoot ? { top: 0, left: 0, bottom: 0, right: 0} : borders($element[0]),
scroll: {
top: (isRoot ? win : $element).scrollTop(),
left: (isRoot ? win : $element).scrollLeft()
},
scrollbar: {
right: isRoot ? 0 : $element.innerWidth() - $element[0].clientWidth,
bottom: isRoot ? 0 : $element.innerHeight() - $element[0].clientHeight
},
rect: (function () {
var r = $element[0].getBoundingClientRect();
return {
top: isRoot ? 0 : r.top,
left: isRoot ? 0 : r.left,
bottom: isRoot ? $element[0].clientHeight : r.bottom,
right: isRoot ? $element[0].clientWidth : r.right
};
})()
};
};
$.fn.extend({
scrollintoview: function (options) {
/// <summary>Scrolls the first element in the set into view by scrolling its closest scrollable parent.</summary>
/// <param name="options" type="Object">Additional options that can configure scrolling:
/// duration (default: "fast") - jQuery animation speed (can be a duration string or number of milliseconds)
/// direction (default: "both") - select possible scrollings ("vertical" or "y", "horizontal" or "x", "both")
/// complete (default: none) - a function to call when scrolling completes (called in context of the DOM element being scrolled)
/// </param>
/// <return type="jQuery">Returns the same jQuery set that this function was run on.</return>
options = $.extend({}, settings, options);
options.direction = converter[typeof (options.direction) === "string" && options.direction.toLowerCase()] || converter.both;
var dirStr = "";
if (options.direction.x === true) dirStr = "horizontal";
if (options.direction.y === true) dirStr = dirStr ? "both" : "vertical";
var el = this.eq(0);
var scroller = el.closest(":scrollable(" + dirStr + ")");
// check if there's anything to scroll in the first place
if (scroller.length > 0)
{
scroller = scroller.eq(0);
var dim = {
e: dimensions(el),
s: dimensions(scroller)
};
var rel = {
top: dim.e.rect.top - (dim.s.rect.top + dim.s.border.top),
bottom: dim.s.rect.bottom - dim.s.border.bottom - dim.s.scrollbar.bottom - dim.e.rect.bottom,
left: dim.e.rect.left - (dim.s.rect.left + dim.s.border.left),
right: dim.s.rect.right - dim.s.border.right - dim.s.scrollbar.right - dim.e.rect.right
};
var animOptions = {};
// vertical scroll
if (options.direction.y === true)
{
if (rel.top < 0)
{
animOptions.scrollTop = dim.s.scroll.top + rel.top;
}
else if (rel.top > 0 && rel.bottom < 0)
{
animOptions.scrollTop = dim.s.scroll.top + Math.min(rel.top, -rel.bottom);
}
}
// horizontal scroll
if (options.direction.x === true)
{
if (rel.left < 0)
{
animOptions.scrollLeft = dim.s.scroll.left + rel.left;
}
else if (rel.left > 0 && rel.right < 0)
{
animOptions.scrollLeft = dim.s.scroll.left + Math.min(rel.left, -rel.right);
}
}
// scroll if needed
if (!$.isEmptyObject(animOptions))
{
if (rootrx.test(scroller[0].nodeName))
{
scroller = $("html,body");
}
scroller
.animate(animOptions, options.duration)
.eq(0) // we want function to be called just once (ref. "html,body")
.queue(function (next) {
$.isFunction(options.complete) && options.complete.call(scroller[0]);
next();
});
}
else
{
// when there's nothing to scroll, just call the "complete" function
$.isFunction(options.complete) && options.complete.call(scroller[0]);
}
}
// return set back
return this;
}
});
var scrollValue = {
auto: true,
scroll: true,
visible: false,
hidden: false
};
$.extend($.expr[":"], {
scrollable: function (element, index, meta, stack) {
var direction = converter[typeof (meta[3]) === "string" && meta[3].toLowerCase()] || converter.both;
var styles = (document.defaultView && document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(element, null) : element.currentStyle);
var overflow = {
x: scrollValue[styles.overflowX.toLowerCase()] || false,
y: scrollValue[styles.overflowY.toLowerCase()] || false,
isRoot: rootrx.test(element.nodeName)
};
// check if completely unscrollable (exclude HTML element because it's special)
if (!overflow.x && !overflow.y && !overflow.isRoot)
{
return false;
}
var size = {
height: {
scroll: element.scrollHeight,
client: element.clientHeight
},
width: {
scroll: element.scrollWidth,
client: element.clientWidth
},
// check overflow.x/y because iPad (and possibly other tablets) don't dislay scrollbars
scrollableX: function () {
return (overflow.x || overflow.isRoot) && this.width.scroll > this.width.client;
},
scrollableY: function () {
return (overflow.y || overflow.isRoot) && this.height.scroll > this.height.client;
}
};
return direction.y && size.scrollableY() || direction.x && size.scrollableX();
}
});
})(jQuery);
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