Commit 4ebdeebd authored by Nick Stenning's avatar Nick Stenning

Import Annotator sources into h

Rather than using a prebuilt vendored Annotator from our fork, this
commit imports the entire forked Annotator source into the h repository.
Our intent is not to maintain this fork within h, but to remove the fork
repository and incrementally move back to dependence on upstream
Annotator.
parent 5279e095
# Abstract anchor class.
class Anchor
constructor: (@annotator, @annotation, @target
@startPage, @endPage,
@quote, @diffHTML, @diffCaseOnly) ->
unless @annotator? then throw "annotator is required!"
unless @annotation? then throw "annotation is required!"
unless @target? then throw "target is required!"
unless @startPage? then "startPage is required!"
unless @endPage? then throw "endPage is required!"
unless @quote? then throw "quote is required!"
@highlight = {}
# Return highlights for the given page
_createHighlight: (page) ->
throw "Function not implemented"
# Create the missing highlights for this anchor
realize: () =>
return if @fullyRealized # If we have everything, go home
# Collect the pages that are already rendered
renderedPages = [@startPage .. @endPage].filter (index) =>
@annotator.domMapper.isPageMapped index
# Collect the pages that are already rendered, but not yet anchored
pagesTodo = renderedPages.filter (index) => not @highlight[index]?
return unless pagesTodo.length # Return if nothing to do
# Create the new highlights
created = for page in pagesTodo
@highlight[page] = @_createHighlight page
# Check if everything is rendered now
@fullyRealized = renderedPages.length is @endPage - @startPage + 1
# Announce the creation of the highlights
@annotator.publish 'highlightsCreated', created
# Remove the highlights for the given set of pages
virtualize: (pageIndex) =>
highlight = @highlight[pageIndex]
return unless highlight? # No highlight for this page
highlight.removeFromDocument()
delete @highlight[pageIndex]
# Mark this anchor as not fully rendered
@fullyRealized = false
# Announce the removal of the highlight
@annotator.publish 'highlightRemoved', highlight
# Virtualize and remove an anchor from all involved pages
remove: ->
# Go over all the pages
for index in [@startPage .. @endPage]
@virtualize index
anchors = @annotator.anchors[index]
# Remove the anchor from the list
i = anchors.indexOf this
anchors[i..i] = []
# Kill the list if it's empty
delete @annotator.anchors[index] unless anchors.length
# This is called when the underlying Annotator has been udpated
annotationUpdated: ->
# Notify the highlights
for index in [@startPage .. @endPage]
@highlight[index]?.annotationUpdated()
This diff is collapsed.
# Public: Delegator is the base class that all of Annotators objects inherit
# from. It provides basic functionality such as instance options, event
# delegation and pub/sub methods.
class Delegator
# Public: Events object. This contains a key/pair hash of events/methods that
# should be bound. See Delegator#addEvents() for usage.
events: {}
# Public: Options object. Extended on initialisation.
options: {}
# A jQuery object wrapping the DOM Element provided on initialisation.
element: null
# Public: Constructor function that sets up the instance. Binds the @events
# hash and extends the @options object.
#
# element - The DOM element that this intance represents.
# options - An Object literal of options.
#
# Examples
#
# element = document.getElementById('my-element')
# instance = new Delegator(element, {
# option: 'my-option'
# })
#
# Returns a new instance of Delegator.
constructor: (element, options) ->
@options = $.extend(true, {}, @options, options)
@element = $(element)
# Delegator creates closures for each event it binds. This is a private
# registry of created closures, used to enable event unbinding.
@_closures = {}
this.on = this.subscribe
this.addEvents()
# Public: binds the function names in the @events Object to their events.
#
# The @events Object should be a set of key/value pairs where the key is the
# event name with optional CSS selector. The value should be a String method
# name on the current class.
#
# This is called by the default Delegator constructor and so shouldn't usually
# need to be called by the user.
#
# Examples
#
# # This will bind the clickedElement() method to the click event on @element.
# @options = {"click": "clickedElement"}
#
# # This will delegate the submitForm() method to the submit event on the
# # form within the @element.
# @options = {"form submit": "submitForm"}
#
# # This will bind the updateAnnotationStore() method to the custom
# # annotation:save event. NOTE: Because this is a custom event the
# # Delegator#subscribe() method will be used and updateAnnotationStore()
# # will not recieve an event parameter like the previous two examples.
# @options = {"annotation:save": "updateAnnotationStore"}
#
# Returns nothing.
addEvents: ->
for event in Delegator._parseEvents(@events)
this._addEvent event.selector, event.event, event.functionName
# Public: unbinds functions previously bound to events by addEvents().
#
# The @events Object should be a set of key/value pairs where the key is the
# event name with optional CSS selector. The value should be a String method
# name on the current class.
#
# Returns nothing.
removeEvents: ->
for event in Delegator._parseEvents(@events)
this._removeEvent event.selector, event.event, event.functionName
# Binds an event to a callback function represented by a String. A selector
# can be provided in order to watch for events on a child element.
#
# The event can be any standard event supported by jQuery or a custom String.
# If a custom string is used the callback function will not recieve an
# event object as it's first parameter.
#
# selector - Selector String matching child elements. (default: '')
# event - The event to listen for.
# functionName - A String function name to bind to the event.
#
# Examples
#
# # Listens for all click events on instance.element.
# instance._addEvent('', 'click', 'onClick')
#
# # Delegates the instance.onInputFocus() method to focus events on all
# # form inputs within instance.element.
# instance._addEvent('form :input', 'focus', 'onInputFocus')
#
# Returns itself.
_addEvent: (selector, event, functionName) ->
f = if typeof functionName is 'string'
this[functionName]
else
functionName
closure = => f.apply(this, arguments)
if selector == '' and Delegator._isCustomEvent(event)
this.subscribe(event, closure)
else
@element.delegate(selector, event, closure)
@_closures["#{selector}/#{event}/#{functionName}"] = closure
this
# Unbinds a function previously bound to an event by the _addEvent method.
#
# Takes the same arguments as _addEvent(), and an event will only be
# successfully unbound if the arguments to removeEvent() are exactly the same
# as the original arguments to _addEvent(). This would usually be called by
# _removeEvents().
#
# selector - Selector String matching child elements. (default: '')
# event - The event to listen for.
# functionName - A String function name to bind to the event.
#
# Returns itself.
_removeEvent: (selector, event, functionName) ->
closure = @_closures["#{selector}/#{event}/#{functionName}"]
if selector == '' and Delegator._isCustomEvent(event)
this.unsubscribe(event, closure)
else
@element.undelegate(selector, event, closure)
delete @_closures["#{selector}/#{event}/#{functionName}"]
this
# Public: Fires an event and calls all subscribed callbacks with any parameters
# provided. This is essentially an alias of @element.triggerHandler() but
# should be used to fire custom events.
#
# NOTE: Events fired using .publish() will not bubble up the DOM.
#
# event - A String event name.
# params - An Array of parameters to provide to callbacks.
#
# Examples
#
# instance.subscribe('annotation:save', (msg) -> console.log(msg))
# instance.publish('annotation:save', ['Hello World'])
# # => Outputs "Hello World"
#
# Returns itself.
publish: () ->
@element.triggerHandler.apply @element, arguments
this
# Public: Listens for custom event which when published will call the provided
# callback. This is essentially a wrapper around @element.bind() but removes
# the event parameter that jQuery event callbacks always recieve. These
# parameters are unnessecary for custom events.
#
# event - A String event name.
# callback - A callback function called when the event is published.
#
# Examples
#
# instance.subscribe('annotation:save', (msg) -> console.log(msg))
# instance.publish('annotation:save', ['Hello World'])
# # => Outputs "Hello World"
#
# Returns itself.
subscribe: (event, callback) ->
closure = -> callback.apply(this, [].slice.call(arguments, 1))
# Ensure both functions have the same unique id so that jQuery will accept
# callback when unbinding closure.
closure.guid = callback.guid = ($.guid += 1)
@element.bind event, closure
this
# Public: Unsubscribes a callback from an event. The callback will no longer
# be called when the event is published.
#
# event - A String event name.
# callback - A callback function to be removed.
#
# Examples
#
# callback = (msg) -> console.log(msg)
# instance.subscribe('annotation:save', callback)
# instance.publish('annotation:save', ['Hello World'])
# # => Outputs "Hello World"
#
# instance.unsubscribe('annotation:save', callback)
# instance.publish('annotation:save', ['Hello Again'])
# # => No output.
#
# Returns itself.
unsubscribe: ->
@element.unbind.apply @element, arguments
this
# Parse the @events object of a Delegator into an array of objects containing
# string-valued "selector", "event", and "func" keys.
Delegator._parseEvents = (eventsObj) ->
events = []
for sel, functionName of eventsObj
[selector..., event] = sel.split ' '
events.push({
selector: selector.join(' '),
event: event,
functionName: functionName
})
return events
# Native jQuery events that should recieve an event object. Plugins can
# add their own methods to this if required.
Delegator.natives = do ->
specials = (key for own key, val of jQuery.event.special)
"""
blur focus focusin focusout load resize scroll unload click dblclick
mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave
change select submit keydown keypress keyup error
""".split(/[^a-z]+/).concat(specials)
# Checks to see if the provided event is a DOM event supported by jQuery or
# a custom user event.
#
# event - String event name.
#
# Examples
#
# Delegator._isCustomEvent('click') # => false
# Delegator._isCustomEvent('mousedown') # => false
# Delegator._isCustomEvent('annotation:created') # => true
#
# Returns true if event is a custom user event.
Delegator._isCustomEvent = (event) ->
[event] = event.split('.')
$.inArray(event, Delegator.natives) == -1
# Stub the console when not available so that everything still works.
functions = [
"log", "debug", "info", "warn", "exception", "assert", "dir", "dirxml",
"trace", "group", "groupEnd", "groupCollapsed", "time", "timeEnd", "profile",
"profileEnd", "count", "clear", "table", "error", "notifyFirebug", "firebug",
"userObjects"
]
if console?
# Opera's console doesn't have a group function as of 2010-07-01
if not console.group?
console.group = (name) -> console.log "GROUP: ", name
# Webkit's developer console has yet to implement groupCollapsed as of 2010-07-01
if not console.groupCollapsed?
console.groupCollapsed = console.group
# Stub out any remaining functions
for fn in functions
if not console[fn]?
console[fn] = -> console.log _t("Not implemented:") + " console.#{name}"
else
this.console = {}
for fn in functions
this.console[fn] = ->
this.console['error'] = (args...) ->
alert("ERROR: #{args.join(', ')}")
this.console['warn'] = (args...) ->
alert("WARNING: #{args.join(', ')}")
This diff is collapsed.
# I18N
gettext = null
if Gettext?
_gettext = new Gettext(domain: "annotator")
gettext = (msgid) -> _gettext.gettext(msgid)
else
gettext = (msgid) -> msgid
_t = (msgid) -> gettext(msgid)
unless jQuery?.fn?.jquery
console.error(_t("Annotator requires jQuery: have you included lib/vendor/jquery.js?"))
unless JSON and JSON.parse and JSON.stringify
console.error(_t("Annotator requires a JSON implementation: have you included lib/vendor/json2.js?"))
$ = jQuery
Util = {}
# Public: Flatten a nested array structure
#
# Returns an array
Util.flatten = (array) ->
flatten = (ary) ->
flat = []
for el in ary
flat = flat.concat(if el and $.isArray(el) then flatten(el) else el)
return flat
flatten(array)
# Public: Finds all text nodes within the elements in the current collection.
#
# Returns a new jQuery collection of text nodes.
Util.getTextNodes = (jq) ->
getTextNodes = (node) ->
if node and node.nodeType != Node.TEXT_NODE
nodes = []
# If not a comment then traverse children collecting text nodes.
# We traverse the child nodes manually rather than using the .childNodes
# property because IE9 does not update the .childNodes property after
# .splitText() is called on a child text node.
if node.nodeType != Node.COMMENT_NODE
# Start at the last child and walk backwards through siblings.
node = node.lastChild
while node
nodes.push getTextNodes(node)
node = node.previousSibling
# Finally reverse the array so that nodes are in the correct order.
return nodes.reverse()
else
return node
jq.map -> Util.flatten(getTextNodes(this))
# Public: determine the last text node inside or before the given node
Util.getLastTextNodeUpTo = (n) ->
switch n.nodeType
when Node.TEXT_NODE
return n # We have found our text node.
when Node.ELEMENT_NODE
# This is an element, we need to dig in
if n.lastChild? # Does it have children at all?
result = Util.getLastTextNodeUpTo n.lastChild
if result? then return result
else
# Not a text node, and not an element node.
# Could not find a text node in current node, go backwards
n = n.previousSibling
if n?
Util.getLastTextNodeUpTo n
else
null
# Public: determine the first text node in or after the given jQuery node.
Util.getFirstTextNodeNotBefore = (n) ->
switch n.nodeType
when Node.TEXT_NODE
return n # We have found our text node.
when Node.ELEMENT_NODE
# This is an element, we need to dig in
if n.firstChild? # Does it have children at all?
result = Util.getFirstTextNodeNotBefore n.firstChild
if result? then return result
else
# Not a text or an element node.
# Could not find a text node in current node, go forward
n = n.nextSibling
if n?
Util.getFirstTextNodeNotBefore n
else
null
Util.xpathFromNode = (el, relativeRoot) ->
try
result = simpleXPathJQuery.call el, relativeRoot
catch exception
console.log "jQuery-based XPath construction failed! Falling back to manual."
result = simpleXPathPure.call el, relativeRoot
result
Util.nodeFromXPath = (xp, root) ->
steps = xp.substring(1).split("/")
node = root
for step in steps
[name, idx] = step.split "["
idx = if idx? then parseInt (idx?.split "]")[0] else 1
node = findChild node, name.toLowerCase(), idx
node
Util.escape = (html) ->
html
.replace(/&(?!\w+;)/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
# Abstract highlight class
class Highlight
constructor: (@anchor, @pageIndex) ->
@annotator = @anchor.annotator
@annotation = @anchor.annotation
# Mark/unmark this hl as temporary (while creating an annotation)
setTemporary: (value) ->
throw "Operation not implemented."
# Is this a temporary hl?
isTemporary: ->
throw "Operation not implemented."
# TODO: review the usage of the batch parameters.
# Mark/unmark this hl as focused
#
# Value specifies whether it should be focused or not
#
# The 'batch' field specifies whether this call is only one of
# many subsequent calls, which should be executed together.
#
# In this case, a "finalizeHighlights" event will be published
# when all the flags have been set, and the changes should be
# executed.
setFocused: (value, batch = false) ->
throw "Operation not implemented."
# React to changes in the underlying annotation
annotationUpdated: ->
#console.log "In HL", this, "annotation has been updated."
# Remove all traces of this hl from the document
removeFromDocument: ->
throw "Operation not implemented."
# Get the HTML elements making up the highlight
# If you implement this, you get automatic implementation for the functions
# below. However, if you need a more sophisticated control mechanism,
# you are free to leave this unimplemented, and manually implement the
# rest.
_getDOMElements: ->
throw "Operation not implemented."
# Get the Y offset of the highlight. Override for more control
getTop: -> $(@_getDOMElements()).offset().top
# Get the height of the highlight. Override for more control
getHeight: -> $(@_getDOMElements()).outerHeight true
# Get the bottom Y offset of the highlight. Override for more control.
getBottom: -> @getTop() + @getBottom()
# Scroll the highlight into view. Override for more control
scrollTo: -> $(@_getDOMElements()).scrollintoview()
# Scroll the highlight into view, with a comfortable margin.
# up should be true if we need to scroll up; false otherwise
paddedScrollTo: (direction) ->
unless direction? then throw "Direction is required"
dir = if direction is "up" then -1 else +1
where = $(@_getDOMElements())
wrapper = @annotator.wrapper
defaultView = wrapper[0].ownerDocument.defaultView
pad = defaultView.innerHeight * .2
where.scrollintoview
complete: ->
scrollable = if this.parentNode is this.ownerDocument
$(this.ownerDocument.body)
else
$(this)
top = scrollable.scrollTop()
correction = pad * dir
scrollable.stop().animate {scrollTop: top + correction}, 300
# Scroll up to the highlight, with a comfortable margin.
paddedScrollUpTo: -> @paddedScrollTo "up"
# Scroll down to the highlight, with a comfortable margin.
paddedScrollDownTo: -> @paddedScrollTo "down"
Annotator = Annotator || {}
# Public: A simple notification system that can be used to display information,
# warnings and errors to the user. Display of notifications are controlled
# cmpletely by CSS by adding/removing the @options.classes.show class. This
# allows styling/animation using CSS rather than hardcoding styles.
class Annotator.Notification extends Delegator
# Sets events to be bound to the @element.
events:
"click": "hide"
# Default options.
options:
html: "<div class='annotator-notice'></div>"
classes:
show: "annotator-notice-show"
info: "annotator-notice-info"
success: "annotator-notice-success"
error: "annotator-notice-error"
# Public: Creates an instance of Notification and appends it to the
# document body.
#
# options - The following options can be provided.
# classes - A Object literal of classes used to determine state.
# html - An HTML string used to create the notification.
#
# Examples
#
# # Displays a notification with the text "Hello World"
# notification = new Annotator.Notification
# notification.show("Hello World")
#
# Returns
constructor: (options) ->
super $(@options.html).appendTo(document.body)[0], options
# Public: Displays the annotation with message and optional status. The
# message will hide itself after 5 seconds or if the user clicks on it.
#
# message - A message String to display (HTML will be escaped).
# status - A status constant. This will apply a class to the element for
# styling. (default: Annotator.Notification.INFO)
#
# Examples
#
# # Displays a notification with the text "Hello World"
# notification.show("Hello World")
#
# # Displays a notification with the text "An error has occurred"
# notification.show("An error has occurred", Annotator.Notification.ERROR)
#
# Returns itself.
show: (message, status=Annotator.Notification.INFO) =>
@currentStatus = status
$(@element)
.addClass(@options.classes.show)
.addClass(@options.classes[@currentStatus])
.html(Util.escape(message || ""))
setTimeout this.hide, 5000
this
# Public: Hides the notification.
#
# Examples
#
# # Hides the notification.
# notification.hide()
#
# Returns itself.
hide: =>
@currentStatus ?= Annotator.Notification.INFO
$(@element)
.removeClass(@options.classes.show)
.removeClass(@options.classes[@currentStatus])
this
# Constants for controlling the display of the notification. Each constant
# adds a different class to the Notification#element.
Annotator.Notification.INFO = 'show'
Annotator.Notification.SUCCESS = 'success'
Annotator.Notification.ERROR = 'error'
# Attach notification methods to the Annotation object on document ready.
$(->
notification = new Annotator.Notification
Annotator.showNotification = notification.show
Annotator.hideNotification = notification.hide
)
# Public: Creates a Date object from an ISO8601 formatted date String.
#
# string - ISO8601 formatted date String.
#
# Returns Date instance.
createDateFromISO8601 = (string) ->
regexp = "([0-9]{4})(-([0-9]{2})(-([0-9]{2})" +
"(T([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]+))?)?" +
"(Z|(([-+])([0-9]{2}):([0-9]{2})))?)?)?)?"
d = string.match(new RegExp(regexp))
offset = 0
date = new Date(d[1], 0, 1)
date.setMonth(d[3] - 1) if d[3]
date.setDate(d[5]) if d[5]
date.setHours(d[7]) if d[7]
date.setMinutes(d[8]) if d[8]
date.setSeconds(d[10]) if d[10]
date.setMilliseconds(Number("0." + d[12]) * 1000) if d[12]
if d[14]
offset = (Number(d[16]) * 60) + Number(d[17])
offset *= ((d[15] == '-') ? 1 : -1)
offset -= date.getTimezoneOffset()
time = (Number(date) + (offset * 60 * 1000))
date.setTime(Number(time))
date
base64Decode = (data) ->
if atob?
# Gecko and Webkit provide native code for this
atob(data)
else
# Adapted from MIT/BSD licensed code at http://phpjs.org/functions/base64_decode
# version 1109.2015
b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
i = 0
ac = 0
dec = ""
tmp_arr = []
if not data
return data
data += ''
while i < data.length
# unpack four hexets into three octets using index points in b64
h1 = b64.indexOf(data.charAt(i++))
h2 = b64.indexOf(data.charAt(i++))
h3 = b64.indexOf(data.charAt(i++))
h4 = b64.indexOf(data.charAt(i++))
bits = h1 << 18 | h2 << 12 | h3 << 6 | h4
o1 = bits >> 16 & 0xff
o2 = bits >> 8 & 0xff
o3 = bits & 0xff
if h3 == 64
tmp_arr[ac++] = String.fromCharCode(o1)
else if h4 == 64
tmp_arr[ac++] = String.fromCharCode(o1, o2)
else
tmp_arr[ac++] = String.fromCharCode(o1, o2, o3)
tmp_arr.join('')
base64UrlDecode = (data) ->
m = data.length % 4
if m != 0
for i in [0...4 - m]
data += '='
data = data.replace(/-/g, '+')
data = data.replace(/_/g, '/')
base64Decode(data)
parseToken = (token) ->
[head, payload, sig] = token.split('.')
JSON.parse(base64UrlDecode(payload))
# Public: Supports the Store plugin by providing Authentication headers.
class Annotator.Plugin.Auth extends Annotator.Plugin
# User options that can be provided.
options:
# An authentication token. Used to skip the request to the server for a
# a token.
token: null
# The URL on the local server to request an authentication token.
tokenUrl: '/auth/token'
# If true will try and fetch a token when the plugin is initialised.
autoFetch: true
# Public: Create a new instance of the Auth plugin.
#
# element - The element to bind all events to. Usually the Annotator#element.
# options - An Object literal containing user options.
#
# Examples
#
# plugin = new Annotator.Plugin.Auth(annotator.element, {
# tokenUrl: '/my/custom/path'
# })
#
# Returns instance of Auth.
constructor: (element, options) ->
super
# List of functions to be executed when we have a valid token.
@waitingForToken = []
if @options.token
this.setToken(@options.token)
else
this.requestToken()
# Public: Makes a request to the local server for an authentication token.
#
# Examples
#
# auth.requestToken()
#
# Returns jqXHR object.
requestToken: ->
@requestInProgress = true
$.ajax
url: @options.tokenUrl
dataType: 'text'
xhrFields:
     withCredentials: true # Send any auth cookies to the backend
# on success, set the auth token
.done (data, status, xhr) =>
this.setToken(data)
# on failure, relay any message given by the server to the user with a notification
.fail (xhr, status, err) =>
msg = Annotator._t("Couldn't get auth token:")
console.error "#{msg} #{err}", xhr
Annotator.showNotification("#{msg} #{xhr.responseText}", Annotator.Notification.ERROR)
# always reset the requestInProgress indicator
.always =>
@requestInProgress = false
# Public: Sets the @token and checks it's validity. If the token is invalid
# requests a new one from the server.
#
# token - A token string.
#
# Examples
#
# auth.setToken('eyJh...9jQ3I')
#
# Returns nothing.
setToken: (token) ->
@token = token
# Parse the token without verifying its authenticity:
@_unsafeToken = parseToken(token)
if this.haveValidToken()
if @options.autoFetch
# Set timeout to fetch new token 2 seconds before current token expiry
@refreshTimeout = setTimeout (() => this.requestToken()), (this.timeToExpiry() - 2) * 1000
# Set headers field on this.element
this.updateHeaders()
# Run callbacks waiting for token
while @waitingForToken.length > 0
@waitingForToken.pop()(@_unsafeToken)
else
console.warn Annotator._t("Didn't get a valid token.")
if @options.autoFetch
console.warn Annotator._t("Getting a new token in 10s.")
setTimeout (() => this.requestToken()), 10 * 1000
# Public: Checks the validity of the current token. Note that this *does
# not* check the authenticity of the token.
#
# Examples
#
# auth.haveValidToken() # => Returns true if valid.
#
# Returns true if the token is valid.
haveValidToken: () ->
allFields = @_unsafeToken &&
@_unsafeToken.issuedAt &&
@_unsafeToken.ttl &&
@_unsafeToken.consumerKey
if allFields && this.timeToExpiry() > 0
return true
else
return false
# Public: Calculates the time in seconds until the current token expires.
#
# Returns Number of seconds until token expires.
timeToExpiry: ->
now = new Date().getTime() / 1000
issue = createDateFromISO8601(@_unsafeToken.issuedAt).getTime() / 1000
expiry = issue + @_unsafeToken.ttl
timeToExpiry = expiry - now
if (timeToExpiry > 0) then timeToExpiry else 0
# Public: Updates the headers to be sent with the Store requests. This is
# achieved by updating the 'annotator:headers' key in the @element.data()
# store.
#
# Returns nothing.
updateHeaders: ->
current = @element.data('annotator:headers')
@element.data('annotator:headers', $.extend(current, {
'x-annotator-auth-token': @token,
}))
# Runs the provided callback if a valid token is available. Otherwise requests
# a token until it recieves a valid one.
#
# callback - A callback function to call once a valid token is obtained.
#
# Examples
#
# auth.withToken ->
# store.loadAnnotations()
#
# Returns nothing.
withToken: (callback) ->
if not callback?
return
if this.haveValidToken()
callback(@_unsafeToken)
else
this.waitingForToken.push(callback)
if not @requestInProgress
this.requestToken()
class Annotator.Plugin.Document extends Annotator.Plugin
$ = Annotator.$
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
pluginInit: ->
this.getDocumentMetadata()
# returns the primary URI for the document being annotated
uri: =>
uri = decodeURIComponent document.location.href
for link in @metadata.link
if link.rel == "canonical"
uri = link.href
return uri
# returns all uris for the document being annotated
uris: =>
uniqueUrls = {}
for link in @metadata.link
uniqueUrls[link.href] = true if link.href
return (href for href of uniqueUrls)
beforeAnnotationCreated: (annotation) =>
annotation.document = @metadata
getDocumentMetadata: =>
@metadata = {}
# first look for some common metadata types
# TODO: look for microdata/rdfa?
this._getHighwire()
this._getDublinCore()
this._getFacebook()
this._getEprints()
this._getPrism()
this._getTwitter()
this._getFavicon()
this._getDocOwners()
# extract out/normalize some things
this._getTitle()
this._getLinks()
return @metadata
_getHighwire: =>
return @metadata.highwire = this._getMetaTags("citation", "name", "_")
_getFacebook: =>
return @metadata.facebook = this._getMetaTags("og", "property", ":")
_getTwitter: =>
return @metadata.twitter = this._getMetaTags("twitter", "name", ":")
_getDublinCore: =>
return @metadata.dc = this._getMetaTags("dc", "name", ".")
_getPrism: =>
return @metadata.prism = this._getMetaTags("prism", "name", ".")
_getEprints: =>
return @metadata.eprints = this._getMetaTags("eprints", "name", ".")
_getMetaTags: (prefix, attribute, delimiter) =>
tags = {}
for meta in $("meta")
name = $(meta).attr(attribute)
content = $(meta).prop("content")
if name
match = name.match(RegExp("^#{prefix}#{delimiter}(.+)$", "i"))
if match
n = match[1]
if tags[n]
tags[n].push(content)
else
tags[n] = [content]
return tags
_getTitle: =>
if @metadata.highwire.title
@metadata.title = @metadata.highwire.title[0]
else if @metadata.eprints.title
@metadata.title = @metadata.eprints.title
else if @metadata.prism.title
@metadata.title = @metadata.prism.title
else if @metadata.facebook.title
@metadata.title = @metadata.facebook.title
else if @metadata.twitter.title
@metadata.title = @metadata.twitter.title
else if @metadata.dc.title
@metadata.title = @metadata.dc.title
else
@metadata.title = $("head title").text()
_getLinks: =>
# we know our current location is a link for the document
@metadata.link = [href: document.location.href]
# look for some relevant link relations
for link in $("link")
l = $(link)
href = this._absoluteUrl(l.prop('href')) # get absolute url
rel = l.prop('rel')
type = l.prop('type')
relTypes = ["alternate", "canonical", "bookmark", "shortlink"]
dropTypes = ["application/rss+xml", "application/atom+xml"]
if rel in relTypes and type not in dropTypes
@metadata.link.push(href: href, rel: rel, type: type)
# look for links in scholar metadata
for name, values of @metadata.highwire
if name == "pdf_url"
for url in values
@metadata.link.push
href: this._absoluteUrl(url)
type: "application/pdf"
# kind of a hack to express DOI identifiers as links but it's a
# convenient place to look them up later, and somewhat sane since
# they don't have a type
if name == "doi"
for doi in values
if doi[0..3] != "doi:"
doi = "doi:" + doi
@metadata.link.push(href: doi)
# look for links in dublincore data
for name, values of @metadata.dc
if name == "identifier"
for id in values
if id[0..3] == "doi:"
@metadata.link.push(href: id)
_getFavicon: =>
for link in $("link")
if $(link).prop("rel") in ["shortcut icon", "icon"]
@metadata["favicon"] = this._absoluteUrl(link.href)
_getDocOwners: =>
@metadata.reply_to = []
for a in $("a")
if a.rel is 'reply-to'
if a.href.toLowerCase().slice(0,7) is "mailto:"
@metadata.reply_to.push a.href[7..]
else
@metadata.reply_to.push a.href
# hack to get a absolute url from a possibly relative one
_absoluteUrl: (url) ->
d = document.createElement('a')
d.href = url
d.href
# Annotator plugin providing dom-text-mapper
class Annotator.Plugin.DomTextMapper extends Annotator.Plugin
pluginInit: ->
if @options.skip
console.log "Not registering DOM-Text-Mapper."
return
@annotator.documentAccessStrategies.unshift
# Document access strategy for simple HTML documents,
# with enhanced text extraction and mapping features.
name: "DOM-Text-Mapper"
mapper: window.DomTextMapper
init: => @annotator.domMapper.setRootNode @annotator.wrapper[0]
# Annotator plugin for fuzzy text matching
class Annotator.Plugin.FuzzyTextAnchors extends Annotator.Plugin
pluginInit: ->
# Do we have the basic text anchors plugin loaded?
unless @annotator.plugins.TextAnchors
console.warn "The FuzzyTextAnchors Annotator plugin requires the TextAnchors plugin. Skipping."
return
@Annotator = Annotator
# Initialize the text matcher library
@textFinder = new DomTextMatcher => @annotator.domMapper.getCorpus()
# Register our fuzzy strategies
@annotator.anchoringStrategies.push
# Two-phased fuzzy text matching strategy. (Using context and quote.)
# This can handle document structure changes,
# and also content changes.
name: "two-phase fuzzy"
code: this.twoPhaseFuzzyMatching
@annotator.anchoringStrategies.push
# Naive fuzzy text matching strategy. (Using only the quote.)
# This can handle document structure changes,
# and also content changes.
name: "one-phase fuzzy"
code: this.fuzzyMatching
twoPhaseFuzzyMatching: (annotation, target) =>
# This won't work without DTM
return unless @annotator.domMapper.getInfoForNode?
# Fetch the quote and the context
quoteSelector = @annotator.findSelector target.selector, "TextQuoteSelector"
prefix = quoteSelector?.prefix
suffix = quoteSelector?.suffix
quote = quoteSelector?.exact
# No context, to joy
unless (prefix? and suffix?) then return null
# Fetch the expected start and end positions
posSelector = @annotator.findSelector target.selector, "TextPositionSelector"
expectedStart = posSelector?.start
expectedEnd = posSelector?.end
options =
contextMatchDistance: @annotator.domMapper.getCorpus().length * 2
contextMatchThreshold: 0.5
patternMatchThreshold: 0.5
flexContext: true
result = @textFinder.searchFuzzyWithContext prefix, suffix, quote,
expectedStart, expectedEnd, false, options
# If we did not got a result, give up
unless result.matches.length
# console.log "Fuzzy matching did not return any results. Giving up on two-phase strategy."
return null
# here is our result
match = result.matches[0]
# console.log "2-phase fuzzy found match at: [" + match.start + ":" +
# match.end + "]: '" + match.found + "' (exact: " + match.exact + ")"
# OK, we have everything
# Create a TextPositionAnchor from this data
new @Annotator.TextPositionAnchor @annotator, annotation, target,
match.start, match.end,
(@annotator.domMapper.getPageIndexForPos match.start),
(@annotator.domMapper.getPageIndexForPos match.end),
match.found,
unless match.exact then match.comparison.diffHTML,
unless match.exact then match.exactExceptCase
fuzzyMatching: (annotation, target) =>
# This won't work without DTM
return unless @annotator.domMapper.getInfoForNode?
# Fetch the quote
quoteSelector = @annotator.findSelector target.selector, "TextQuoteSelector"
quote = quoteSelector?.exact
# No quote, no joy
unless quote? then return null
# For too short quotes, this strategy is bound to return false positives.
# See https://github.com/hypothesis/h/issues/853 for details.
return unless quote.length >= 32
# Get a starting position for the search
posSelector = @annotator.findSelector target.selector, "TextPositionSelector"
expectedStart = posSelector?.start
# Get full document length
len = @annotator.domMapper.getCorpus().length
# If we don't have the position saved, start at the middle of the doc
expectedStart ?= Math.floor(len / 2)
# Do the fuzzy search
options =
matchDistance: len * 2
withFuzzyComparison: true
result = @textFinder.searchFuzzy quote, expectedStart, false, options
# If we did not got a result, give up
unless result.matches.length
# console.log "Fuzzy matching did not return any results. Giving up on one-phase strategy."
return null
# here is our result
match = result.matches[0]
# console.log "1-phase fuzzy found match at: [" + match.start + ":" +
# match.end + "]: '" + match.found + "' (exact: " + match.exact + ")"
# OK, we have everything
# Create a TextPosutionAnchor from this data
new @Annotator.TextPositionAnchor @annotator, annotation, target,
match.start, match.end,
(@annotator.domMapper.getPageIndexForPos match.start),
(@annotator.domMapper.getPageIndexForPos match.end),
match.found,
unless match.exact then match.comparison.diffHTML,
unless match.exact then match.exactExceptCase
detectedPDFjsVersion = PDFJS?.version.split(".").map parseFloat
# Compare two versions, given as arrays of numbers
compareVersions = (v1, v2) ->
unless Array.isArray(v1) and Array.isArray(v2)
throw new Error "Expecting arrays, in the form of [1, 0, 123]"
unless v1.length is v2.length
throw new Error "Can't compare versions in different formats."
for i in [0 ... v1.length]
if v1[i] < v2[i]
return -1
else if v1[i] > v2[i]
return 1
# Finished comparing, it's the same all along
return 0
# Document mapper module for PDF.js documents
class window.PDFTextMapper extends PageTextMapperCore
# Are we working with a PDF document?
@isPDFDocument: ->
PDFView? or # for PDF.js up to v1.0.712
PDFViewerApplication? # for PDF.js v1.0.907 and up
# Can we use this document access strategy?
@applicable: -> @isPDFDocument()
requiresSmartStringPadding: true
# Get the number of pages
getPageCount: -> @_viewer.pages.length
# Where are we in the document?
getPageIndex: -> @_app.page - 1
# Jump to a given page
setPageIndex: (index) -> @_app.page = index + 1
# Determine whether a given page has been rendered
_isPageRendered: (index) ->
@_viewer.pages[index]?.textLayer?.renderingDone
# Get the root DOM node of a given page
getRootNodeForPage: (index) ->
@_viewer.pages[index].textLayer.textLayerDiv
constructor: ->
# Set references to objects that moved around in different versions
# of PDF.js, and define a few methods accordingly
if PDFViewerApplication?
@_app = PDFViewerApplication
@_viewer = @_app.pdfViewer
@_tryExtractPage = (index) => @_viewer.getPageTextContent(index)
else
@_app = @_viewer = PDFView
@_finder = @_app.findController ? # PDF.js v1.0.712
PDFFindController # up to PDF.js v1.0.437
@_tryExtractPage = (index) =>
new Promise (resolve, reject) =>
tryIt = =>
page = @_finder.pdfPageSource.pages[index]
if page?.pdfPage?
page.getTextContent().then(resolve)
else
setTimeout tryIt, 100
tryIt()
@setEvents()
# Starting with PDF.js v1.0.822, the CSS rules changed.
#
# See this commit:
# https://github.com/mozilla/pdf.js/commit/a2e8a5ee7fecdbb2f42eeeb2343faa38cd553a15
# We need to know about that, and set our own CSS rules accordingly,
# so that our highlights are still visible. So we add a marker class,
# if this is the case.
if compareVersions(detectedPDFjsVersion, [1, 0, 822]) >= 0
@_viewer.container.className += " has-transparent-text-layer"
# Install watchers for various events to detect page rendering/unrendering
setEvents: ->
# Detect page rendering
addEventListener "pagerender", (evt) =>
# If we have not yet finished the initial scanning, then we are
# not interested.
return unless @pageInfo?
index = evt.detail.pageNumber - 1
@_onPageRendered index
# Detect page un-rendering
addEventListener "DOMNodeRemoved", (evt) =>
node = evt.target
if node.nodeType is Node.ELEMENT_NODE and node.nodeName.toLowerCase() is "div" and node.className is "textLayer"
index = parseInt node.parentNode.id.substr(13) - 1
# Forget info about the new DOM subtree
@_unmapPage @pageInfo[index]
# Do something about cross-page selections
viewer = document.getElementById "viewer"
viewer.addEventListener "domChange", (event) =>
node = event.srcElement ? event.target
data = event.data
if "viewer" is node.getAttribute? "id"
console.log "Detected cross-page change event."
# This event escaped the pages.
# Must be a cross-page selection.
if data.start? and data.end?
startPage = @getPageForNode data.start
@_updateMap @pageInfo[startPage.index]
endPage = @getPageForNode data.end
@_updateMap @pageInfo[endPage.index]
@_viewer.container.addEventListener "scroll", @_onScroll
_extractionPattern: /[ ]+/g
_parseExtractedText: (text) => text.replace @_extractionPattern, " "
# Wait for PDF.js to initialize
waitForInit: ->
# Create a utility function to poll status
tryIt = (resolve) =>
# Are we ready yet?
if @_app.documentFingerprint and @_app.documentInfo
# Now we have PDF metadata."
resolve()
else
# PDF metadata is not yet available; postponing extraction.
setTimeout ( =>
# let's try again if we have PDF metadata.
tryIt resolve
), 100
# Return a promise
new Promise (resolve, reject) =>
if PDFTextMapper.applicable()
tryIt resolve
else
reject "Not a PDF.js document"
# Extract the text from the PDF
scan: ->
# Return a promise
new Promise (resolve, reject) =>
@_pendingScanResolve = resolve
@waitForInit().then =>
# Wait for the document to load
@_app.pdfDocument.getPage(1).then =>
@pageInfo = []
@_extractPageText 0
# Manually extract the text from the PDF document.
# This workaround is here to avoid depending PDFFindController's
# own text extraction routines, which sometimes fail to add
# adequate spacing.
_extractPageText: (pageIndex) ->
@_tryExtractPage(pageIndex).then (data) =>
# There is some variation about what I might find here,
# depending on PDF.js version, so we need to do some guesswork.
textData = data.bidiTexts ? data.items ? data
# First, join all the pieces from the bidiTexts
rawContent = (text.str for text in textData).join " "
# Do some post-processing
content = @_parseExtractedText rawContent
# Save the extracted content to our page information registery
@pageInfo[pageIndex] =
index: pageIndex
content: content
if pageIndex is @getPageCount() - 1
@_finishScan()
else
@_extractPageText pageIndex + 1
# This is called when scanning is finished
_finishScan: =>
# Do some besic calculations with the content
@_onHavePageContents()
# OK, we are ready to rock.
@_pendingScanResolve()
# Do whatever we need to do after scanning
@_onAfterScan()
# Look up the page for a given DOM node
getPageForNode: (node) ->
# Search for the root of this page
div = node
while (
(div.nodeType isnt Node.ELEMENT_NODE) or
not div.getAttribute("class")? or
(div.getAttribute("class") isnt "textLayer")
)
div = div.parentNode
# Fetch the page number from the id. ("pageContainerN")
index = parseInt div.parentNode.id.substr(13) - 1
# Look up the page
@pageInfo[index]
getDocumentFingerprint: -> @_app.documentFingerprint
getDocumentInfo: -> @_app.documentInfo
# Annotator plugin for annotating documents handled by PDF.js
class Annotator.Plugin.PDF extends Annotator.Plugin
$ = Annotator.$
pluginInit: ->
# We need dom-text-mapper
unless @annotator.plugins.DomTextMapper
console.warn "The PDF Annotator plugin requires the DomTextMapper plugin. Skipping."
return
@annotator.documentAccessStrategies.unshift
# Strategy to handle PDF documents rendered by PDF.js
name: "PDF.js"
mapper: PDFTextMapper
# Are we looking at a PDF.js-rendered document?
_isPDF: -> PDFTextMapper.applicable()
# Extract the URL of the PDF file, maybe from the chrome-extension URL
_getDocumentURI: ->
uri = window.location.href
# We might have the URI embedded in a chrome-extension URI
matches = uri.match('chrome-extension://[a-z]{32}/(content/web/viewer.html\\?file=)?(.*)')
# Get the last match
match = matches?[matches.length - 1]
if match
decodeURIComponent match
else
uri
# Get a PDF fingerPrint-based URI
_getFingerPrintURI: ->
fingerprint = @annotator.domMapper.getDocumentFingerprint()
# This is an experimental URN,
# as per http://tools.ietf.org/html/rfc3406#section-3.0
"urn:x-pdf:" + fingerprint
# Public: get a canonical URI, if this is a PDF. (Null otherwise)
uri: ->
return null unless @_isPDF()
# For now, we return the fingerprint-based URI first,
# because it's probably more relevant.
# OTOH, we can't use it for clickable source links ...
# but the path is also included in the matadata,
# so anybody who _needs_ that can access it from there.
@_getFingerPrintURI()
# Try to extract the title; first from metadata, then HTML header
_getTitle: ->
title = @annotator.domMapper.getDocumentInfo().Title?.trim()
if title? and title isnt ""
title
else
$("head title").text().trim()
# Get metadata
_metadata: ->
metadata =
link: [{
href: @_getFingerPrintURI()
}]
title: @_getTitle()
documentURI = @_getDocumentURI()
if documentURI.toLowerCase().indexOf('file://') is 0
metadata.filename = new URL(documentURI).pathname.split('/').pop()
else
metadata.link.push {href: documentURI}
metadata
# Public: Get metadata (when the doc is loaded). Returns a promise.
getMetaData: =>
new Promise (resolve, reject) =>
if @annotator.domMapper.waitForInit?
@annotator.domMapper.waitForInit().then =>
try
resolve @_metadata()
catch error
reject "Internal error"
else
reject "Not a PDF dom mapper."
# We want to react to some events
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
# This is what we do to new annotations
beforeAnnotationCreated: (annotation) =>
return unless @_isPDF()
annotation.document = @_metadata()
# This plugin implements the UI code for creating text annotations
class Annotator.Plugin.TextAnchors extends Annotator.Plugin
# Plugin initialization
pluginInit: ->
# We need text highlights
unless @annotator.plugins.TextHighlights
throw new Error "The TextAnchors Annotator plugin requires the TextHighlights plugin."
@Annotator = Annotator
@$ = Annotator.$
# Register the event handlers required for creating a selection
$(document).bind({
"mouseup": @checkForEndSelection
})
null
# Code used to create annotations around text ranges =====================
# Gets the current selection excluding any nodes that fall outside of
# the @wrapper. Then returns and Array of NormalizedRange instances.
#
# Examples
#
# # A selection inside @wrapper
# annotation.getSelectedRanges()
# # => Returns [NormalizedRange]
#
# # A selection outside of @wrapper
# annotation.getSelectedRanges()
# # => Returns []
#
# Returns Array of NormalizedRange instances.
_getSelectedRanges: ->
selection = @Annotator.util.getGlobal().getSelection()
ranges = []
rangesToIgnore = []
unless selection.isCollapsed
ranges = for i in [0...selection.rangeCount]
r = selection.getRangeAt(i)
browserRange = new @Annotator.Range.BrowserRange(r)
normedRange = browserRange.normalize().limit @annotator.wrapper[0]
# If the new range falls fully outside the wrapper, we
# should add it back to the document but not return it from
# this method
rangesToIgnore.push(r) if normedRange is null
normedRange
# BrowserRange#normalize() modifies the DOM structure and deselects the
# underlying text as a result. So here we remove the selected ranges and
# reapply the new ones.
selection.removeAllRanges()
for r in rangesToIgnore
selection.addRange(r)
# Remove any ranges that fell outside of @wrapper.
@$.grep ranges, (range) ->
# Add the normed range back to the selection if it exists.
selection.addRange(range.toRange()) if range
range
# This is called then the mouse is released.
# Checks to see if a selection has been made on mouseup and if so,
# calls Annotator's onSuccessfulSelection method.
# Also resets the @mouseIsDown property.
#
# event - The event triggered this. Usually it's a mouseup Event,
# but that's not necessary. The coordinates will be used,
# if they are present. If the event (or the coordinates)
# are missing, new coordinates will be generated, based on the
# selected ranges.
#
# Returns nothing.
checkForEndSelection: (event = {}) =>
@annotator.mouseIsDown = false
# We don't care about the adder button click
return if @annotator.inAdderClick
# Get the currently selected ranges.
selectedRanges = @_getSelectedRanges()
for range in selectedRanges
container = range.commonAncestor
# TODO: what is selection ends inside a different type of highlight?
if @Annotator.TextHighlight.isInstance container
container = @Annotator.TextHighlight.getIndependentParent container
return if @annotator.isAnnotator(container)
if selectedRanges.length
event.segments = []
for r in selectedRanges
event.segments.push
type: "text range"
range: r
# Do we have valid page coordinates inside the event
# which has triggered this function?
unless event.pageX
# No, we don't. Adding fake coordinates
pos = selectedRanges[0].getEndCoords()
event.pageX = pos.x
event.pageY = pos.y #- window.scrollY
@annotator.onSuccessfulSelection event
else
@annotator.onFailedSelection event
# Strategies used for creating anchors from saved data
# This plugin containts the text highlight implementation,
# required for annotating text.
class TextHighlight extends Annotator.Highlight
# XXX: This is a temporay workaround until the Highlighter extension
# PR will be merged which will restore separation properly
@highlightClass = 'annotator-hl'
# Save the Annotator class reference, while we have access to it.
# TODO: Is this really the way to go? How do other plugins do it?
@Annotator = Annotator
@$ = Annotator.$
@highlightType = 'TextHighlight'
# Is this element a text highlight physical anchor ?
@isInstance: (element) -> @$(element).hasClass 'annotator-hl'
# Find the first parent outside this physical anchor
@getIndependentParent: (element) ->
@$(element).parents(':not([class^=annotator-hl])')[0]
# List of annotators we have already set up events for
@_inited: []
# Collect the annotations impacted by an event
@getAnnotations: (event) ->
TextHighlight.$(event.target)
.parents('.annotator-hl')
.andSelf()
.map( -> TextHighlight.$(this).data("annotation"))
.toArray()
# Set up events for this annotator
@_init: (annotator) ->
return if annotator in @_inited
annotator.element.delegate ".annotator-hl", "mouseover", this,
(event) => annotator.onAnchorMouseover event
annotator.element.delegate ".annotator-hl", "mouseout", this,
(event) => annotator.onAnchorMouseout event
annotator.element.delegate ".annotator-hl", "mousedown", this,
(event) => annotator.onAnchorMousedown event
annotator.element.delegate ".annotator-hl", "click", this,
(event) => annotator.onAnchorClick event
@_inited.push annotator
# Public: Wraps the DOM Nodes within the provided range with a highlight
# element of the specified class and returns the highlight Elements.
#
# normedRange - A NormalizedRange to be highlighted.
# cssClass - A CSS class to use for the highlight (default: 'annotator-hl')
#
# Returns an array of highlight Elements.
_highlightRange: (normedRange, cssClass='annotator-hl') ->
white = /^\s*$/
hl = @$("<span class='#{cssClass}'></span>")
# Ignore text nodes that contain only whitespace characters. This prevents
# spans being injected between elements that can only contain a restricted
# subset of nodes such as table rows and lists. This does mean that there
# may be the odd abandoned whitespace node in a paragraph that is skipped
# but better than breaking table layouts.
nodes = @$(normedRange.textNodes()).filter((i) -> not white.test @nodeValue)
r = nodes.wrap(hl).parent().show().toArray()
for node in nodes
event = document.createEvent "UIEvents"
event.initUIEvent "domChange", true, false, window, 0
event.reason = "created hilite"
node.dispatchEvent event
r
constructor: (anchor, pageIndex, normedRange) ->
super anchor, pageIndex
TextHighlight._init @annotator
@$ = TextHighlight.$
@Annotator = TextHighlight.Annotator
# Create a highlights, and link them with the annotation
@_highlights = @_highlightRange normedRange
@$(@_highlights).data "annotation", @annotation
# Implementing the required APIs
# Is this a temporary hl?
isTemporary: -> @_temporary
# Mark/unmark this hl as active
setTemporary: (value) ->
@_temporary = value
if value
@$(@_highlights).addClass('annotator-hl-temporary')
else
@$(@_highlights).removeClass('annotator-hl-temporary')
# Mark/unmark this hl as focused
setFocused: (value) ->
if value
@$(@_highlights).addClass('annotator-hl-focused')
else
@$(@_highlights).removeClass('annotator-hl-focused')
# Remove all traces of this hl from the document
removeFromDocument: ->
for hl in @_highlights
# Is this highlight actually the part of the document?
if hl.parentNode? and @annotator.domMapper.isPageMapped @pageIndex
# We should restore original state
child = hl.childNodes[0]
@$(hl).replaceWith hl.childNodes
event = document.createEvent "UIEvents"
event.initUIEvent "domChange", true, false, window, 0
event.reason = "removed hilite (annotation deleted)"
child.parentNode.dispatchEvent event
# Get the HTML elements making up the highlight
_getDOMElements: -> @_highlights
class Annotator.Plugin.TextHighlights extends Annotator.Plugin
# Plugin initialization
pluginInit: ->
# Export the text highlight class for other plugins
Annotator.TextHighlight = TextHighlight
\ No newline at end of file
# This anchor type stores information about a piece of text,
# described using start and end character offsets
class TextPositionAnchor extends Annotator.Anchor
@Annotator = Annotator
constructor: (annotator, annotation, target,
@start, @end, startPage, endPage,
quote, diffHTML, diffCaseOnly) ->
super annotator, annotation, target,
startPage, endPage,
quote, diffHTML, diffCaseOnly
# This pair of offsets is the key information,
# upon which this anchor is based upon.
unless @start? then throw new Error "start is required!"
unless @end? then throw new Error "end is required!"
@Annotator = TextPositionAnchor.Annotator
# This is how we create a highlight out of this kind of anchor
_createHighlight: (page) ->
# First we create the range from the stored stard and end offsets
mappings = @annotator.domMapper.getMappingsForCharRange @start, @end, [page]
# Get the wanted range out of the response of DTM
realRange = mappings.sections[page].realRange
# Get a BrowserRange
browserRange = new @Annotator.Range.BrowserRange realRange
# Get a NormalizedRange
normedRange = browserRange.normalize @annotator.wrapper[0]
# Create the highligh
new @Annotator.TextHighlight this, page, normedRange
# Annotator plugin for text position-based anchoring
class Annotator.Plugin.TextPosition extends Annotator.Plugin
pluginInit: ->
@Annotator = Annotator
# Register the creator for text quote selectors
@annotator.selectorCreators.push
name: "TextPositionSelector"
describe: @_getTextPositionSelector
@annotator.anchoringStrategies.push
# Position-based strategy. (The quote is verified.)
# This can handle document structure changes,
# but not the content changes.
name: "position"
code: @createFromPositionSelector
# Export the anchor type
@Annotator.TextPositionAnchor = TextPositionAnchor
# Create a TextPositionSelector around a range
_getTextPositionSelector: (selection) =>
# We only care about "text range" selections.
return [] unless selection.type is "text range"
# We need dom-text-mapper - style functionality
return [] unless @annotator.domMapper.getStartPosForNode?
startOffset = @annotator.domMapper.getStartPosForNode selection.range.start
endOffset = @annotator.domMapper.getEndPosForNode selection.range.end
if startOffset? and endOffset?
[
type: "TextPositionSelector"
start: startOffset
end: endOffset
]
else
# It looks like we can't determine the start and end offsets.
# That means no valid TextPosition selector can be generated from this.
unless startOffset?
console.log "Warning: can't generate TextPosition selector, because",
selection.range.start,
"does not have a valid start position."
unless endOffset?
console.log "Warning: can't generate TextPosition selector, because",
selection.range.end,
"does not have a valid end position."
[ ]
# Create an anchor using the saved TextPositionSelector.
# The quote is verified.
createFromPositionSelector: (annotation, target) =>
# We need the TextPositionSelector
selector = @annotator.findSelector target.selector, "TextPositionSelector"
return unless selector?
unless selector.start?
console.log "Warning: 'start' field is missing from TextPositionSelector. Skipping."
return null
unless selector.end?
console.log "Warning: 'end' field is missing from TextPositionSelector. Skipping."
return null
corpus = @annotator.domMapper.getCorpus?()
# This won't work without d-t-m
return null unless corpus
content = corpus[selector.start ... selector.end].trim()
currentQuote = @annotator.normalizeString content
savedQuote = @annotator.getQuoteForTarget? target
if savedQuote? and currentQuote isnt savedQuote
# We have a saved quote, let's compare it to current content
#console.log "Could not apply position selector" +
# " [#{selector.start}:#{selector.end}] to current document," +
# " because the quote has changed. " +
# "(Saved quote is '#{savedQuote}'." +
# " Current quote is '#{currentQuote}'.)"
return null
# Create a TextPositionAnchor from this data
new TextPositionAnchor @annotator, annotation, target,
selector.start, selector.end,
(@annotator.domMapper.getPageIndexForPos selector.start),
(@annotator.domMapper.getPageIndexForPos selector.end),
currentQuote
# This plugin defines the TextQuote selector
class Annotator.Plugin.TextQuote extends Annotator.Plugin
@Annotator = Annotator
@$ = Annotator.$
# Plugin initialization
pluginInit: ->
# Register the creator for text quote selectors
@annotator.selectorCreators.push
name: "TextQuoteSelector"
describe: @_getTextQuoteSelector
# Register function to get quote from this selector
@annotator.getQuoteForTarget = (target) =>
selector = @annotator.findSelector target.selector, "TextQuoteSelector"
if selector?
@annotator.normalizeString selector.exact
else
null
# Create a TextQuoteSelector around a range
_getTextQuoteSelector: (selection) =>
return [] unless selection.type is "text range"
unless selection.range?
throw new Error "Called getTextQuoteSelector() with null range!"
rangeStart = selection.range.start
unless rangeStart?
throw new Error "Called getTextQuoteSelector() on a range with no valid start."
rangeEnd = selection.range.end
unless rangeEnd?
throw new Error "Called getTextQuoteSelector() on a range with no valid end."
if @annotator.domMapper.getStartPosForNode?
# Calculate the quote and context using DTM
startOffset = @annotator.domMapper.getStartPosForNode rangeStart
endOffset = @annotator.domMapper.getEndPosForNode rangeEnd
if startOffset? and endOffset?
quote = @annotator.domMapper.getCorpus()[startOffset .. endOffset-1].trim()
[prefix, suffix] = @annotator.domMapper.getContextForCharRange startOffset, endOffset
[
type: "TextQuoteSelector"
exact: quote
prefix: prefix
suffix: suffix
]
else
# It looks like we can't determine the start and end offsets.
# That means no valid TextQuote selector can be generated from this.
console.log "Warning: can't generate TextQuote selector.", startOffset, endOffset
[ ]
else
# Get the quote directly from the range
[
type: "TextQuoteSelector"
exact: selection.range.text().trim()
]
# This anhor type stores information about a piece of text,
# described using the actual reference to the range in the DOM.
#
# When creating this kind of anchor, you are supposed to pass
# in a NormalizedRange object, which should cover exactly
# the wanted piece of text; no character offset correction is supported.
#
# Also, please note that these anchors can not really be virtualized,
# because they don't have any truly DOM-independent information;
# the core information stored is the reference to an object which
# lives in the DOM. Therefore, no lazy loading is possible with
# this kind of anchor. For that, use TextPositionAnchor instead.
#
# This plugin also adds a strategy to reanchor based on range selectors.
# If the TextQuote plugin is also loaded, then it will also check
# the saved quote against what is available now.
#
# If the TextPosition plugin is loaded, it will create a TextPosition
# anchor; otherwise it will record a TextRangeAnchor.
class TextRangeAnchor extends Annotator.Anchor
@Annotator = Annotator
constructor: (annotator, annotation, target, @range, quote) ->
super annotator, annotation, target, 0, 0, quote
unless @range? then throw new Error "range is required!"
@Annotator = TextRangeAnchor.Annotator
# This is how we create a highlight out of this kind of anchor
_createHighlight: ->
# Create the highligh
new @Annotator.TextHighlight this, 0, @range
# Annotator plugin for creating, and anchoring based on text range
# selectors
class Annotator.Plugin.TextRange extends Annotator.Plugin
pluginInit: ->
@Annotator = Annotator
# Register the creator for range selectors
@annotator.selectorCreators.push
name: "RangeSelector"
describe: @_getRangeSelector
# Register our anchoring strategies
@annotator.anchoringStrategies.push
# Simple strategy based on DOM Range
name: "range"
code: @createFromRangeSelector
# Export these anchor types
@annotator.TextRangeAnchor = TextRangeAnchor
# Create a RangeSelector around a range
_getRangeSelector: (selection) =>
return [] unless selection.type is "text range"
sr = selection.range.serialize @annotator.wrapper[0], '.' + @Annotator.TextHighlight.highlightClass
[
type: "RangeSelector"
startContainer: sr.startContainer
startOffset: sr.startOffset
endContainer: sr.endContainer
endOffset: sr.endOffset
]
# Create and anchor using the saved Range selector.
# The quote is verified.
createFromRangeSelector: (annotation, target) =>
selector = @annotator.findSelector target.selector, "RangeSelector"
unless selector? then return null
# Try to apply the saved XPath
try
range = @Annotator.Range.sniff selector
normedRange = range.normalize @annotator.wrapper[0]
catch error
return null
# Get the text of this range
if @annotator.domMapper.getInfoForNode?
# Determine the current content of the given range using DTM
startInfo = @annotator.domMapper.getInfoForNode normedRange.start
return null unless startInfo # Don't fret if page is not mapped
startOffset = startInfo.start
endInfo = @annotator.domMapper.getInfoForNode normedRange.end
return null unless endInfo # Don't fret if page is not mapped
endOffset = endInfo.end
rawQuote = @annotator.domMapper.getCorpus()[startOffset .. endOffset-1].trim()
else
# Determine the current content of the given range directly
rawQuote = normedRange.text().trim()
currentQuote = @annotator.normalizeString rawQuote
# Look up the saved quote
savedQuote = @annotator.getQuoteForTarget? target
if savedQuote? and currentQuote isnt savedQuote
#console.log "Could not apply XPath selector to current document, " +
# "because the quote has changed. (Saved quote is '#{savedQuote}'." +
# " Current quote is '#{currentQuote}'.)"
return null
if startInfo?.start? and endInfo?.end?
# Create a TextPositionAnchor from the start and end offsets
# of this range
# (to be used with dom-text-mapper)
new @Annotator.TextPositionAnchor @annotator, annotation, target,
startInfo.start, endInfo.end,
(startInfo.pageIndex ? 0), (endInfo.pageIndex ? 0),
currentQuote
else
# Create a TextRangeAnchor from this range
# (to be used whithout dom-text-mapper)
new TextRangeAnchor @annotator, annotation, target,
normedRange, currentQuote
This diff is collapsed.
# Public: Creates an element for viewing annotations.
class Annotator.Viewer extends Annotator.Widget
# Events to be bound to the @element.
events:
".annotator-edit click": "onEditClick"
".annotator-delete click": "onDeleteClick"
# Classes for toggling annotator state.
classes:
hide: 'annotator-hide'
showControls: 'annotator-visible'
# HTML templates for @element and @item properties.
html:
element:"""
<div class="annotator-outer annotator-viewer">
<ul class="annotator-widget annotator-listing"></ul>
</div>
"""
item: """
<li class="annotator-annotation annotator-item">
<span class="annotator-controls">
<a href="#" title="View as webpage" class="annotator-link">View as webpage</a>
<button title="Edit" class="annotator-edit">Edit</button>
<button title="Delete" class="annotator-delete">Delete</button>
</span>
</li>
"""
# Configuration options
options:
readOnly: false # Start the viewer in read-only mode. No controls will be shown.
# Public: Creates an instance of the Viewer object. This will create the
# @element from the @html.element string and set up all events.
#
# options - An Object literal containing options.
#
# Examples
#
# # Creates a new viewer, adds a custom field and displays an annotation.
# viewer = new Annotator.Viewer()
# viewer.addField({
# load: someLoadCallback
# })
# viewer.load(annotation)
#
# Returns a new Viewer instance.
constructor: (options) ->
super $(@html.element)[0], options
@item = $(@html.item)[0]
@fields = []
@annotations = []
# Public: Displays the Viewer and first the "show" event. Can be used as an
# event callback and will call Event#preventDefault() on the supplied event.
#
# event - Event object provided if method is called by event
# listener (default:undefined)
#
# Examples
#
# # Displays the editor.
# viewer.show()
#
# # Displays the viewer on click (prevents default action).
# $('a.show-viewer').bind('click', viewer.show)
#
# Returns itself.
show: (event) =>
util.preventEventDefault event
controls = @element
.find('.annotator-controls')
.addClass(@classes.showControls)
setTimeout((=> controls.removeClass(@classes.showControls)), 500)
@element.removeClass(@classes.hide)
this.checkOrientation().publish('show')
# Public: Checks to see if the Viewer is currently displayed.
#
# Examples
#
# viewer.show()
# viewer.isShown() # => Returns true
#
# viewer.hide()
# viewer.isShown() # => Returns false
#
# Returns true if the Viewer is visible.
isShown: ->
not @element.hasClass(@classes.hide)
# Public: Hides the Editor and fires the "hide" event. Can be used as an event
# callback and will call Event#preventDefault() on the supplied event.
#
# event - Event object provided if method is called by event
# listener (default:undefined)
#
# Examples
#
# # Hides the editor.
# viewer.hide()
#
# # Hide the viewer on click (prevents default action).
# $('a.hide-viewer').bind('click', viewer.hide)
#
# Returns itself.
hide: (event) =>
util.preventEventDefault event
@element.addClass(@classes.hide)
this.publish('hide')
# Public: Loads annotations into the viewer and shows it. Fires the "load"
# event once the viewer is loaded passing the annotations into the callback.
#
# annotation - An Array of annotation elements.
#
# Examples
#
# viewer.load([annotation1, annotation2, annotation3])
#
# Returns itslef.
load: (annotations) =>
@annotations = annotations || []
list = @element.find('ul:first').empty()
for annotation in @annotations
item = $(@item).clone().appendTo(list).data('annotation', annotation)
controls = item.find('.annotator-controls')
link = controls.find('.annotator-link')
edit = controls.find('.annotator-edit')
del = controls.find('.annotator-delete')
links = new LinkParser(annotation.links or []).get('alternate', {'type': 'text/html'})
if links.length is 0 or not links[0].href?
link.remove()
else
link.attr('href', links[0].href)
if @options.readOnly
edit.remove()
del.remove()
else
controller = {
showEdit: -> edit.removeAttr('disabled')
hideEdit: -> edit.attr('disabled', 'disabled')
showDelete: -> del.removeAttr('disabled')
hideDelete: -> del.attr('disabled', 'disabled')
}
for field in @fields
element = $(field.element).clone().appendTo(item)[0]
field.load(element, annotation, controller)
this.publish('load', [@annotations])
this.show()
# Public: Adds an addional field to an annotation view. A callback can be
# provided to update the view on load.
#
# options - An options Object. Options are as follows:
# load - Callback Function called when the view is loaded with an
# annotation. Recieves a newly created clone of @item and
# the annotation to be displayed (it will be called once
# for each annotation being loaded).
#
# Examples
#
# # Display a user name.
# viewer.addField({
# # This is called when the viewer is loaded.
# load: (field, annotation) ->
# field = $(field)
#
# if annotation.user
# field.text(annotation.user) # Display the user
# else
# field.remove() # Do not display the field.
# })
#
# Returns itself.
addField: (options) ->
field = $.extend({
load: ->
}, options)
field.element = $('<div />')[0]
@fields.push field
field.element
this
# Callback function: called when the edit button is clicked.
#
# event - An Event object.
#
# Returns nothing.
onEditClick: (event) =>
this.onButtonClick(event, 'edit')
# Callback function: called when the delete button is clicked.
#
# event - An Event object.
#
# Returns nothing.
onDeleteClick: (event) =>
this.onButtonClick(event, 'delete')
# Fires an event of type and passes in the associated annotation.
#
# event - An Event object.
# type - The type of event to fire. Either "edit" or "delete".
#
# Returns nothing.
onButtonClick: (event, type) ->
item = $(event.target).parents('.annotator-annotation')
this.publish(type, [item.data('annotation')])
# Private: simple parser for hypermedia link structure
#
# Examples:
#
# links = [
# { rel: 'alternate', href: 'http://example.com/pages/14.json', type: 'application/json' },
# { rel: 'prev': href: 'http://example.com/pages/13' }
# ]
#
# lp = LinkParser(links)
# lp.get('alternate') # => [ { rel: 'alternate', href: 'http://...', ... } ]
# lp.get('alternate', {type: 'text/html'}) # => []
#
class LinkParser
constructor: (@data) ->
get: (rel, cond={}) ->
cond = $.extend({}, cond, {rel: rel})
keys = (k for own k, v of cond)
for d in @data
match = keys.reduce ((m, k) -> m and (d[k] is cond[k])), true
if match
d
else
continue
# Public: Base class for the Editor and Viewer elements. Contains methods that
# are shared between the two.
class Annotator.Widget extends Delegator
# Classes used to alter the widgets state.
classes:
hide: 'annotator-hide'
invert:
x: 'annotator-invert-x'
y: 'annotator-invert-y'
# Public: Creates a new Widget instance.
#
# element - The Element that represents the widget in the DOM.
# options - An Object literal of options.
#
# Examples
#
# element = document.createElement('div')
# widget = new Annotator.Widget(element)
#
# Returns a new Widget instance.
constructor: (element, options) ->
super
@classes = $.extend {}, Annotator.Widget.prototype.classes, @classes
# Public: Unbind the widget's events and remove its element from the DOM.
#
# Returns nothing.
destroy: ->
this.removeEvents()
@element.remove()
checkOrientation: ->
this.resetOrientation()
window = $(util.getGlobal())
widget = @element.children(":first")
offset = widget.offset()
viewport = {
top: window.scrollTop(),
right: window.width() + window.scrollLeft()
}
current = {
top: offset.top
right: offset.left + widget.width()
}
if (current.top - viewport.top) < 0
this.invertY()
if (current.right - viewport.right) > 0
this.invertX()
this
# Public: Resets orientation of widget on the X & Y axis.
#
# Examples
#
# widget.resetOrientation() # Widget is original way up.
#
# Returns itself for chaining.
resetOrientation: ->
@element.removeClass(@classes.invert.x).removeClass(@classes.invert.y)
this
# Public: Inverts the widget on the X axis.
#
# Examples
#
# widget.invertX() # Widget is now right aligned.
#
# Returns itself for chaining.
invertX: ->
@element.addClass @classes.invert.x
this
# Public: Inverts the widget on the Y axis.
#
# Examples
#
# widget.invertY() # Widget is now upside down.
#
# Returns itself for chaining.
invertY: ->
@element.addClass @classes.invert.y
this
# Public: Find out whether or not the widget is currently upside down
#
# Returns a boolean: true if the widget is upside down
isInvertedY: ->
@element.hasClass @classes.invert.y
# Public: Find out whether or not the widget is currently right aligned
#
# Returns a boolean: true if the widget is right aligned
isInvertedX: ->
@element.hasClass @classes.invert.x
# A simple XPath evaluator using jQuery which can evaluate queries of
simpleXPathJQuery = (relativeRoot) ->
jq = this.map ->
path = ''
elem = this
while elem?.nodeType == Node.ELEMENT_NODE and elem isnt relativeRoot
tagName = elem.tagName.replace(":", "\\:")
idx = $(elem.parentNode).children(tagName).index(elem) + 1
idx = "[#{idx}]"
path = "/" + elem.tagName.toLowerCase() + idx + path
elem = elem.parentNode
path
jq.get()
# A simple XPath evaluator using only standard DOM methods which can
# evaluate queries of the form /tag[index]/tag[index].
simpleXPathPure = (relativeRoot) ->
getPathSegment = (node) ->
name = getNodeName node
pos = getNodePosition node
"#{name}[#{pos}]"
rootNode = relativeRoot
getPathTo = (node) ->
xpath = '';
while node != rootNode
unless node?
throw new Error "Called getPathTo on a node which was not a descendant of @rootNode. " + rootNode
xpath = (getPathSegment node) + '/' + xpath
node = node.parentNode
xpath = '/' + xpath
xpath = xpath.replace /\/$/, ''
xpath
jq = this.map ->
path = getPathTo this
path
jq.get()
findChild = (node, type, index) ->
unless node.hasChildNodes()
throw new Error "XPath error: node has no children!"
children = node.childNodes
found = 0
for child in children
name = getNodeName child
if name is type
found += 1
if found is index
return child
throw new Error "XPath error: wanted child not found."
# Get the node name for use in generating an xpath expression.
getNodeName = (node) ->
nodeName = node.nodeName.toLowerCase()
switch nodeName
when "#text" then return "text()"
when "#comment" then return "comment()"
when "#cdata-section" then return "cdata-section()"
else return nodeName
# Get the index of the node as it appears in its parent's child list
getNodePosition = (node) ->
pos = 0
tmp = node
while tmp
if tmp.nodeName is node.nodeName
pos++
tmp = tmp.previousSibling
pos
\ No newline at end of file
This diff is collapsed.
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