Commit 9368bfc6 authored by Randall Leeds's avatar Randall Leeds

Anchoring rewrite

It went down something like this:

:city_sunset::sparkles::alien::sweat_drops::pray::boom::city_sunrise:
parent c464e564
......@@ -2,6 +2,17 @@ Promise = require('es6-promise').Promise
Annotator = require('annotator')
$ = Annotator.$
anchor = require('./lib/anchor')
highlight = require('./lib/highlight')
ANCHOR_TYPES = [
anchor.FragmentAnchor
anchor.RangeAnchor
anchor.TextPositionAnchor
anchor.TextQuoteAnchor
]
module.exports = class Guest extends Annotator
SHOW_HIGHLIGHTS_CLASS = 'annotator-highlights-always-on'
......@@ -15,14 +26,7 @@ module.exports = class Guest extends Annotator
# Plugin configuration
options:
TextHighlights: {}
EnhancedAnchoring: {}
DomTextMapper: {}
TextSelection: {}
TextRange: {}
TextPosition: {}
TextQuote: {}
FuzzyTextAnchors: {}
FragmentSelector: {}
# Internal state
visibleHighlights: false
......@@ -36,9 +40,10 @@ module.exports = class Guest extends Annotator
'''
constructor: (element, options, config = {}) ->
options.noScan = true
super
delete @options.noScan
this.anchored = []
this.unanchored = []
# Are going to be able to use the PDF plugin here?
if window.PDFTextMapper?.applicable()
......@@ -85,50 +90,6 @@ module.exports = class Guest extends Annotator
if not @plugins[name] and Annotator.Plugin[name]
this.addPlugin(name, opts)
unless config.dontScan
# Scan the document text with the DOM Text libraries
this.anchoring._scan()
# Watch for newly rendered highlights, and update positions in sidebar
this.subscribe "highlightsCreated", (highlights) =>
unless Array.isArray highlights
highlights = [highlights]
highlights.forEach (hl) ->
hls = hl.anchor.highlight # Fetch all the highlights
# Get the pages we have highlights on (for the given anchor)
pages = Object.keys(hls).map (s) -> parseInt s
firstPage = pages.sort()[0] # Determine the first page
firstHl = hls[firstPage] # Determine the first (topmost) hl
# Store the position of this anchor inside target
hl.anchor.target.pos =
top: hl.getTop()
height: hl.getHeight()
# Collect all impacted annotations
annotations = (hl.annotation for hl in highlights)
# Announce the new positions, so that the sidebar knows
this.plugins.CrossFrame.sync(annotations)
# Watch for removed highlights, and update positions in sidebar
this.subscribe "highlightRemoved", (highlight) =>
hls = highlight.anchor.highlight # Fetch all the highlights
# Get the pages we have highlights on (for the given anchor)
pages = Object.keys(hls).map (s) -> parseInt s
# Do we have any highlights left?
if pages.length
firstPage = pages.sort()[0] # Determine the first page
firstHl = hls[firstPage] # Determine the first (topmost) hl
# Store the position of this anchor inside target
highlight.anchor.target.pos =
top: highlight.getTop()
heigth: highlight.getHeight()
else
delete highlight.anchor.target.pos
# Announce the new positions, so that the sidebar knows
this.plugins.CrossFrame.sync([highlight.annotation])
# Utility function to remove the hash part from a URL
_removeHash: (url) ->
url = new URL url
......@@ -156,15 +117,13 @@ module.exports = class Guest extends Annotator
crossframe.on('onEditorHide', this.onEditorHide)
crossframe.on('onEditorSubmit', this.onEditorSubmit)
crossframe.on 'focusAnnotations', (ctx, tags=[]) =>
for hl in @anchoring.getHighlights()
if hl.annotation.$$tag in tags
hl.setFocused true
else
hl.setFocused false
for info in @anchored
toggle = info.annotation.$$tag in tags
$(info.highlights).toggleClass('annotator-hl-focused', toggle)
crossframe.on 'scrollToAnnotation', (ctx, tag) =>
for a in @anchoring.getAnchors()
if a.annotation.$$tag is tag
a.scrollToView()
for info in @anchored
if info.annotation.$$tag is tag
$(info.highlights).scrollintoview()
return
crossframe.on 'getDocumentInfo', (trans) =>
(@plugins.PDF?.getMetaData() ? Promise.reject())
......@@ -211,9 +170,51 @@ module.exports = class Guest extends Annotator
this.removeEvents()
setupAnnotation: ->
annotation = super
setupAnnotation: (annotation) ->
unless annotation.target?
if @selectedRanges?
annotation.target = (@_getTargetFromRange(r) for r in @selectedRanges)
@selectedRanges = null
else
annotation.target = [this.getHref()]
# Create a TextHighlight for a range.
highlightRange = (range) =>
normedRange = Annotator.Range.sniff(range).normalize(@element[0])
return highlight.highlightRange(normedRange)
# Factories to close over the loop variable, below.
succeed = (target) ->
(highlights) -> {annotation, target, highlights}
fail = (target) ->
(reason) -> {annotation, target}
# Function to collect anchoring promises
finish = (results) =>
anchored = false
for result in results
if result.highlights?
anchored = true
@anchored.push(result)
else
@unanchored.push(result)
if results.length and not anchored
annotation.$orphan = true
this.plugins.CrossFrame.sync([annotation])
# Anchor all the targets, highlighting the successes.
promises = for target in annotation.target ? [] when target.selector
this.anchorTarget(target)
.then(highlightRange)
.then(succeed(target), fail(target))
# Collect the results.
Promise.all(promises).then(finish)
annotation
createAnnotation: ->
......@@ -227,6 +228,89 @@ module.exports = class Guest extends Annotator
this.plugins.CrossFrame.sync([annotation])
annotation
deleteAnnotation: (annotation) ->
for info in @anchored when info.annotation is annotation
for h in info.highlights when h.parentNode?
child = h.childNodes[0]
$(h).replaceWith(h.childNodes)
@anchored = (a for a in @anchored when a.annotation isnt annotation)
@unanchored = (a for a in @unanchored when a.annotation isnt annotation)
this.publish('annotationDeleted', [annotation])
annotation
###*
# Anchor a target.
#
# This function converts an annotation target into a document range using
# its selectors. It encapsulates the core anchoring algorithm that uses the
# selectors alone or in combination to establish an anchor within the document.
#
# :root Node target: The root Node of the anchoring context.
# :param Object target: The target to anchor.
# :return: A Promise that resolves to a Range on success.
# :rtype: Promise
####
anchorTarget: (target) ->
root = @element[0]
# Selectors
fragment = null
position = null
quote = null
range = null
# Collect all the selectors
for selector in target.selector ? []
switch selector.type
when 'FragmentSelector'
fragment = selector
when 'TextPositionSelector'
position = selector
when 'TextQuoteSelector'
quote = selector
when 'RangeSelector'
range = selector
# Until we successfully anchor, we fail.
promise = Promise.reject('unable to anchor')
if fragment?
promise = promise.catch =>
a = anchor.FragmentAnchor.fromSelector(fragment)
r = a.toRange(root)
if quote?.exact? and r.toString() != quote.exact
throw new Error('quote mismatch')
else
return r
if range?
promise = promise.catch =>
a = anchor.RangeAnchor.fromSelector(range, root)
r = a.toRange(root)
if quote?.exact? and r.toString() != quote.exact
throw new Error('quote mismatch')
else
return r
if position?
promise = promise.catch =>
a = anchor.TextPositionAnchor.fromSelector(position)
r = a.toRange(root)
if quote?.exact? and r.toString() != quote.exact
throw new Error('quote mismatch')
else
return r
if quote?
promise = promise.catch =>
# The quote is implicitly checked during range conversion.
a = anchor.TextQuoteAnchor.fromSelector(quote, position)
r = a.toRange(root)
return promise
showAnnotations: (annotations) =>
@crossframe?.notify
method: "showAnnotations"
......@@ -249,29 +333,26 @@ module.exports = class Guest extends Annotator
onAnchorMousedown: ->
# This is called to create a target from a raw selection,
# using selectors created by the registered selector creators
_getTargetFromSelection: (selection) ->
source: @getHref()
selector: @anchoring.getSelectorsFromSelection(selection)
confirmSelection: ->
return true unless @selectedTargets.length is 1
quote = @getQuoteForTarget @selectedTargets[0]
if quote.length > 2 then return true
# Create a target from a raw selection using all the anchor types.
_getTargetFromRange: (range) ->
selector = for type in ANCHOR_TYPES
try
type.fromRange(range).toSelector(@element[0])
catch
continue
return confirm "You have selected a very short piece of text: only " + length + " chars. Are you sure you want to highlight this?"
return {
source: this.getHref()
selector: selector
}
onSuccessfulSelection: (event, immediate) ->
unless event?
throw "Called onSuccessfulSelection without an event!"
unless event.segments?
throw "Called onSuccessulSelection with an event with missing segments!"
unless event.ranges?
throw "Called onSuccessulSelection with an event with missing ranges!"
# Describe the selection with targets
@selectedTargets = (@_getTargetFromSelection s for s in event.segments)
@selectedRanges = event.ranges
# Do we want immediate annotation?
if immediate
......@@ -287,7 +368,7 @@ module.exports = class Guest extends Annotator
onFailedSelection: (event) ->
@adder.hide()
@selectedTargets = []
@selectedRanges = []
# Select some annotations.
#
......
......@@ -29,7 +29,7 @@ module.exports = class Host extends Guest
.addClass('annotator-frame annotator-outer annotator-collapsed')
.appendTo(element)
super element, options, dontScan: true
super
this._addCrossFrameListeners()
app.appendTo(@frame)
......@@ -39,8 +39,6 @@ module.exports = class Host extends Guest
# Host frame dictates the toolbar options.
this.on 'panelReady', =>
this.anchoring._scan() # Scan the document
# Guest is designed to respond to events rather than direct method
# calls. If we call set directly the other plugins will never recieve
# these events and the UI will be out of sync.
......
# XXX: for globals needed by DomTextMatcher
require('diff-match-patch')
require('text-match-engines')
DomTextMatcher = require('dom-text-matcher')
Annotator = require('annotator')
$ = Annotator.$
xpathRange = Annotator.Range
textWalker = require('./text-walker')
# Helper functions for throwing common errors
missingParameter = (name) ->
throw new Error('missing required parameter "' + name + '"')
notImplemented = ->
throw new Error('method not implemented')
###*
# class:: Abstract base class for anchors.
###
class Anchor
# Create an instance of the anchor from a Range.
@fromRange: notImplemented
# Create an instance of the anchor from a selector.
@fromSelector: notImplemented
# Create a Range from the anchor.
toRange: notImplemented
# Create a selector from the anchor.
toSelector: notImplemented
###*
# class:: FragmentAnchor(id)
#
# This anchor type represents a fragment identifier.
#
# :param String id: The id of the fragment for the anchor.
###
class FragmentAnchor extends Anchor
constructor: (@id) ->
unless @id? then missingParameter('id')
@fromRange: (range) ->
id = $(range.commonAncestorContainer).closest('[id]').attr('id')
return new FragmentAnchor(id)
@fromSelector: (selector) ->
return new FragmentAnchor(selector.value)
toSelector: ->
return {
type: 'FragmentSelector'
value: @id
}
toRange: ->
el = document.getElementById(@id)
range = document.createRange()
range.selectNode(el)
return range
###*
# class:: RangeAnchor(range)
#
# This anchor type represents a DOM Range.
#
# :param Range range: A range describing the anchor.
###
class RangeAnchor extends Anchor
constructor: (@range) ->
unless @range? then missingParameter('range')
@fromRange: (range) ->
return new RangeAnchor(range)
# Create and anchor using the saved Range selector.
@fromSelector: (selector, root=document.body) ->
data = {
start: selector.startContainer
startOffset: selector.startOffset
end: selector.endContainer
endOffset: selector.endOffset
}
range = new xpathRange.SerializedRange(data).normalize(root).toRange()
return new RangeAnchor(range)
toRange: ->
return @range
toSelector: (root=document.body, ignoreSelector='[class^="annotator-"]') ->
range = new xpathRange.BrowserRange(@range).serialize(root, ignoreSelector)
return {
type: 'RangeSelector'
startContainer: range.start
startOffset: range.startOffset
endContainer: range.end
endOffset: range.endOffset
}
###*
# class:: TextPositionAnchor(start, end)
#
# This anchor type represents a piece of text described by start and end
# character offsets.
#
# :param Number start: The start offset for the anchor text.
# :param Number end: The end offset for the anchor text.
###
class TextPositionAnchor extends Anchor
constructor: (@start, @end) ->
unless @start? then missingParameter('start')
unless @end? then missingParameter('end')
@fromRange: (range, root=document.body, filter=null) ->
range = new xpathRange.BrowserRange(range).normalize(root)
walker = new textWalker.TextWalker(root, filter)
walker.currentNode = range.start
start = walker.tell()
walker.currentNode = range.end
end = walker.tell() + range.end.textContent.length
return new TextPositionAnchor(start, end)
@fromSelector: (selector) ->
return new TextPositionAnchor(selector.start, selector.end)
toRange: (root=document.body, filter=null) ->
walker = new textWalker.TextWalker(root, filter)
range = document.createRange()
offset = walker.seek(@start, textWalker.SEEK_SET)
range.setStart(walker.currentNode, @start - offset)
offset = walker.seek(@end - offset, textWalker.SEEK_CUR)
range.setEnd(walker.currentNode, @end - offset)
return range
toSelector: ->
return {
type: 'TextPositionSelector'
start: @start
end: @end
}
###*
# class:: TextQuoteAnchor(quote, [prefix, [suffix, [start, [end]]]])
#
# This anchor type represents a piece of text described by a quote. The quote
# may optionally include textual context and/or a position within the text.
#
# :param String quote: The anchor text to match.
# :param String prefix: A prefix that preceeds the anchor text.
# :param String suffix: A suffix that follows the anchor text.
# :param Number start: The start offset for the anchor text.
# :param Number end: The end offset for the anchor text.
###
class TextQuoteAnchor extends Anchor
constructor: (@quote, @prefix='', @suffix='', @start, @end) ->
unless @quote? then missingParameter('quote')
@fromRange: (range, root=document.body, filter=null) ->
range = new xpathRange.BrowserRange(range).normalize(root)
walker = new textWalker.TextWalker(root, filter)
walker.currentNode = range.start
start = walker.tell()
walker.currentNode = range.end
end = walker.tell() + range.end.textContent.length
prefixStart = Math.max(start - 32, 0)
corpus = root.textContent
exact = corpus.substr(start, end - start)
prefix = corpus.substr(prefixStart, start - prefixStart)
suffix = corpus.substr(end, 32)
return new TextQuoteAnchor(exact, prefix, suffix, start, end)
@fromSelector: (selector, position) ->
{exact, prefix, suffix} = selector
{start, end} = position ? {}
return new TextQuoteAnchor(exact, prefix, suffix, start, end)
toRange: (root=document.body) ->
corpus = root.textContent
matcher = new DomTextMatcher(-> corpus)
options =
matchDistance: corpus.length * 2
contextMatchDistance: corpus.length * 2
contextMatchThreshold: 0.5
patternMatchThreshold: 0.5
flexContext: true
withFuzzyComparison: true
if @prefix.length and @suffix.length
result = matcher.searchFuzzyWithContext(
@prefix, @suffix, @quote, @start, @end, true, options)
else if quote.length >= 32
# For short quotes, this is bound to return false positives.
# See https://github.com/hypothesis/h/issues/853 for details.
result = matcher.searchFuzzy(@quote, @start, true, options)
if result.matches.length
match = result.matches[0]
positionAnchor = new TextPositionAnchor(match.start, match.end)
return positionAnchor.toRange()
throw new Error('no match found')
toSelector: ->
selector = {
type: 'TextQuoteSelector'
exact: @quote
}
if @prefix? then selector.prefix = @prefix
if @suffix? then selector.suffix = @suffix
return selector
exports.Anchor = Anchor
exports.FragmentAnchor = FragmentAnchor
exports.RangeAnchor = RangeAnchor
exports.TextPositionAnchor = TextPositionAnchor
exports.TextQuoteAnchor = TextQuoteAnchor
Annotator = require('annotator')
$ = 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.
exports.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
Annotator = require('annotator')
$ = Annotator.$
th = require('../texthighlights')
highlight = require('../highlight')
assert = chai.assert
sinon.assert.expose(assert, prefix: '')
describe 'Annotator.Plugin.TextHighlight', ->
describe 'TextHighlight', ->
sandbox = null
scrollTarget = null
createTestHighlight = ->
anchor =
id: "test anchor"
annotation: "test annotation"
anchoring:
id: "test anchoring manager"
annotator:
id: "test annotator"
element:
delegate: sinon.spy()
new th.TextHighlight anchor, "test page", "test range"
new highlight.TextHighlight "test range"
beforeEach ->
sandbox = sinon.sandbox.create()
sandbox.stub th.TextHighlight, 'highlightRange',
sandbox.stub highlight.TextHighlight, 'highlightRange',
(normedRange, cssClass) ->
hl = document.createElement "hl"
hl.appendChild document.createTextNode "test highlight span"
......@@ -43,17 +33,12 @@ describe 'Annotator.Plugin.TextHighlight', ->
describe "constructor", ->
it 'wraps a highlight span around the given range', ->
hl = createTestHighlight()
assert.calledWith th.TextHighlight.highlightRange, "test range"
assert.calledWith highlight.TextHighlight.highlightRange, "test range"
it 'stores the created highlight spans in _highlights', ->
hl = createTestHighlight()
assert.equal hl._highlights.textContent, "test highlight span"
it "assigns the annotation as data to the highlight span", ->
hl = createTestHighlight()
annotation = Annotator.$(hl._highlights).data "annotation"
assert.equal annotation, "test annotation"
describe "getBoundingClientRect", ->
it 'returns the bounding box of all the highlight client rectangles', ->
......@@ -86,7 +71,7 @@ describe 'Annotator.Plugin.TextHighlight', ->
fakeHighlights = rects.map (r) ->
return getBoundingClientRect: -> r
hl = _highlights: fakeHighlights
result = th.TextHighlight.prototype.getBoundingClientRect.call(hl)
result = highlight.TextHighlight.prototype.getBoundingClientRect.call(hl)
assert.equal(result.left, 10)
assert.equal(result.top, 10)
assert.equal(result.right, 30)
......
###*
# The `text-walker` module provides a single class, `TextWalker`, and
# assocatied constants for seeking within the text content of a `Node` tree.
###
SEEK_SET = 0
SEEK_CUR = 1
SEEK_END = 2
# A NodeFilter bitmask that matches node types included by `Node.textContent`.
TEXT_CONTENT_FILTER = (
NodeFilter.SHOW_ALL &
~NodeFilter.SHOW_COMMENT &
~NodeFilter.SHOW_PROCESSING_INSTRUCTION
)
class TextWalker
constructor: (root, filter) ->
@root = root
@currentNode = root
@filter = filter
@offset = 0
###*
# Seek the `TextWalker` to a new text offset.
#
# The value of `whence` determines the meaning of `offset`. It may be one
# of `SEEK_SET`, `SEEK_CUR` or `SEEK_END`. The meaning is the same as for
# POSIX lseek(2) except that it is impossible to seek past the end of the
# text.
###
seek: (offset, whence) ->
walker = document.createTreeWalker(@root, TEXT_CONTENT_FILTER, @filter)
switch whence
when SEEK_SET
@offset = 0
walker.currentNode = @root
when SEEK_CUR
# XXX: Only two hard problems...
walker.currentNode = @currentNode
when SEEK_END
throw new Error('Seeking from the end not yet supported')
# Walk forwards
while offset > 0
step = walker.currentNode.textContent.length
# If this node is longer than the remainder to seek then step in to it.
if step > offset
# If there is no smaller step to take then finish.
if walker.firstChild() is null
break
# Otherwise, continue with the first child.
else
continue
# If this node is not longer than the seek then try to step over it.
else if walker.nextSibling() is null
# Failing that, step out or finish.
if walker.nextNode() is null
break
# Update the instance offset cache
@offset += step
# Decrease the remainder offset and continue.
offset -= step
# Walk backwards
while offset < 0
throw new Error('Negative offset values not yet supported.')
# Store the current node
@currentNode = walker.currentNode
# Return the offset.
return @offset
tell: ->
# Calculating the offset is the safest way to be correct even if the DOM
# has changed since this instance was created, but it is obviously slow.
offset = 0
walker = document.createTreeWalker(@root, TEXT_CONTENT_FILTER, @filter)
# Start from the current node.
walker.currentNode = @currentNode
# Continue until reaching the root.
while walker.currentNode isnt walker.root
# Step backwards through siblings, to count the leading content.
while node = walker.previousSibling()
offset += node.textContent.length
# Step up to the parent and continue until done.
walker.parentNode()
# Store and return the offset.
@offset = offset
return @offset
exports.SEEK_SET = SEEK_SET
exports.SEEK_CUR = SEEK_CUR
exports.SEEK_END = SEEK_END
exports.TextWalker = TextWalker
var Annotator = require('annotator');
// Monkeypatch annotator!
require('./monkey');
// Scroll plugin for jQuery
// TODO: replace me
require('jquery-scrollintoview')
// Polyfills
var g = Annotator.Util.getGlobal();
if (g.wgxpath) g.wgxpath.install();
// Applications
Annotator.Guest = require('./guest')
......@@ -13,39 +18,15 @@ Annotator.Plugin.CrossFrame.Bridge = require('../bridge')
Annotator.Plugin.CrossFrame.AnnotationSync = require('../annotation-sync')
Annotator.Plugin.CrossFrame.Discovery = require('../discovery')
// Document plugin
require('../vendor/annotator.document');
// Bucket bar
require('./plugin/bucket-bar');
// Toolbar
require('./plugin/toolbar');
// Drawing highlights
require('./plugin/texthighlights');
// Creating selections
require('./plugin/textselection');
// URL fragments
require('./plugin/fragmentselector');
// Anchoring dependencies
require('diff-match-patch')
require('dom-text-mapper')
require('dom-text-matcher')
require('page-text-mapper-core')
require('text-match-engines')
// Anchoring plugins
require('./plugin/enhancedanchoring');
require('./plugin/domtextmapper');
require('./plugin/fuzzytextanchors');
require('./plugin/pdf');
require('./plugin/textquote');
require('./plugin/textposition');
require('./plugin/textrange');
var docs = 'https://h.readthedocs.org/en/latest/hacking/customized-embedding.html';
var options = {
......@@ -54,6 +35,15 @@ var options = {
Toolbar: {container: '.annotator-frame'}
};
// Document metadata plugins
if (window.PDFViewerApplication) {
require('./plugin/pdf')
options['PDF'] = {};
} else {
require('../vendor/annotator.document');
options['Document'] = {};
}
// Simple IE autodetect function
// See for example https://stackoverflow.com/questions/19999388/jquery-check-if-user-is-using-ie/21712356#21712356
var ua = window.navigator.userAgent;
......
Annotator = require('annotator')
# Save references to Range and Util (because we call Annotator.noConflict() when
# bootstrapping)
Range = Annotator.Range
Util = Annotator.Util
# Disable Annotator's default highlight events
delete Annotator.prototype.events[".annotator-hl mouseover"]
delete Annotator.prototype.events[".annotator-hl mouseout"]
# Override setupAnnotation
Annotator.prototype.setupAnnotation = (annotation) ->
# If this is a new annotation, we might have to add the targets
annotation.target ?= @selectedTargets
@selectedTargets = []
annotation.anchors = []
for t in annotation.target ? []
try
# Create an anchor for this target
result = this.anchoring.createAnchor annotation, t
anchor = result.result
if result.error? instanceof Range.RangeError
this.publish 'rangeNormalizeFail', [annotation, result.error.range, result.error]
if anchor?
t.diffHTML = anchor.diffHTML
t.diffCaseOnly = anchor.diffCaseOnly
catch exception
console.log "Error in setupAnnotation for", annotation.id,
":", exception.stack ? exception
if annotation.target?.length and not annotation.anchors?.length
annotation.$orphan = true
annotation
# Override deleteAnnotation to deal with anchors, not highlights.
Annotator.prototype.deleteAnnotation = (annotation) ->
if annotation.anchors?
for a in annotation.anchors
a.remove()
this.publish('annotationDeleted', [annotation])
annotation
# Checks for the presence of wicked-good-xpath
# It is always safe to install it, it'll not overwrite existing functions
g = Annotator.Util.getGlobal()
if g.wgxpath? then g.wgxpath.install()
Range.BrowserRange.prototype.normalize = (root) ->
if @tainted
console.error(_t("You may only call normalize() once on a BrowserRange!"))
return false
else
@tainted = true
r = {}
# Look at the start
if @startContainer.nodeType is Node.ELEMENT_NODE
# We are dealing with element nodes
r.start = Util.getFirstTextNodeNotBefore @startContainer.childNodes[@startOffset]
r.startOffset = 0
else
# We are dealing with simple text nodes
r.start = @startContainer
r.startOffset = @startOffset
# Look at the end
if @endContainer.nodeType is Node.ELEMENT_NODE
# Get specified node.
node = @endContainer.childNodes[@endOffset]
if node? # Does that node exist?
# Look for a text node either at the immediate beginning of node
n = node
while n? and (n.nodeType isnt Node.TEXT_NODE)
n = n.firstChild
if n? # Did we find a text node at the start of this element?
# Check the previous sibling
prev = n.previousSibling
if prev? and (prev.nodeType is Node.TEXT_NODE)
# We have another text righ before us. Use that instead.
r.end = prev
r.endOffset = prev.nodeValue.length
else
# No, we need to stick to this node.
r.end = n
r.endOffset = 0
unless r.end?
# We need to find a text node in the previous sibling of the node at the
# given offset, if one exists, or in the previous sibling of its container.
if @endOffset
node = @endContainer.childNodes[@endOffset - 1]
else
node = @endContainer.previousSibling
r.end = Util.getLastTextNodeUpTo node
r.endOffset = r.end.nodeValue.length
else # We are dealing with simple text nodes
r.end = @endContainer
r.endOffset = @endOffset
# We have collected the initial data.
# Now let's start to slice & dice the text elements!
nr = {}
changed = false
if r.startOffset > 0
# Do we really have to cut?
if r.start.nodeValue.length > r.startOffset
# Yes. Cut.
nr.start = r.start.splitText(r.startOffset)
changed = true
else
# Avoid splitting off zero-length pieces.
nr.start = r.start.nextSibling
else
nr.start = r.start
# is the whole selection inside one text element ?
if r.start is r.end
if nr.start.nodeValue.length > (r.endOffset - r.startOffset)
nr.start.splitText(r.endOffset - r.startOffset)
changed = true
nr.end = nr.start
else # no, the end of the selection is in a separate text element
# does the end need to be cut?
if r.end.nodeValue.length > r.endOffset
r.end.splitText(r.endOffset)
changed = true
nr.end = r.end
# Make sure the common ancestor is an element node.
nr.commonAncestor = @commonAncestorContainer
while nr.commonAncestor.nodeType isnt Node.ELEMENT_NODE
nr.commonAncestor = nr.commonAncestor.parentNode
if changed
event = document.createEvent "UIEvents"
event.initUIEvent "domChange", true, false, window, 0
event.reason = "range normalization"
event.data = nr
nr.commonAncestor.dispatchEvent event
new Range.NormalizedRange(nr)
raf = require('raf')
Annotator = require('annotator')
$ = Annotator.$
# Get the bounding client rectangle of a collection in viewport coordinates.
getBoundingClientRect = (collection) ->
# Reduce the client rectangles of the highlights to a bounding box
rects = collection.map((n) -> n.getBoundingClientRect())
return rects.reduce (acc, r) ->
top: Math.min(acc.top, r.top)
left: Math.min(acc.left, r.left)
bottom: Math.max(acc.bottom, r.bottom)
right: Math.max(acc.right, r.right)
# Scroll to the next closest anchor off screen in the given direction.
scrollToClosest = (objs, direction) ->
dir = if direction is "up" then +1 else -1
{next} = objs.reduce (acc, obj) ->
{start, next} = acc
rect = getBoundingClientRect(obj.highlights)
# Ignore if it's not in the right direction.
if (dir is 1 and rect.top >= 0)
return acc
else if (dir is -1 and rect.top <= window.innerHeight)
return acc
# Select the closest to carry forward
if not next?
start: rect.top
next: obj
else if start * dir < rect.top * dir
start: rect.top
next: obj
else
acc
, {}
$(next.highlights).scrollintoview()
class Annotator.Plugin.BucketBar extends Annotator.Plugin
# prototype constants
BUCKET_THRESHOLD_PAD: 106
......@@ -50,27 +88,6 @@ class Annotator.Plugin.BucketBar extends Annotator.Plugin
$(window).on 'resize scroll', this._scheduleUpdate
$(document.body).on 'resize scroll', '*', this._scheduleUpdate
# Event handler to to update when new highlights have been created
@annotator.subscribe "highlightsCreated", (highlights) =>
# All the highlights are guaranteed to belong to one anchor,
# so we can do this:
anchor = if Array.isArray highlights # Did we got a list ?
highlights[0].anchor
else
# I see that somehow if I publish an array with a signel element,
# by the time it arrives, it's not an array any more.
# Weird, but for now, let's work around it.
highlights.anchor
if anchor.annotation.id? # Is this a finished annotation ?
this._scheduleUpdate()
# Event handler to to update when highlights have been removed
@annotator.subscribe "highlightRemoved", (highlight) =>
if highlight.annotation.id? # Is this a finished annotation ?
this._scheduleUpdate()
addEventListener "docPageScrolling", this._scheduleUpdate
# Update sometime soon
_scheduleUpdate: =>
return if @_updatePending?
......@@ -86,104 +103,29 @@ class Annotator.Plugin.BucketBar extends Annotator.Plugin
return 1
return 0
_collectVirtualAnnotations: (startPage, endPage) ->
results = []
for page in [startPage .. endPage]
anchors = @annotator.anchoring.anchors[page]
if anchors?
$.merge results, (anchor.annotation for anchor in anchors when not anchor.fullyRealized)
results
# Find the first/last annotation from the list, based on page number,
# and Y offset, if already known, and jump to it.
# If the Y offsets are not yet known, just jump the page,
# wait for the highlights to be realized, and finish the selection then.
_jumpMinMax: (annotations, direction) ->
unless direction in ["up", "down"]
throw "Direction is mandatory!"
dir = if direction is "up" then +1 else -1
{next} = annotations.reduce (acc, ann) ->
{start, next} = acc
anchor = ann.anchors[0]
hl = anchor.highlight[anchor.startPage]
# Ignore this anchor if its highlight is currently on screen.
if hl?
rect = hl.getBoundingClientRect()
switch dir
when 1
if rect.bottom >= 0
return acc
when -1
if rect.top <= window.innerHeight
return acc
if not next? or start.page*dir < anchor.startPage*dir
# This one is obviously better
start:
page: anchor.startPage
top: anchor.highlight[anchor.startPage]?.getTop()
next: [anchor]
else if start.page is anchor.startPage
# This is on the same page, might be better
if hl?
# We have a real highlight, let's compare coordinates
if start.top*dir < hl.getTop()*dir
# OK, this one is better
start:
page: start.page
top: hl.getTop()
next: [anchor]
else
# No, let's keep the old one instead
acc
else
# The page is not yet rendered, can't decide yet.
# Let's just store this one, too
start: page: start.page
next: $.merge next, [anchor]
else
# No, we have clearly seen better alternatives
acc
, {}
# Get an anchor from the page we want to go to
anchor = next[0]
anchor.scrollToView()
_update: =>
wrapper = @annotator.wrapper
highlights = @annotator.anchoring.getHighlights()
defaultView = wrapper[0].ownerDocument.defaultView
# Keep track of buckets of annotations above and below the viewport
above = []
below = []
# Get the page numbers
mapper = @annotator.anchoring.document
return unless mapper? # Maybe it's too soon to do this
firstPage = 0
currentPage = mapper.getPageIndex()
lastPage = mapper.getPageCount() - 1
# Construct indicator points
points = @annotator.anchored.reduce (points, obj, i) =>
hl = obj.highlights
# Collect the virtual anchors from above and below
$.merge above, this._collectVirtualAnnotations 0, currentPage-1
$.merge below, this._collectVirtualAnnotations currentPage+1, lastPage
if hl.length is 0
return points
# Construct indicator points
points = highlights.reduce (points, hl, i) =>
d = hl.annotation
x = hl.getTop() - defaultView.pageYOffset
h = hl.getHeight()
rect = getBoundingClientRect(hl)
x = rect.top
h = rect.bottom - rect.top
if x < 0
if d not in above then above.push d
if obj not in above then above.push obj
else if x + h > window.innerHeight
if d not in below then below.push d
if obj not in below then below.push obj
else
points.push [x, 1, d]
points.push [x + h, -1, d]
points.push [x, 1, obj]
points.push [x + h, -1, obj]
points
, []
......@@ -209,23 +151,23 @@ class Annotator.Plugin.BucketBar extends Annotator.Plugin
.sort(this._collate)
.reduce ({buckets, index, carry}, [x, d, a], i, points) =>
if d > 0 # Add annotation
if (j = carry.annotations.indexOf a) < 0
carry.annotations.unshift a
if (j = carry.objs.indexOf a) < 0
carry.objs.unshift a
carry.counts.unshift 1
else
carry.counts[j]++
else # Remove annotation
j = carry.annotations.indexOf a # XXX: assert(i >= 0)
j = carry.objs.indexOf a # XXX: assert(i >= 0)
if --carry.counts[j] is 0
carry.annotations.splice j, 1
carry.objs.splice j, 1
carry.counts.splice j, 1
if (
(index.length is 0 or i is points.length - 1) or # First or last?
carry.annotations.length is 0 or # A zero marker?
carry.objs.length is 0 or # A zero marker?
x - index[index.length-1] > @options.gapSize # A large gap?
) # Mark a new bucket.
buckets.push carry.annotations.slice()
buckets.push carry.objs.slice()
index.push x
else
# Merge the previous bucket, making sure its predecessor contains
......@@ -238,7 +180,7 @@ class Annotator.Plugin.BucketBar extends Annotator.Plugin
else
last = buckets[buckets.length-1]
toMerge = []
last.push a0 for a0 in carry.annotations when a0 not in last
last.push a0 for a0 in carry.objs when a0 not in last
last.push a0 for a0 in toMerge when a0 not in last
{buckets, index, carry}
......@@ -246,7 +188,7 @@ class Annotator.Plugin.BucketBar extends Annotator.Plugin
buckets: []
index: []
carry:
annotations: []
objs: []
counts: []
latest: 0
......@@ -289,31 +231,30 @@ class Annotator.Plugin.BucketBar extends Annotator.Plugin
# TODO: This should use event delegation on the container.
.on 'mousemove', (event) =>
bucket = @tabs.index(event.currentTarget)
for hl in @annotator.anchoring.getHighlights()
if hl.annotation in @buckets[bucket]
hl.setFocused true
else
hl.setFocused false
for obj in @annotator.anchored
toggle = obj in @buckets[bucket]
$(obj.highlights).toggleClass('annotator-hl-focused', toggle)
# Gets rid of them after
.on 'mouseout', =>
for hl in @annotator.anchoring.getHighlights()
hl.setFocused false
.on 'mouseout', (event) =>
bucket = @tabs.index(event.currentTarget)
for obj in @buckets[bucket]
$(obj.highlights).removeClass('annotator-hl-focused')
# Does one of a few things when a tab is clicked depending on type
.on 'click', (event) =>
bucket = @tabs.index(event.currentTarget)
event.stopPropagation()
pad = defaultView.innerHeight * .2
pad = window.innerHeight * .2
# If it's the upper tab, scroll to next anchor above
if (@isUpper bucket)
@_jumpMinMax @buckets[bucket], "up"
scrollToClosest(@buckets[bucket], 'up')
# If it's the lower tab, scroll to next anchor below
else if (@isLower bucket)
@_jumpMinMax @buckets[bucket], "down"
scrollToClosest(@buckets[bucket], 'down')
else
annotations = @buckets[bucket].slice()
annotations = (obj.annotation for obj in @buckets[bucket])
annotator.selectAnnotations annotations,
(event.ctrlKey or event.metaKey),
......
# 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
@anchoring = @annotator.anchoring
@anchoring.documentAccessStrategies.unshift
# Document access strategy for simple HTML documents,
# with enhanced text extraction and mapping features.
name: "DOM-Text-Mapper"
mapper: window.DomTextMapper
init: => @anchoring.document.setRootNode @annotator.wrapper[0]
Annotator = require('annotator')
$ = Annotator.$
# Fake two-phase / pagination support, used for HTML documents
class DummyDocumentAccess
@applicable: -> true
getPageIndex: -> 0
getPageCount: -> 1
getPageIndexForPos: -> 0
isPageMapped: -> true
scan: ->
# Abstract anchor class.
class Anchor
constructor: (@anchoring, @annotation, @target
@startPage, @endPage,
@quote, @diffHTML, @diffCaseOnly) ->
unless @anchoring? then throw "anchoring manager 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 = {}
_getSegment: (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) =>
@anchoring.document.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
# TODO: add a layer of abstraction here
# Don't call TextHighlight directly; instead, make a system
# For registering highlight creators, or publish an event, or
# whatever
@highlight[page] = Annotator.TextHighlight.createFrom @_getSegment(page), this, page
# Check if everything is rendered now
@fullyRealized = renderedPages.length is @endPage - @startPage + 1
# Announce the creation of the highlights
@anchoring.annotator.publish 'highlightsCreated', created
# If we are supposed to scroll to the highlight on a page,
# and it's available now, go scroll there.
if @pendingScrollTargetPage? and (hl = @highlight[@pendingScrollTargetPage])
hl.scrollToView()
delete @pendingScrollTargetPage
# 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
@anchoring.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 = @anchoring.anchors[index]
# Remove the anchor from the list
i = anchors.indexOf this
anchors[i..i] = []
# Kill the list if it's empty
delete @anchoring.anchors[index] unless anchors.length
# Scroll to this anchor
scrollToView: ->
currentPage = @anchoring.document.getPageIndex()
if @startPage is @endPage and currentPage is @startPage
# It's all in one page. Simply scrolling
@highlight[@startPage].scrollToView()
else
if currentPage < @startPage
# We need to go forward
wantedPage = @startPage
scrollPage = wantedPage - 1
else if currentPage > @endPage
# We need to go backwards
wantedPage = @endPage
scrollPage = wantedPage + 1
else
# We have no idea where we need to go.
# Let's just go to the start.
wantedPage = @startPage
scrollPage = wantedPage
# Is this rendered?
if @anchoring.document.isPageMapped wantedPage
# The wanted page is already rendered, we can simply go there
@highlight[wantedPage].scrollToView()
else
# Not rendered yet. Go to the page, we will continue from there
@pendingScrollTargetPage = wantedPage
@anchoring.document.setPageIndex scrollPage
null
Annotator.Anchor = Anchor
# This plugin contains the enhanced anchoring framework.
class Annotator.Plugin.EnhancedAnchoring extends Annotator.Plugin
constructor: ->
# Initializes the available document access strategies
_setupDocumentAccessStrategies: ->
@documentAccessStrategies = [
# Default dummy strategy for simple HTML documents.
# The generic fallback.
name: "Dummy"
mapper: DummyDocumentAccess
]
this
# Initializes the components used for analyzing the document
chooseAccessPolicy: ->
if @document? then return
# Go over the available strategies
for s in @documentAccessStrategies
# Can we use this strategy for this document?
if s.mapper.applicable()
@documentAccessStrategy = s
@document = new s.mapper()
@anchors = {}
addEventListener "docPageMapped", (evt) =>
@_realizePage evt.pageIndex
addEventListener "docPageUnmapped", (evt) =>
@_virtualizePage evt.pageIndex
s.init?()
return this
# Remove the current document access policy
_removeCurrentAccessPolicy: ->
return unless @document?
list = @documentAccessStrategies
index = list.indexOf @documentAccessStrategy
list.splice(index, 1) unless index is -1
@document.destroy?()
delete @document
# Perform a scan of the DOM. Required for finding anchors.
_scan: ->
# Ensure that we have a document access strategy
@chooseAccessPolicy()
try
@pendingScan = @document.scan()
catch
@_removeCurrentAccessPolicy()
@_scan()
return
# Plugin initialization
pluginInit: ->
@selectorCreators = []
@strategies = []
@_setupDocumentAccessStrategies()
self = this
@annotator.anchoring = this
# Override loadAnnotations to account for the possibility that the anchoring
# plugin is currently scanning the page.
_loadAnnotations = Annotator.prototype.loadAnnotations
Annotator.prototype.loadAnnotations = (annotations=[]) ->
if self.pendingScan?
# Schedule annotation load for when scan has finished
self.pendingScan.then =>
_loadAnnotations.call(this, annotations)
else
_loadAnnotations.call(this, annotations)
# PUBLIC Try to find the right anchoring point for a given target
#
# Returns an Anchor object if succeeded, null otherwise
createAnchor: (annotation, target) ->
unless target?
throw new Error "Trying to find anchor for null target!"
error = null
anchor = null
for s in @strategies
try
a = s.code.call this, annotation, target
if a
# Store this anchor for the annotation
annotation.anchors.push a
# Store the anchor for all involved pages
for pageIndex in [a.startPage .. a.endPage]
@anchors[pageIndex] ?= []
@anchors[pageIndex].push a
# Realizing the anchor
a.realize()
return result: a
catch error
console.log "Strategy '" + s.name + "' has thrown an error.",
error.stack ? error
return error: "No strategies worked."
# Do some normalization to get a "canonical" form of a string.
# Used to even out some browser differences.
normalizeString: (string) -> string.replace /\s{2,}/g, " "
# Find the given type of selector from an array of selectors, if it exists.
# If it does not exist, null is returned.
findSelector: (selectors, type) ->
for selector in selectors
if selector.type is type then return selector
null
# Realize anchors on a given pages
_realizePage: (index) ->
# If the page is not mapped, give up
return unless @document.isPageMapped index
# Go over all anchors related to this page
for anchor in @anchors[index] ? []
anchor.realize()
# Virtualize anchors on a given page
_virtualizePage: (index) ->
# Go over all anchors related to this page
for anchor in @anchors[index] ? []
anchor.virtualize index
# Collect all the highlights (optionally for a given set of annotations)
getHighlights: (annotations) ->
results = []
for anchor in @getAnchors(annotations)
for page, highlight of anchor.highlight
results.push highlight
results
# Collect all the anchors (optionally for a given set of annotations)
getAnchors: (annotations) ->
results = []
if annotations?
# Collect only the given set of annotations
for annotation in annotations
$.merge results, annotation.anchors
else
# Collect from everywhere
for page, anchors of @anchors
$.merge results, anchors
results
# PUBLIC entry point 1:
# This is called to create a target from a raw selection,
# using selectors created by the registered selector creators
getSelectorsFromSelection: (selection) =>
selectors = []
for c in @selectorCreators
description = c.describe selection
for selector in description
selectors.push selector
selectors
exports.Anchor = Anchor
exports.EnhancedAnchoring = Annotator.Plugin.EnhancedAnchoring
Annotator = require('annotator')
# 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.TextPosition
console.warn "The FuzzyTextAnchors Annotator plugin requires the TextPosition plugin. Skipping."
return
@anchoring = @annotator.anchoring
# Initialize the text matcher library
@textFinder = new DomTextMatcher => @anchoring.document.getCorpus()
# Register our fuzzy strategies
@anchoring.strategies.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
@anchoring.strategies.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) =>
document = @anchoring.document
# This won't work without DTM
return unless document.getInfoForNode?
# Fetch the quote and the context
quoteSelector = @anchoring.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 = @anchoring.findSelector target.selector, "TextPositionSelector"
expectedStart = posSelector?.start
expectedEnd = posSelector?.end
options =
contextMatchDistance: document.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 @anchoring, annotation, target,
match.start, match.end,
(document.getPageIndexForPos match.start),
(document.getPageIndexForPos match.end),
match.found,
unless match.exact then match.comparison.diffHTML,
unless match.exact then match.exactExceptCase
fuzzyMatching: (annotation, target) =>
document = @anchoring.document
# This won't work without DTM
return unless document.getInfoForNode?
# Fetch the quote
quoteSelector = @anchoring.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 = @anchoring.findSelector target.selector, "TextPositionSelector"
expectedStart = posSelector?.start
# Get full document length
len = document.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 @anchoring, annotation, target,
match.start, match.end,
(document.getPageIndexForPos match.start),
(document.getPageIndexForPos match.end),
match.found,
unless match.exact then match.comparison.diffHTML,
unless match.exact then match.exactExceptCase
Promise = require('es6-promise').Promise
Annotator = require('annotator')
$ = Annotator.$
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
class PDF extends Annotator.Plugin
documentPromise: null
# 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
else
@_app = @_viewer = PDFView
@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 =>
# Initialize our main page data array
@pageInfo = []
# Start the text extraction
@_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) ->
# Wait for the page to load
@_app.pdfDocument.getPage(pageIndex + 1).then (page) =>
# Wait for the data to be extracted
page.getTextContent().then (data) =>
# First, join all the pieces from the bidiTexts
rawContent = (text.str for text in data.items).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
pluginInit: ->
# We need dom-text-mapper
unless @annotator.plugins.DomTextMapper
console.warn "The PDF Annotator plugin requires the DomTextMapper plugin. Skipping."
return
@anchoring = @annotator.anchoring
@anchoring.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 = @anchoring.document.getDocumentFingerprint()
@documentPromise = new Promise (resolveDocument, rejectDocument) ->
resolveDocumentAndCleanup = (evt) ->
window.removeEventListener('documentload', resolveDocumentAndCleanup)
debugger
resolveDocument(evt)
window.addEventListener('documentload', resolveDocument)
# This is an experimental URN,
# as per http://tools.ietf.org/html/rfc3406#section-3.0
"urn:x-pdf:" + fingerprint
window.addEventListener('pagerendered', @onpagerendered)
# Public: get a canonical URI, if this is a PDF. (Null otherwise)
uri: ->
return null unless @_isPDF()
destroy: ->
window.removeEventListener('pagerendered', @onpagerendered)
# 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()
getMetadata: ->
Promise.resolve({})
# Try to extract the title; first from metadata, then HTML header
_getTitle: ->
title = @anchoring.document.getDocumentInfo().Title?.trim()
if title? and title isnt ""
title
else
$("head title").text().trim()
onpagerendered: =>
succeed = ({annotation, target}) ->
(highlights) -> {annotation, target, highlights}
# Get metadata
_metadata: ->
metadata =
link: [{
href: @_getFingerPrintURI()
}]
title: @_getTitle()
fail = ({annotation, target}) ->
(reason) -> {annotation, target}
documentURI = @_getDocumentURI()
if documentURI.toLowerCase().indexOf('file://') is 0
metadata.filename = new URL(documentURI).pathname.split('/').pop()
else
metadata.link.push {href: documentURI}
finish = (results) =>
anchored = @annotator.anchored
unanchored = @annotator.unanchored
updated = for result in results when result.highlights?
delete result.annotation.$orphan
anchored.push(result)
result
@annotator.unanchored = unanchored.filter((o) -> o not in anchored)
@annotator.plugins.CrossFrame.sync(updated) if updated.length
metadata
promises = for obj in @annotator.unanchored
@annotator.anchorTarget(obj.target).then(succeed(obj), fail(obj))
# Public: Get metadata (when the doc is loaded). Returns a promise.
getMetaData: =>
new Promise (resolve, reject) =>
if @anchoring.document.waitForInit?
@anchoring.document.waitForInit().then =>
try
resolve @_metadata()
catch error
reject "Internal error"
else
reject "Not a PDF dom mapper."
Promise.all(promises).then(finish)
# We want to react to some events
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
Annotator.Plugin.PDF = PDF
# This is what we do to new annotations
beforeAnnotationCreated: (annotation) =>
return unless @_isPDF()
annotation.document = @_metadata()
module.exports = PDF
Promise = require('es6-promise').Promise
Annotator = require('annotator')
ea = require('../enhancedanchoring')
assert = chai.assert
sinon.assert.expose(assert, prefix: '')
# Then Anchor class is not supposed to be used directly.
# Every concrete implementation should have it's own class,
# encompassing a way to identify the given segment of the document.
#
# For testing, we will use the TestAnchor class,
# which does not actually identify a real segment of the HTML document.
class TestAnchor extends ea.Anchor
_getSegment: -> "html segment for " + @id
constructor: (manager, annotation, target, startPage, endPage) ->
super manager, annotation, target, startPage, endPage,
"fake quote for" + target.id
@id = "fake anchor for " + target.id
describe 'Annotator.Plugin.EnhancedAnchoring', ->
sandbox = null
pendingTest = null
am = null
ann = null
anchor = null
beforeEach ->
sandbox = sinon.sandbox.create()
Annotator.TextHighlight = {
createFrom: (segment, anchor, page) ->
segment: segment
anchor: anchor
page: page
removeFromDocument: sinon.spy()
scrollToView: sinon.spy -> pendingTest?.resolve()
}
afterEach ->
sandbox.restore()
pendingTest = null
am = null
ann = null
anchor = null
describe "single-phase anchoring", ->
createTestAnnotation = (id, targets = 1) ->
id: "annotation " + id
target: (("target " + id + "-" + num) for num in [1 .. targets])
anchors: []
createAnchoringManager = ->
annotator =
publish: sinon.spy()
am = new ea.EnhancedAnchoring()
am.annotator = annotator
am.pluginInit()
am.chooseAccessPolicy()
am.document.setPageIndex = sinon.spy()
am.strategies.push
name: "dummy anchoring strategy"
code: (annotation, target) ->
new TestAnchor am, annotation, target, 42, 42
am
beforeEach ->
am = createAnchoringManager()
describe "createAnchor() - single-phase", ->
beforeEach ->
ann = createTestAnnotation "a1"
anchor = am.createAnchor(ann, ann.target[0]).result
it 'adds an anchor property to the annotations', ->
ann = createTestAnnotation "a1", 2
anchor1 = am.createAnchor(ann, ann.target[0]).result
anchor2 = am.createAnchor(ann, ann.target[1]).result
assert.isArray ann.anchors
assert.include ann.anchors, anchor1
assert.include ann.anchors, anchor2
assert.equal ann.anchors.length, 2
it 'adds an annotation property to the created anchors', ->
assert.equal anchor.annotation, ann
it 'adds a target property to the created anchors', ->
assert.equal anchor.target, ann.target[0]
it 'creates the anchors from the right targets', ->
assert.equal anchor.id, "fake anchor for " + anchor.target.id
it 'adds the created anchors to the correct per-page array', ->
assert.include am.anchors[anchor.startPage], anchor
it 'adds the created highlights to the anchors', ->
assert.isObject anchor.highlight
page = anchor.startPage
hl = anchor.highlight[page]
assert.ok hl
assert.equal hl.page, page
it 'announces the creation of the highlights in an event', ->
hl = anchor.highlight[anchor.startPage]
assert.calledWith am.annotator.publish, 'highlightsCreated', [hl]
it 'adds an anchor property to the created Highlights', ->
page = anchor.startPage
hl = anchor.highlight[page]
assert.equal hl.anchor, anchor
describe "getAnchors()", ->
it 'returns an empty array by default', ->
anchors = am.getAnchors()
assert.isArray anchors
assert.equal anchors.length, 0
it 'returns all the anchors', ->
ann1 = createTestAnnotation "a1", 2
ann2 = createTestAnnotation "a2"
ann3 = createTestAnnotation "a3"
anchor11 = am.createAnchor(ann1, ann1.target[0]).result
anchor12 = am.createAnchor(ann1, ann1.target[1]).result
anchor2 = am.createAnchor(ann2, ann2.target).result
anchor3 = am.createAnchor(ann3, ann2.target).result
anchors = am.getAnchors()
assert.isArray anchors
assert.include anchors, anchor11
assert.include anchors, anchor12
assert.include anchors, anchor2
assert.include anchors, anchor3
it 'returns the anchors belonging to a set of annotations', ->
ann1 = createTestAnnotation "a1", 2
ann2 = createTestAnnotation "a2"
ann3 = createTestAnnotation "a3"
anchor11 = am.createAnchor(ann1, ann1.target[0]).result
anchor12 = am.createAnchor(ann1, ann1.target[1]).result
anchor2 = am.createAnchor(ann2, ann2.target).result
anchor3 = am.createAnchor(ann3, ann2.target).result
anchors = am.getAnchors [ann1, ann2]
assert.isArray anchors
assert.include anchors, anchor11
assert.include anchors, anchor12
assert.include anchors, anchor2
assert.notInclude anchors, anchor3
describe 'getHighlights()', ->
it 'returns an empty array by default', ->
hls = am.getHighlights()
assert.isArray hls
assert.equal hls.length, 0
it 'returns all the highlights', ->
ann1 = createTestAnnotation "a1", 2
ann2 = createTestAnnotation "a2"
ann3 = createTestAnnotation "a3"
anchor11 = am.createAnchor(ann1, ann1.target[0]).result
anchor12 = am.createAnchor(ann1, ann1.target[1]).result
anchor2 = am.createAnchor(ann2, ann2.target).result
anchor3 = am.createAnchor(ann3, ann2.target).result
hls = am.getHighlights()
assert.isArray hls
assert.include hls, anchor11.highlight[anchor11.startPage]
assert.include hls, anchor12.highlight[anchor12.startPage]
assert.include hls, anchor2.highlight[anchor2.startPage]
assert.include hls, anchor3.highlight[anchor3.startPage]
it 'returns the highlights belonging to a set of annotations', ->
ann1 = createTestAnnotation "a1", 2
ann2 = createTestAnnotation "a2"
ann3 = createTestAnnotation "a3"
anchor11 = am.createAnchor(ann1, ann1.target[0]).result
anchor12 = am.createAnchor(ann1, ann1.target[1]).result
anchor2 = am.createAnchor(ann2, ann2.target).result
anchor3 = am.createAnchor(ann3, ann2.target).result
hls = am.getHighlights [ann1, ann2]
assert.isArray hls
assert.include hls, anchor11.highlight[anchor11.startPage]
assert.include hls, anchor12.highlight[anchor12.startPage]
assert.include hls, anchor2.highlight[anchor2.startPage]
assert.notInclude hls, anchor3.highlight[anchor3.startPage]
describe 'Anchor.scrollToView() - single-phase', ->
it 'calls scrollIntoView() on the highlight', ->
ann = createTestAnnotation "a1"
anchor = am.createAnchor(ann, ann.target[0]).result
anchor.scrollToView()
assert.called anchor.highlight[anchor.startPage].scrollToView
describe 'two-phased anchoring', ->
class DummyDocumentAccess
@applicable: -> true
isPageMapped: (index) -> index in @_rendered
getPageIndex: -> @currentIndex
constructor: ->
@_rendered = []
# Helper function to trigger a page rendering
# This is an asynchronous method; returns a promise.
renderPage = (doc, index) ->
if doc.isPageMapped(index)
throw new Error "Cannot call renderPage with an already mapped index: #{index}, ensure the document is setup correctly"
new Promise (resolve, reject) ->
setTimeout ->
doc._rendered.push index
# Publish an event
event = document.createEvent "UIEvents"
event.initUIEvent "docPageMapped", false, false, window, 0
event.pageIndex = index
window.dispatchEvent event
# Resolve the promise
resolve()
# Helper function to trigger the rendering of several pages.
# This is an asynchronous method; returns a promise.
renderPages = (doc, indexes) ->
Promise.all(renderPage(doc, index) for index in indexes)
# Helper function to trigger a page unrendering
# This is an asynchronous method; returns a promise.
unrenderPage = (doc, index) ->
unless doc.isPageMapped index
throw new Error "Cannot call unrenderPage with an unmapped index: #{index}, ensure the document is setup correctly"
new Promise (resolve, reject) ->
setTimeout ->
i = doc._rendered.indexOf index
doc._rendered.splice(i, 1)
# Publish an event
event = document.createEvent "UIEvents"
event.initUIEvent "docPageUnmapped", false, false, window, 0
event.pageIndex = index
window.dispatchEvent event
# Resolve the promise
resolve()
# Helper function to set up an anchoring manager
# with a document access policy that mimics
# a platform with lazy rendering
createAnchoringManagerAndLazyDocument = ->
annotator =
publish: sinon.spy()
am = new ea.EnhancedAnchoring()
am.annotator = annotator
am.pluginInit()
am.documentAccessStrategies.unshift
name: "Dummy two-phase"
mapper: DummyDocumentAccess
am.strategies.push
name: "dummy anchoring strategy"
code: (annotation, target) ->
new TestAnchor am, annotation, target,
target.startPage, target.endPage
am.chooseAccessPolicy()
am
# Helper function to create an annotation with several
# targets, search of them potentially targeting a given
# range of pages.
createTestAnnotationForPages = (id, pageRanges) ->
result =
id: "annotation " + id
target: []
anchors: []
index = 0
for targetRange in pageRanges
[start, end] = if Array.isArray targetRange
targetRange
else
[targetRange, targetRange]
result.target.push
id: "target " + id + "-" + index++
startPage: start
endPage: end
result
ann = null
beforeEach ->
am = createAnchoringManagerAndLazyDocument()
ann = createTestAnnotationForPages "a1", [1]
describe "when the wanted page is already rendered", ->
it 'creates real anchors', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
assert anchor.fullyRealized
it 'creates highlights', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[1]
assert.ok hl
assert.equal hl.page, 1
it 'announces the highlights with the appropriate event', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[1]
assert.calledWith am.annotator.publish, 'highlightsCreated', [hl]
describe 'when a page is unrendered', ->
it 'calls removeFromDocument an the correct highlight', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[1]
unrenderPage(am.document, 1).then ->
assert.called hl.removeFromDocument
it 'removes highlights from the relevant page', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
unrenderPage(am.document, 1).then ->
assert !anchor.fullyRealized
it 'announces the removal of the highlights from the relevant page', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[1]
unrenderPage(am.document, 1).then ->
assert.calledWith am.annotator.publish, 'highlightRemoved', hl
it 'switches the anchor to virtual', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
unrenderPage(am.document, 1).then ->
assert !anchor.fullyRealized
describe 'when the wanted page is not rendered', ->
it 'creates virtual anchors', ->
anchor = am.createAnchor(ann, ann.target[0]).result
assert !anchor.fullyRealized
it 'creates no highlights', ->
anchor = am.createAnchor(ann, ann.target[0]).result
assert.notOk anchor.highlight[1], "Should not have a highlight on page 1"
it 'announces no highlihts', ->
am.annotator.publish.reset()
anchor = am.createAnchor(ann, ann.target[0]).result
assert.notCalled am.annotator.publish
describe 'when the pages are rendered later on', ->
it 'realizes the anchor', ->
anchor = am.createAnchor(ann, ann.target[0]).result
renderPage(am.document, 1).then ->
assert anchor.fullyRealized
it 'creates the highlight', ->
anchor = am.createAnchor(ann, ann.target[0]).result
renderPage(am.document, 1).then ->
hl = anchor.highlight[1]
assert.ok hl
assert.calledWith am.annotator.publish, 'highlightsCreated', [hl]
it 'announces the highlight', ->
anchor = am.createAnchor(ann, ann.target[0]).result
renderPage(am.document, 1).then ->
hl = anchor.highlight[1]
assert.calledWith am.annotator.publish, 'highlightsCreated', [hl]
describe 'when an anchor spans several pages, some of them rendered', ->
beforeEach ->
ann = createTestAnnotationForPages "a1", [[2,3]]
it 'creates partially realized anchors', ->
renderPage(am.document, 2).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
assert !anchor.fullyRealized
it 'creates the highlights for the rendered pages', ->
renderPage(am.document, 2).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
assert.ok anchor.highlight[2]
it 'creates no highlights for the missing pages', ->
renderPage(am.document, 2).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
assert.notOk anchor.highlight[3]
it 'announces the creation of highlights for the rendered pages', ->
renderPage(am.document, 2).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
assert.calledWith am.annotator.publish,
'highlightsCreated', [anchor.highlight[2]]
describe 'when the missing pages are rendered', ->
it 'the anchor is fully realized', ->
renderPage(am.document, 2).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
renderPage(am.document, 3).then ->
assert anchor.fullyRealized
it 'creates the missing highlights', ->
renderPage(am.document, 2).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
renderPage(am.document, 3).then ->
assert.ok anchor.highlight[3]
it 'announces the creation of the missing highlights', ->
renderPage(am.document, 2).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
renderPage(am.document, 3).then ->
assert.calledWith am.annotator.publish,
'highlightsCreated', [anchor.highlight[3]]
describe 'when an anchor spans several pages, and a page is unrendered', ->
beforeEach ->
ann = createTestAnnotationForPages "a1", [[2,3]]
it 'calls removeFromDocument() on the involved highlight', ->
renderPages(am.document, [2,3]).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[2]
unrenderPage(am.document, 2).then ->
assert.called hl.removeFromDocument
it 'does not call removeFromDocument() on the other highlights', ->
renderPages(am.document, [2,3]).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[3]
unrenderPage(am.document, 2).then ->
assert.notCalled hl.removeFromDocument
it 'removes the involved highlight', ->
renderPages(am.document, [2, 3]).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
unrenderPage(am.document, 2).then ->
assert.notOk anchor.highlight[2]
it 'retains the other highlights', ->
renderPages(am.document, [2,3]).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
unrenderPage(am.document, 2).then ->
assert.ok anchor.highlight[3]
it 'announces the removal of the involved highlight', ->
renderPages(am.document, [2, 3]).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[2]
unrenderPage(am.document, 2).then ->
assert.calledWith am.annotator.publish, 'highlightRemoved', hl
it 'switched the anchor to virtual', ->
renderPages(am.document, [2, 3]).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[2]
unrenderPage(am.document, 2).then ->
assert !anchor.fullyRealized
describe 'manually virtualizing an anchor', ->
beforeEach ->
ann = createTestAnnotationForPages "a1", [1]
it 'calls removeFromDocument() on the highlight', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[1]
anchor.virtualize 1
assert.called hl.removeFromDocument
it 'removes the highlight', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
anchor.virtualize 1
assert.notOk anchor.highlight[1], "the highlight should be no more"
it 'announces the removal of the highlight', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
hl = anchor.highlight[1]
anchor.virtualize 1
assert.calledWith am.annotator.publish, 'highlightRemoved', hl
it 'switches the anchor to virtual', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
anchor.virtualize 1
assert !anchor.fullyRealized
describe 'when re-realizing a manually virtualized anchor', ->
it 're-creates the highlight', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
anchor.virtualize 1
anchor.realize()
assert.ok anchor.highlight[1]
it 'announces the creation of the highlight', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
anchor.virtualize 1
anchor.realize()
hl = anchor.highlight[1]
assert.calledWith am.annotator.publish, 'highlightsCreated', [hl]
it 'realizes the anchor', ->
renderPage(am.document, 1).then ->
anchor = am.createAnchor(ann, ann.target[0]).result
anchor.virtualize 1
anchor.realize()
assert anchor.fullyRealized
describe 'when scrolling to a virtual anchor', ->
watchForScroll = ->
new Promise (resolve, reject) ->
pendingTest = resolve: resolve
beforeEach ->
ann = createTestAnnotationForPages "a1", [10]
it 'scrolls right next to the wanted page (to get it rendered)', ->
anchor = am.createAnchor(ann, ann.target[0]).result
am.document.currentIndex = 5 # We start from page 5
am.document.setPageIndex = sinon.spy()
anchor.scrollToView()
assert.calledWith am.document.setPageIndex, 9
it 'gets the wanted page rendered', ->
anchor = am.createAnchor(ann, ann.target[0]).result
am.document.currentIndex = 5 # We start from page 5
am.document.setPageIndex = sinon.spy (index) ->
am.document.currentIndex = index
if index is 9
renderPage am.document, 9
renderPage am.document, 10
anchor.scrollToView()
watchForScroll().then ->
assert am.document.isPageMapped 10
it 'calls scrollToView() on the highlight', ->
anchor = am.createAnchor(ann, ann.target[0]).result
am.document.currentIndex = 5 # We start from page 5
am.document.setPageIndex = sinon.spy (index) ->
am.document.currentIndex = index
if index is 9
renderPage am.document, 9
renderPage am.document, 10
anchor.scrollToView()
watchForScroll().then ->
assert.called anchor.highlight[10].scrollToView
Annotator = require('annotator')
$ = Annotator.$
require('jquery-scrollintoview')
# 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
class TextHighlight
@highlightRange: highlightRange
@createFrom: (segment, anchor, page) ->
return null if segment.type isnt "magic range"
new TextHighlight anchor, page, segment.data
# List of annotators we have already set up events for
@_inited: []
# Collect the annotations impacted by an event
@getAnnotations: (event) ->
$(event.target)
.parents('.annotator-hl')
.andSelf()
.map(-> $(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
constructor: (@anchor, @pageIndex, normedRange) ->
@annotation = @anchor.annotation
@anchoring = @anchor.anchoring
@annotator = @anchoring.annotator
TextHighlight._init @annotator
# Create highlights and link them with the annotation
@_highlights = TextHighlight.highlightRange(normedRange)
$(@_highlights).data "annotation", @annotation
# 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 @anchoring.document.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 Y offset of the highlight.
getTop: -> $(@_highlights).offset().top
# Get the height of the highlight.
getHeight: -> $(@_highlights).outerHeight true
# Get the bounding rectangle in viewport coordinates.
getBoundingClientRect: ->
# Reduce the client rectangles of the highlights to a bounding box
rects = $(@_highlights).map(-> this.getBoundingClientRect()).get()
return rects.reduce (acc, r) ->
top: Math.min(acc.top, r.top)
left: Math.min(acc.left, r.left)
bottom: Math.max(acc.bottom, r.bottom)
right: Math.max(acc.right, r.right)
# Scroll the highlight into view
scrollToView: ->
$(@_highlights).scrollintoview()
null
class Annotator.Plugin.TextHighlights extends Annotator.Plugin
# Plugin initialization
pluginInit: ->
# Export the text highlight class for other plugins
Annotator.TextHighlight = TextHighlight
exports.TextHighlight = TextHighlight
Annotator = require('annotator')
# This anchor type stores information about a piece of text,
# described using start and end character offsets
class TextPositionAnchor extends Annotator.Anchor
constructor: (anchoring, annotation, target,
@start, @end, startPage, endPage,
quote, diffHTML, diffCaseOnly) ->
super anchoring, 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!"
# This is how we create a highlight out of this kind of anchor
_getSegment: (page) ->
# First we create the range from the stored stard and end offsets
mappings = @anchoring.document.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 @anchoring.annotator.wrapper[0]
type: "magic range"
data: normedRange
# Annotator plugin for text position-based anchoring
class Annotator.Plugin.TextPosition extends Annotator.Plugin
pluginInit: ->
@anchoring = @annotator.anchoring
# Register the creator for text quote selectors
@anchoring.selectorCreators.push
name: "TextPositionSelector"
describe: @_getTextPositionSelector
@anchoring.strategies.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"
document = @anchoring.document
# We need dom-text-mapper - style functionality
return [] unless document.getStartPosForNode?
startOffset = document.getStartPosForNode selection.range.start
endOffset = document.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 = @anchoring.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
document = @anchoring.document
corpus = document.getCorpus?()
# This won't work without d-t-m
return null unless corpus
content = corpus[selector.start ... selector.end].trim()
currentQuote = @anchoring.normalizeString content
savedQuote = @anchoring.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 @anchoring, annotation, target,
selector.start, selector.end,
(document.getPageIndexForPos selector.start),
(document.getPageIndexForPos selector.end),
currentQuote
Annotator = require('annotator')
# This plugin defines the TextQuote selector
class Annotator.Plugin.TextQuote extends Annotator.Plugin
# Plugin initialization
pluginInit: ->
@anchoring = @annotator.anchoring
# Register the creator for text quote selectors
@anchoring.selectorCreators.push
name: "TextQuoteSelector"
describe: @_getTextQuoteSelector
# Register function to get quote from this selector
@anchoring.getQuoteForTarget = (target) =>
selector = @anchoring.findSelector target.selector, "TextQuoteSelector"
if selector?
@anchoring.normalizeString selector.exact
else
null
# Create a TextQuoteSelector around a range
_getTextQuoteSelector: (selection) =>
return [] unless selection.type is "text range"
document = @anchoring.document
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 document.getStartPosForNode?
# Calculate the quote and context using DTM
startOffset = document.getStartPosForNode rangeStart
endOffset = document.getEndPosForNode rangeEnd
if startOffset? and endOffset?
quote = document.getCorpus()[startOffset .. endOffset-1].trim()
[prefix, suffix] = document.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()
]
Annotator = require('annotator')
# 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
constructor: (annotator, annotation, target, @range, quote) ->
super annotator, annotation, target, 0, 0, quote
unless @range? then throw new Error "range is required!"
# This is how we create a highlight out of this kind of anchor
_getSegment: ->
type: "magic range"
data: @range
# Annotator plugin for creating, and anchoring based on text range
# selectors
class Annotator.Plugin.TextRange extends Annotator.Plugin
pluginInit: ->
@anchoring = @annotator.anchoring
# Register the creator for range selectors
@anchoring.selectorCreators.push
name: "RangeSelector"
describe: @_getRangeSelector
# Register our anchoring strategies
@anchoring.strategies.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-hl'
[
type: "RangeSelector"
startContainer: sr.start
startOffset: sr.startOffset
endContainer: sr.end
endOffset: sr.endOffset
]
# Create and anchor using the saved Range selector.
# The quote is verified.
createFromRangeSelector: (annotation, target) =>
document = @anchoring.document
selector = @anchoring.findSelector target.selector, "RangeSelector"
unless selector? then return null
serializedRange = {
start: selector.startContainer
startOffset: selector.startOffset
end: selector.endContainer
endOffset: selector.endOffset
}
# Try to apply the saved XPath
try
range = Annotator.Range.sniff serializedRange
normedRange = range.normalize @annotator.wrapper[0]
catch error
return null
# Get the text of this range
if document.getInfoForNode?
# Determine the current content of the given range using DTM
startInfo = document.getInfoForNode normedRange.start
return null unless startInfo # Don't fret if page is not mapped
startOffset = startInfo.start
endInfo = document.getInfoForNode normedRange.end
return null unless endInfo # Don't fret if page is not mapped
endOffset = endInfo.end
rawQuote = document.getCorpus()[startOffset .. endOffset-1].trim()
else
# Determine the current content of the given range directly
rawQuote = normedRange.text().trim()
currentQuote = @anchoring.normalizeString rawQuote
# Look up the saved quote
savedQuote = @anchoring.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 @anchoring, 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 @anchoring, annotation, target,
normedRange, currentQuote
......@@ -21,80 +21,23 @@ class Annotator.Plugin.TextSelection extends Annotator.Plugin
})
super
# 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 when the mouse is released.
# Checks to see if a selection has been made on mouseup and if so,
# Checks to see if a selection been made on mouseup and if so,
# calls Annotator's onSuccessfulSelection method.
#
# 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.
# but that's not necessary.
#
# Returns nothing.
checkForEndSelection: (event = {}) =>
# Get the currently selected ranges.
selectedRanges = @_getSelectedRanges()
for range in selectedRanges
container = range.commonAncestor
return if @annotator.isAnnotator(container)
if selectedRanges.length
event.segments = []
for r in selectedRanges
event.segments.push
type: "text range"
range: r
selection = Annotator.Util.getGlobal().getSelection()
ranges = for i in [0...selection.rangeCount]
r = selection.getRangeAt(0)
if r.collapsed then continue else r
if ranges.length
event.ranges = ranges
@annotator.onSuccessfulSelection event
else
@annotator.onFailedSelection event
Promise = require('es6-promise').Promise
Annotator = require('annotator')
require('../monkey')
Guest = require('../guest')
assert = chai.assert
......@@ -23,23 +22,6 @@ describe 'Guest', ->
on: sandbox.stub()
sync: sandbox.stub()
# Mock out the anchoring plugin. Oh how I wish I didn't have to do crazy
# shit like this.
Annotator.Plugin.EnhancedAnchoring = -> {
pluginInit: ->
@annotator.anchoring = this
_scan: sandbox.stub()
getHighlights: sandbox.stub().returns([])
getAnchors: sandbox.stub().returns([])
createAnchor: sandbox.spy (annotation, target) ->
anchor = "anchor for " + target
annotation.anchors.push anchor
result: anchor
}
Annotator.Plugin.CrossFrame = -> fakeCrossFrame
sandbox.spy(Annotator.Plugin, 'CrossFrame')
......@@ -195,36 +177,38 @@ describe 'Guest', ->
describe 'on "focusAnnotations" event', ->
it 'focuses any annotations with a matching tag', ->
highlight0 = {setFocused: sandbox.stub()}
highlight1 = {setFocused: sandbox.stub()}
guest = createGuest()
highlights = [
{annotation: {$$tag: 'tag1'}, setFocused: sandbox.stub()}
{annotation: {$$tag: 'tag2'}, setFocused: sandbox.stub()}
guest.anchored = [
{annotation: {$$tag: 'tag1'}, highlight: highlight0}
{annotation: {$$tag: 'tag2'}, highlight: highlight1}
]
guest.anchoring.getHighlights.returns(highlights)
emitGuestEvent('focusAnnotations', 'ctx', ['tag1'])
assert.called(highlights[0].setFocused)
assert.calledWith(highlights[0].setFocused, true)
assert.called(highlight0.setFocused)
assert.calledWith(highlight0.setFocused, true)
it 'unfocuses any annotations without a matching tag', ->
highlight0 = {setFocused: sandbox.stub()}
highlight1 = {setFocused: sandbox.stub()}
guest = createGuest()
highlights = [
{annotation: {$$tag: 'tag1'}, setFocused: sandbox.stub()}
{annotation: {$$tag: 'tag2'}, setFocused: sandbox.stub()}
guest.anchored = [
{annotation: {$$tag: 'tag1'}, highlight: highlight0}
{annotation: {$$tag: 'tag2'}, highlight: highlight1}
]
guest.anchoring.getHighlights.returns(highlights)
emitGuestEvent('focusAnnotations', 'ctx', ['tag1'])
assert.called(highlights[1].setFocused)
assert.calledWith(highlights[1].setFocused, false)
assert.called(highlight1.setFocused)
assert.calledWith(highlight1.setFocused, false)
describe 'on "scrollToAnnotation" event', ->
it 'scrolls to the anchor with the matching tag', ->
highlight = {scrollToView: sandbox.stub()}
guest = createGuest()
anchors = [
{annotation: {$$tag: 'tag1'}, scrollToView: sandbox.stub()}
guest.anchored = [
{annotation: {$$tag: 'tag1'}, highlight: highlight}
]
guest.anchoring.getAnchors.returns(anchors)
emitGuestEvent('scrollToAnnotation', 'ctx', 'tag1')
assert.called(anchors[0].scrollToView)
assert.called(highlight.scrollToView)
describe 'on "getDocumentInfo" event', ->
guest = null
......@@ -294,28 +278,36 @@ describe 'Guest', ->
guest.createAnnotation({})
assert.called(fakeCrossFrame.sync)
it 'calls sync for setupAnnotation', ->
it 'calls sync for setupAnnotation', (done) ->
guest = createGuest()
guest.setupAnnotation({ranges: []})
guest.plugins.Document = {uri: -> 'http://example.com'}
guest.setupAnnotation({})
setTimeout ->
assert.called(fakeCrossFrame.sync)
done()
describe 'Annotator monkey patch', ->
describe 'setupAnnotation()', ->
it "doesn't declare annotation without targets as orphans", ->
it "doesn't declare annotation without targets as orphans", (done) ->
guest = createGuest()
annotation = target: []
guest.setupAnnotation(annotation)
setTimeout ->
assert.isFalse !!annotation.$orphan
done()
it "doesn't declare annotations with a working target as orphans", ->
it "doesn't declare annotations with a working target as orphans", (done) ->
guest = createGuest()
annotation = target: ["test target"]
guest.setupAnnotation(annotation)
setTimeout ->
assert.isFalse !!annotation.$orphan
done()
it "declares annotations with broken targets as orphans", ->
it "declares annotations with broken targets as orphans", (done) ->
guest = createGuest()
guest.anchoring.createAnchor = -> result: null
annotation = target: ["broken target"]
sandbox.stub(guest, 'anchorTarget').returns(Promise.reject())
annotation = target: [{selector: 'broken selector'}]
guest.setupAnnotation(annotation)
setTimeout ->
assert !!annotation.$orphan
done()
// Generated by CoffeeScript 1.7.1
(function() {
var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
__slice = [].slice,
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
window.DomTextMapper = (function() {
var CONTEXT_LEN, SELECT_CHILDREN_INSTEAD, USE_EMPTY_TEXT_WORKAROUND, USE_TABLE_TEXT_WORKAROUND, WHITESPACE, WHITESPACE_REGEX;
DomTextMapper.applicable = function() {
return true;
};
USE_TABLE_TEXT_WORKAROUND = true;
USE_EMPTY_TEXT_WORKAROUND = true;
SELECT_CHILDREN_INSTEAD = ["thead", "tbody", "tfoot", "ol", "a", "caption", "p", "span", "div", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "li", "form"];
CONTEXT_LEN = 32;
WHITESPACE_REGEX = /\s+/g;
DomTextMapper.instances = 0;
function DomTextMapper(id) {
this.id = id;
this.destroy = __bind(this.destroy, this);
this._onChange = __bind(this._onChange, this);
this.setRealRoot();
DomTextMapper.instances += 1;
if (this.id == null) {
this.id = "d-t-m #" + DomTextMapper.instances;
}
}
DomTextMapper.prototype.log = function() {
var msg;
msg = 1 <= arguments.length ? __slice.call(arguments, 0) : [];
};
DomTextMapper.prototype._onChange = function(event) {
this.documentChanged();
this.performUpdateOnNode(event.target, false, event.data);
return this.lastScanned = this.timestamp();
};
DomTextMapper.prototype._changeRootNode = function(node) {
var _ref;
if ((_ref = this.rootNode) != null) {
_ref.removeEventListener("domChange", this._onChange);
}
this.rootNode = node;
this.rootNode.addEventListener("domChange", this._onChange);
return node;
};
DomTextMapper.prototype.setRootNode = function(rootNode) {
this.rootWin = window;
return this.pathStartNode = this._changeRootNode(rootNode);
};
DomTextMapper.prototype.setRootId = function(rootId) {
return this.setRootNode(document.getElementById(rootId));
};
DomTextMapper.prototype.setRootIframe = function(iframeId) {
var iframe;
iframe = window.document.getElementById(iframeId);
if (iframe == null) {
throw new Error("Can't find iframe with specified ID!");
}
this.rootWin = iframe.contentWindow;
if (this.rootWin == null) {
throw new Error("Can't access contents of the specified iframe!");
}
this._changeRootNode(this.rootWin.document);
return this.pathStartNode = this.getBody();
};
DomTextMapper.prototype.getDefaultPath = function() {
return this.getPathTo(this.pathStartNode);
};
DomTextMapper.prototype.setRealRoot = function() {
this.rootWin = window;
this._changeRootNode(document);
return this.pathStartNode = this.getBody();
};
DomTextMapper.prototype.documentChanged = function() {
return this.lastDOMChange = this.timestamp();
};
DomTextMapper.prototype.setExpectedContent = function(content) {
return this.expectedContent = content;
};
DomTextMapper.prototype.scan = function() {
var node, path, startTime, t1, t2;
if (this.domStableSince(this.lastScanned)) {
return;
} else {
}
if (!this.pathStartNode.ownerDocument.body.contains(this.pathStartNode)) {
return;
}
this.documentChanged();
startTime = this.timestamp();
this.saveSelection();
this.path = {};
this.traverseSubTree(this.pathStartNode, this.getDefaultPath());
t1 = this.timestamp();
path = this.getPathTo(this.pathStartNode);
node = this.path[path].node;
this.collectPositions(node, path, null, 0, 0);
this.restoreSelection();
this.lastScanned = this.timestamp();
this._corpus = this.path[path].content;
t2 = this.timestamp();
return null;
};
DomTextMapper.prototype.selectPath = function(path, scroll) {
var info, node;
if (scroll == null) {
scroll = false;
}
info = this.path[path];
if (info == null) {
throw new Error("I have no info about a node at " + path);
}
node = info != null ? info.node : void 0;
node || (node = this.lookUpNode(info.path));
return this.selectNode(node, scroll);
};
DomTextMapper.prototype.performUpdateOnNode = function(node, escalating) {
var data, oldIndex, p, parentNode, parentPath, parentPathInfo, path, pathInfo, pathsToDrop, prefix, startTime, _i, _len, _ref;
if (escalating == null) {
escalating = false;
}
if (node == null) {
throw new Error("Called performUpdate with a null node!");
}
if (this.path == null) {
return;
}
startTime = this.timestamp();
if (!escalating) {
this.saveSelection();
}
path = this.getPathTo(node);
pathInfo = this.path[path];
if (pathInfo == null) {
this.performUpdateOnNode(node.parentNode, true);
if (!escalating) {
this.restoreSelection();
}
return;
}
if (pathInfo.node === node && pathInfo.content === this.getNodeContent(node, false)) {
prefix = path + "/";
pathsToDrop = p;
pathsToDrop = [];
_ref = this.path;
for (p in _ref) {
data = _ref[p];
if (this.stringStartsWith(p, prefix)) {
pathsToDrop.push(p);
}
}
for (_i = 0, _len = pathsToDrop.length; _i < _len; _i++) {
p = pathsToDrop[_i];
delete this.path[p];
}
this.traverseSubTree(node, path);
if (pathInfo.node === this.pathStartNode) {
this.collectPositions(node, path, null, 0, 0);
} else {
parentPath = this.parentPath(path);
parentPathInfo = this.path[parentPath];
if (parentPathInfo == null) {
throw new Error("While performing update on node " + path + ", no path info found for parent path: " + parentPath);
}
oldIndex = node === node.parentNode.firstChild ? 0 : this.path[this.getPathTo(node.previousSibling)].end - parentPathInfo.start;
this.collectPositions(node, path, parentPathInfo.content, parentPathInfo.start, oldIndex);
}
} else {
if (pathInfo.node !== this.pathStartNode) {
parentNode = node.parentNode != null ? node.parentNode : (parentPath = this.parentPath(path), this.lookUpNode(parentPath));
this.performUpdateOnNode(parentNode, true);
} else {
throw new Error("Can not keep up with the changes, since even the node configured as path start node was replaced.");
}
}
if (!escalating) {
return this.restoreSelection();
}
};
DomTextMapper.prototype.getInfoForPath = function(path) {
var result;
if (this.path == null) {
throw new Error("Can't get info before running a scan() !");
}
result = this.path[path];
if (result == null) {
throw new Error("Found no info for path '" + path + "'!");
}
return result;
};
DomTextMapper.prototype.getStartPosForPath = function(path) {
var info, _ref;
info = this.getInfoForPath(path);
return (_ref = info.start) != null ? _ref : this.getFirstPosAfter(info.node);
};
DomTextMapper.prototype.getFirstPosAfter = function(node) {
var info, path, _ref;
if (node.nextSibling != null) {
node = node.nextSibling;
path = this.getPathTo(node);
info = this.path[path];
return (_ref = info.start) != null ? _ref : this.getFirstPosAfter(node);
} else {
return this.getFirstPosAfter(node.parentNode);
}
};
DomTextMapper.prototype.getEndPosForPath = function(path) {
var info, _ref;
info = this.getInfoForPath(path);
return (_ref = info.end) != null ? _ref : this.getFirstPosBefore(info.node);
};
DomTextMapper.prototype.getFirstPosBefore = function(node) {
var info, path, _ref;
if (node.previousSibling != null) {
node = node.previousSibling;
path = this.getPathTo(node);
info = this.path[path];
return (_ref = info.end) != null ? _ref : this.getFirstPosBefore(node);
} else {
return this.getFirstPosBefore(node.parentNode);
}
};
DomTextMapper.prototype.getInfoForNode = function(node) {
if (node == null) {
throw new Error("Called getInfoForNode(node) with null node!");
}
return this.getInfoForPath(this.getPathTo(node));
};
DomTextMapper.prototype.getStartPosForNode = function(node) {
if (node == null) {
throw new Error("Called getStartInfoForNode(node) with null node!");
}
return this.getStartPosForPath(this.getPathTo(node));
};
DomTextMapper.prototype.getEndPosForNode = function(node) {
if (node == null) {
throw new Error("Called getInfoForNode(node) with null node!");
}
return this.getEndPosForPath(this.getPathTo(node));
};
DomTextMapper.prototype.getMappingsForCharRanges = function(charRanges) {
var charRange, _i, _len, _results;
_results = [];
for (_i = 0, _len = charRanges.length; _i < _len; _i++) {
charRange = charRanges[_i];
_results.push(this.getMappingsForCharRange(charRange.start, charRange.end));
}
return _results;
};
DomTextMapper.prototype.getContentForPath = function(path) {
if (path == null) {
path = null;
}
if (path == null) {
path = this.getDefaultPath();
}
return this.path[path].content;
};
DomTextMapper.prototype.getLengthForPath = function(path) {
if (path == null) {
path = null;
}
if (path == null) {
path = this.getDefaultPath();
}
return this.path[path].length;
};
DomTextMapper.prototype.getDocLength = function() {
return this._corpus.length;
};
DomTextMapper.prototype.getCorpus = function() {
return this._corpus;
};
DomTextMapper.prototype.getContextForCharRange = function(start, end) {
var prefix, prefixStart, suffix;
if (start < 0) {
throw Error("Negative range start is invalid!");
}
if (end > this._corpus.length) {
throw Error("Range end is after the end of corpus!");
}
prefixStart = Math.max(0, start - CONTEXT_LEN);
prefix = this._corpus.slice(prefixStart, start);
suffix = this._corpus.slice(end, end + CONTEXT_LEN);
return [prefix.trim(), suffix.trim()];
};
DomTextMapper.prototype.getMappingsForCharRange = function(start, end) {
var endInfo, endMapping, endNode, endOffset, endPath, info, mappings, p, r, result, startInfo, startMapping, startNode, startOffset, startPath, _ref;
if (!((start != null) && (end != null))) {
throw new Error("start and end is required!");
}
this.scan();
mappings = [];
_ref = this.path;
for (p in _ref) {
info = _ref[p];
if (info.atomic && this.regions_overlap(info.start, info.end, start, end)) {
(function(_this) {
return (function(info) {
var full, mapping;
mapping = {
element: info
};
full = start <= info.start && info.end <= end;
if (full) {
mapping.full = true;
mapping.wanted = info.content;
mapping.yields = info.content;
mapping.startCorrected = 0;
mapping.endCorrected = 0;
} else {
if (info.node.nodeType === Node.TEXT_NODE) {
if (start <= info.start) {
mapping.end = end - info.start;
mapping.wanted = info.content.substr(0, mapping.end);
} else if (info.end <= end) {
mapping.start = start - info.start;
mapping.wanted = info.content.substr(mapping.start);
} else {
mapping.start = start - info.start;
mapping.end = end - info.start;
mapping.wanted = info.content.substr(mapping.start, mapping.end - mapping.start);
}
_this.computeSourcePositions(mapping);
mapping.yields = info.node.data.substr(mapping.startCorrected, mapping.endCorrected - mapping.startCorrected);
} else if ((info.node.nodeType === Node.ELEMENT_NODE) && (info.node.tagName.toLowerCase() === "img")) {
_this.log("Can not select a sub-string from the title of an image. Selecting all.");
mapping.full = true;
mapping.wanted = info.content;
} else {
_this.log("Warning: no idea how to handle partial mappings for node type " + info.node.nodeType);
if (info.node.tagName != null) {
_this.log("Tag: " + info.node.tagName);
}
_this.log("Selecting all.");
mapping.full = true;
mapping.wanted = info.content;
}
}
return mappings.push(mapping);
});
})(this)(info);
}
}
if (mappings.length === 0) {
this.log("Collecting nodes for [" + start + ":" + end + "]");
this.log("Should be: '" + this._corpus.slice(start, end) + "'.");
throw new Error("No mappings found for [" + start + ":" + end + "]!");
}
mappings = mappings.sort(function(a, b) {
return a.element.start - b.element.start;
});
r = this.rootWin.document.createRange();
startMapping = mappings[0];
startNode = startMapping.element.node;
startPath = startMapping.element.path;
startOffset = startMapping.startCorrected;
if (startMapping.full) {
r.setStartBefore(startNode);
startInfo = startPath;
} else {
r.setStart(startNode, startOffset);
startInfo = startPath + ":" + startOffset;
}
endMapping = mappings[mappings.length - 1];
endNode = endMapping.element.node;
endPath = endMapping.element.path;
endOffset = endMapping.endCorrected;
if (endMapping.full) {
r.setEndAfter(endNode);
endInfo = endPath;
} else {
r.setEnd(endNode, endOffset);
endInfo = endPath + ":" + endOffset;
}
result = {
mappings: mappings,
realRange: r,
rangeInfo: {
startPath: startPath,
startOffset: startOffset,
startInfo: startInfo,
endPath: endPath,
endOffset: endOffset,
endInfo: endInfo
},
safeParent: r.commonAncestorContainer
};
return {
sections: [result]
};
};
DomTextMapper.prototype.timestamp = function() {
return new Date().getTime();
};
DomTextMapper.prototype.stringStartsWith = function(string, prefix) {
if (!prefix) {
throw Error("Requires a non-empty prefix!");
}
return string.slice(0, prefix.length) === prefix;
};
DomTextMapper.prototype.stringEndsWith = function(string, suffix) {
if (!suffix) {
throw Error("Requires a non-empty suffix!");
}
return string.slice(string.length - suffix.length, string.length) === suffix;
};
DomTextMapper.prototype.parentPath = function(path) {
return path.substr(0, path.lastIndexOf("/"));
};
DomTextMapper.prototype.domChangedSince = function(timestamp) {
if ((this.lastDOMChange != null) && (timestamp != null)) {
return this.lastDOMChange > timestamp;
} else {
return true;
}
};
DomTextMapper.prototype.domStableSince = function(timestamp) {
return !this.domChangedSince(timestamp);
};
DomTextMapper.prototype.getProperNodeName = function(node) {
var nodeName;
nodeName = node.nodeName;
switch (nodeName) {
case "#text":
return "text()";
case "#comment":
return "comment()";
case "#cdata-section":
return "cdata-section()";
default:
return nodeName;
}
};
DomTextMapper.prototype.getNodePosition = function(node) {
var pos, tmp;
pos = 0;
tmp = node;
while (tmp) {
if (tmp.nodeName === node.nodeName) {
pos++;
}
tmp = tmp.previousSibling;
}
return pos;
};
DomTextMapper.prototype.getPathSegment = function(node) {
var name, pos;
name = this.getProperNodeName(node);
pos = this.getNodePosition(node);
return name + (pos > 1 ? "[" + pos + "]" : "");
};
DomTextMapper.prototype.getPathTo = function(node) {
var xpath;
xpath = '';
while (node !== this.rootNode) {
if (node == null) {
throw new Error("Called getPathTo on a node which was not a descendant of @rootNode. " + this.rootNode);
}
xpath = (this.getPathSegment(node)) + '/' + xpath;
node = node.parentNode;
}
xpath = (this.rootNode.ownerDocument != null ? './' : '/') + xpath;
xpath = xpath.replace(/\/$/, '');
return xpath;
};
DomTextMapper.prototype.traverseSubTree = function(node, path, invisible, verbose) {
var child, cont, subpath, _i, _len, _ref;
if (invisible == null) {
invisible = false;
}
if (verbose == null) {
verbose = false;
}
this.underTraverse = path;
cont = this.getNodeContent(node, false);
this.path[path] = {
path: path,
content: cont,
length: cont.length,
node: node
};
if (cont.length) {
if (verbose) {
this.log("Collected info about path " + path);
}
if (invisible) {
throw new Error("Failed to scan document: got inconsistent data from selection API.");
}
} else {
if (verbose) {
this.log("Found no content at path " + path);
}
invisible = true;
}
if (node.hasChildNodes()) {
_ref = node.childNodes;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
child = _ref[_i];
subpath = path + '/' + (this.getPathSegment(child));
this.traverseSubTree(child, subpath, invisible, verbose);
}
}
return null;
};
DomTextMapper.prototype.getBody = function() {
return (this.rootWin.document.getElementsByTagName("body"))[0];
};
DomTextMapper.prototype.regions_overlap = function(start1, end1, start2, end2) {
return start1 < end2 && start2 < end1;
};
DomTextMapper.prototype.lookUpNode = function(path) {
var doc, node, results, _ref;
doc = (_ref = this.rootNode.ownerDocument) != null ? _ref : this.rootNode;
results = doc.evaluate(path, this.rootNode, null, 0, null);
return node = results.iterateNext();
};
DomTextMapper.prototype.saveSelection = function() {
var i, sel;
if (this.savedSelection != null) {
this.log("Selection saved at:");
this.log(this.selectionSaved);
throw new Error("Selection already saved!");
}
sel = this.rootWin.getSelection();
this.savedSelection = (function() {
var _i, _ref, _results;
_results = [];
for (i = _i = 0, _ref = sel.rangeCount; 0 <= _ref ? _i < _ref : _i > _ref; i = 0 <= _ref ? ++_i : --_i) {
_results.push(sel.getRangeAt(i));
}
return _results;
})();
return this.selectionSaved = (new Error("selection was saved here")).stack;
};
DomTextMapper.prototype.restoreSelection = function() {
var range, sel, _i, _len, _ref;
if (this.savedSelection == null) {
throw new Error("No selection to restore.");
}
sel = this.rootWin.getSelection();
sel.removeAllRanges();
_ref = this.savedSelection;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
range = _ref[_i];
sel.addRange(range);
}
return delete this.savedSelection;
};
DomTextMapper.prototype.selectNode = function(node, scroll) {
var children, exception, realRange, sel, sn, _ref;
if (scroll == null) {
scroll = false;
}
if (node == null) {
throw new Error("Called selectNode with null node!");
}
sel = this.rootWin.getSelection();
sel.removeAllRanges();
realRange = this.rootWin.document.createRange();
if (node.nodeType === Node.ELEMENT_NODE && node.hasChildNodes() && (_ref = node.tagName.toLowerCase(), __indexOf.call(SELECT_CHILDREN_INSTEAD, _ref) >= 0)) {
children = node.childNodes;
realRange.setStartBefore(children[0]);
realRange.setEndAfter(children[children.length - 1]);
sel.addRange(realRange);
} else {
if (USE_TABLE_TEXT_WORKAROUND && node.nodeType === Node.TEXT_NODE && node.parentNode.tagName.toLowerCase() === "table") {
} else {
try {
realRange.setStartBefore(node);
realRange.setEndAfter(node);
sel.addRange(realRange);
} catch (_error) {
exception = _error;
if (!(USE_EMPTY_TEXT_WORKAROUND && this.isWhitespace(node))) {
this.log("Warning: failed to scan element @ " + this.underTraverse);
this.log("Content is: " + node.innerHTML);
this.log("We won't be able to properly anchor to any text inside this element.");
}
}
}
}
if (scroll) {
sn = node;
while ((sn != null) && (sn.scrollIntoViewIfNeeded == null)) {
sn = sn.parentNode;
}
if (sn != null) {
sn.scrollIntoViewIfNeeded();
} else {
this.log("Failed to scroll to element. (Browser does not support scrollIntoViewIfNeeded?)");
}
}
return sel;
};
DomTextMapper.prototype.readSelectionText = function(sel) {
sel || (sel = this.rootWin.getSelection());
return sel.toString().trim().replace(WHITESPACE_REGEX, ' ');
};
DomTextMapper.prototype.getNodeSelectionText = function(node, shouldRestoreSelection) {
var sel, text;
if (shouldRestoreSelection == null) {
shouldRestoreSelection = true;
}
if (shouldRestoreSelection) {
this.saveSelection();
}
sel = this.selectNode(node);
text = this.readSelectionText(sel);
if (shouldRestoreSelection) {
this.restoreSelection();
}
return text;
};
DomTextMapper.prototype.computeSourcePositions = function(match) {
var dc, displayEnd, displayIndex, displayStart, displayText, sc, sourceEnd, sourceIndex, sourceStart, sourceText;
sourceText = match.element.node.data.replace(WHITESPACE_REGEX, " ");
displayText = match.element.content.replace(WHITESPACE_REGEX, " ");
displayStart = match.start != null ? match.start : 0;
displayEnd = match.end != null ? match.end : displayText.length;
if (displayEnd === 0) {
match.startCorrected = 0;
match.endCorrected = 0;
return;
}
sourceIndex = 0;
displayIndex = 0;
while (!((sourceStart != null) && (sourceEnd != null))) {
sc = sourceText[sourceIndex];
dc = displayText[displayIndex];
if (sc === dc) {
if (displayIndex === displayStart) {
sourceStart = sourceIndex;
}
displayIndex++;
if (displayIndex === displayEnd) {
sourceEnd = sourceIndex + 1;
}
} else if ((sc == null) || (dc == null)) {
throw new Error("display and source text mismatch: '" + sourceText + "' vs. '" + displayText + "'");
}
sourceIndex++;
}
match.startCorrected = sourceStart;
match.endCorrected = sourceEnd;
return null;
};
DomTextMapper.prototype.getNodeContent = function(node, shouldRestoreSelection) {
if (shouldRestoreSelection == null) {
shouldRestoreSelection = true;
}
if (node === this.pathStartNode && (this.expectedContent != null)) {
return this.expectedContent;
} else {
return this.getNodeSelectionText(node, shouldRestoreSelection);
}
};
DomTextMapper.prototype.collectPositions = function(node, path, parentContent, parentIndex, index) {
var atomic, child, childPath, children, content, endIndex, i, newCount, nodeName, oldCount, pathInfo, pos, startIndex, typeCount;
if (parentContent == null) {
parentContent = null;
}
if (parentIndex == null) {
parentIndex = 0;
}
if (index == null) {
index = 0;
}
pathInfo = this.path[path];
content = pathInfo != null ? pathInfo.content : void 0;
if (!content) {
pathInfo.start = parentIndex + index;
pathInfo.end = parentIndex + index;
pathInfo.atomic = false;
return index;
}
startIndex = parentContent != null ? parentContent.indexOf(content, index) : index;
if (startIndex === -1) {
this.log("Content of this not is not present in content of parent, at path " + path);
this.log("(Content: '" + content + "'.)");
return index;
}
endIndex = startIndex + content.length;
atomic = !node.hasChildNodes();
pathInfo.start = parentIndex + startIndex;
pathInfo.end = parentIndex + endIndex;
pathInfo.atomic = atomic;
if (!atomic) {
children = node.childNodes;
i = 0;
pos = 0;
typeCount = Object();
while (i < children.length) {
child = children[i];
nodeName = this.getProperNodeName(child);
oldCount = typeCount[nodeName];
newCount = oldCount != null ? oldCount + 1 : 1;
typeCount[nodeName] = newCount;
childPath = path + "/" + nodeName + (newCount > 1 ? "[" + newCount + "]" : "");
pos = this.collectPositions(child, childPath, content, parentIndex + startIndex, pos);
i++;
}
}
return endIndex;
};
WHITESPACE = /^\s*$/;
DomTextMapper.prototype.isWhitespace = function(node) {
var child, mightBeEmpty, result;
result = (function() {
var _i, _len, _ref;
switch (node.nodeType) {
case Node.TEXT_NODE:
return WHITESPACE.test(node.data);
case Node.ELEMENT_NODE:
mightBeEmpty = true;
_ref = node.childNodes;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
child = _ref[_i];
mightBeEmpty = mightBeEmpty && this.isWhitespace(child);
}
return mightBeEmpty;
default:
return false;
}
}).call(this);
return result;
};
DomTextMapper.prototype._testMap = function() {
var expected, i, ok, p, _ref, _ref1;
this.log("Verifying map info: was it all properly traversed?");
_ref = this.path;
for (i in _ref) {
p = _ref[i];
if (p.atomic == null) {
this.log(i + " is missing data.");
}
}
this.log("Verifying map info: do atomic elements match?");
_ref1 = this.path;
for (i in _ref1) {
p = _ref1[i];
if (!p.atomic) {
continue;
}
expected = this._corpus.slice(p.start, p.end);
ok = p.content === expected;
if (!ok) {
this.log("Mismatch on " + i + ": content is '" + p.content + "', range in corpus is '" + expected + "'.");
}
ok;
}
return null;
};
DomTextMapper.prototype.destroy = function() {
var sel;
if (this.savedSelection) {
this.restoreSelection();
} else {
sel = this.rootWin.getSelection();
sel.removeAllRanges();
}
return delete this.path;
};
DomTextMapper.prototype.getPageIndex = function() {
return 0;
};
DomTextMapper.prototype.getPageCount = function() {
return 1;
};
DomTextMapper.prototype.getPageIndexForPos = function() {
return 0;
};
DomTextMapper.prototype.isPageMapped = function() {
return true;
};
return DomTextMapper;
})();
}).call(this);
//# sourceMappingURL=dom_text_mapper.map
// Generated by CoffeeScript 1.7.1
(function() {
var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
window.PageTextMapperCore = (function() {
function PageTextMapperCore() {
this._onPageRendered = __bind(this._onPageRendered, this);
}
PageTextMapperCore.prototype.CONTEXT_LEN = 32;
PageTextMapperCore.prototype.getPageIndexForPos = function(pos) {
var info, _i, _len, _ref;
_ref = this.pageInfo;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
info = _ref[_i];
if ((info.start <= pos && pos < info.end)) {
return info.index;
console.log("Not on page " + info.index);
}
}
return -1;
};
PageTextMapperCore.prototype._onPageRendered = function(index) {
if (!(this._isPageRendered(index) && this.pageInfo[index])) {
setTimeout(((function(_this) {
return function() {
return _this._onPageRendered(index);
};
})(this)), 1000);
return;
}
return this._mapPage(this.pageInfo[index]);
};
PageTextMapperCore.prototype.isPageMapped = function(index) {
var _ref;
return ((_ref = this.pageInfo[index]) != null ? _ref.domMapper : void 0) != null;
};
PageTextMapperCore.prototype._mapPage = function(info) {
var renderedContent;
info.node = this.getRootNodeForPage(info.index);
info.domMapper = new DomTextMapper("d-t-m for page #" + info.index);
info.domMapper.setRootNode(info.node);
info.domMapper.documentChanged();
if (this.requiresSmartStringPadding) {
info.domMapper.setExpectedContent(info.content);
}
info.domMapper.scan();
renderedContent = info.domMapper.getCorpus();
if (renderedContent !== info.content) {
console.log("Oops. Mismatch between rendered and extracted text, while mapping page #" + info.index + "!");
console.trace();
console.log("Rendered: " + renderedContent);
console.log("Extracted: " + info.content);
}
return setTimeout(function() {
var event;
event = document.createEvent("UIEvents");
event.initUIEvent("docPageMapped", false, false, window, 0);
event.pageIndex = info.index;
return window.dispatchEvent(event);
});
};
PageTextMapperCore.prototype._updateMap = function(info) {
info.domMapper.documentChanged();
return info.domMapper.scan();
};
PageTextMapperCore.prototype._unmapPage = function(info) {
var event;
delete info.domMapper;
event = document.createEvent("UIEvents");
event.initUIEvent("docPageUnmapped", false, false, window, 0);
event.pageIndex = info.index;
return window.dispatchEvent(event);
};
PageTextMapperCore.prototype._onScroll = function() {
var event;
event = document.createEvent("UIEvents");
event.initUIEvent("docPageScrolling", false, false, window, 0);
return window.dispatchEvent(event);
};
PageTextMapperCore.prototype.getInfoForNode = function(node) {
var info, k, nodeData, pageData, v;
pageData = this.getPageForNode(node);
if (!pageData.domMapper) {
return null;
}
nodeData = pageData.domMapper.getInfoForNode(node);
info = {};
for (k in nodeData) {
v = nodeData[k];
info[k] = v;
}
info.start += pageData.start;
info.end += pageData.start;
info.pageIndex = pageData.index;
return info;
};
PageTextMapperCore.prototype.getStartPosForNode = function(node) {
var nodeStart, pageData;
pageData = this.getPageForNode(node);
nodeStart = pageData.domMapper.getStartPosForNode(node);
return pageData.start + nodeStart;
};
PageTextMapperCore.prototype.getEndPosForNode = function(node) {
var nodeEnd, pageData;
pageData = this.getPageForNode(node);
nodeEnd = pageData.domMapper.getEndPosForNode(node);
return pageData.start + nodeEnd;
};
PageTextMapperCore.prototype.getMappingsForCharRange = function(start, end, pages) {
var endIndex, getSection, index, sections, startIndex, _i, _j, _len, _ref, _results;
startIndex = this.getPageIndexForPos(start);
endIndex = this.getPageIndexForPos(end);
getSection = (function(_this) {
return function(index) {
var info, mappings, realEnd, realStart;
info = _this.pageInfo[index];
realStart = (Math.max(info.start, start)) - info.start;
realEnd = (Math.min(info.end, end)) - info.start;
mappings = info.domMapper.getMappingsForCharRange(realStart, realEnd);
return mappings.sections[0];
};
})(this);
sections = {};
_ref = pages != null ? pages : (function() {
_results = [];
for (var _j = startIndex; startIndex <= endIndex ? _j <= endIndex : _j >= endIndex; startIndex <= endIndex ? _j++ : _j--){ _results.push(_j); }
return _results;
}).apply(this);
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
index = _ref[_i];
sections[index] = getSection(index);
}
return {
sections: sections
};
};
PageTextMapperCore.prototype.getCorpus = function() {
if (!this._corpus) {
throw new Error("Hey! Called getCorpus() before corpus defined!");
}
return this._corpus;
};
PageTextMapperCore.prototype.getContextForCharRange = function(start, end) {
var prefix, prefixLen, prefixStart, suffix;
prefixStart = Math.max(0, start - this.CONTEXT_LEN);
prefixLen = start - prefixStart;
prefix = this._corpus.substr(prefixStart, prefixLen);
suffix = this._corpus.substr(end, this.CONTEXT_LEN);
return [prefix.trim(), suffix.trim()];
};
PageTextMapperCore.prototype._onHavePageContents = function() {
var info, pos;
this._corpus = ((function() {
var _i, _len, _ref, _results;
_ref = this.pageInfo;
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
info = _ref[_i];
_results.push(info.content);
}
return _results;
}).call(this)).join(" ");
pos = 0;
return this.pageInfo.forEach((function(_this) {
return function(info, i) {
info.len = info.content.length;
info.start = pos;
return info.end = (pos += info.len + 1);
};
})(this));
};
PageTextMapperCore.prototype._onAfterScan = function() {
return this.pageInfo.forEach((function(_this) {
return function(info, i) {
if (_this._isPageRendered(i)) {
return _this._mapPage(info);
}
};
})(this));
};
return PageTextMapperCore;
})();
}).call(this);
......@@ -55,14 +55,12 @@
"angular": "./h/static/scripts/vendor/angular.js",
"angular-mock": "./h/static/scripts/vendor/angular-mocks.js",
"diff-match-patch": "./h/static/scripts/vendor/diff_match_patch_uncompressed.js",
"dom-text-mapper": "./h/static/scripts/vendor/dom_text_mapper.js",
"dom-text-matcher": "./h/static/scripts/vendor/dom_text_matcher.js",
"es6-promise": "./node_modules/es6-promise/dist/es6-promise.js",
"hammerjs": "./node_modules/hammerjs/hammer.js",
"jquery": "./h/static/scripts/vendor/jquery.js",
"jquery-scrollintoview": "./h/static/scripts/vendor/jquery.scrollintoview.js",
"jschannel": "./h/static/scripts/vendor/jschannel.js",
"page-text-mapper-core": "./h/static/scripts/vendor/page_text_mapper_core.js",
"text-match-engines": "./h/static/scripts/vendor/text_match_engines.js"
},
"browserify-shim": {
......@@ -86,7 +84,6 @@
},
"angular-mock": "global:angular.mock",
"diff-match-patch": "diff_match_patch",
"dom-text-mapper": "DomTextMapper",
"dom-text-matcher": "DomTextMatcher",
"es6-promise": "ES6Promise",
"hammerjs": "Hammer",
......@@ -97,7 +94,6 @@
]
},
"jschannel": "Channel",
"page-text-mapper-core": "PageTextMapperCore",
"text-match-engines": "TextMatchEngines"
}
}
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