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
This diff is collapsed.
......@@ -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)
# 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
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: []})
assert.called(fakeCrossFrame.sync)
describe 'Annotator monkey patch', ->
describe 'setupAnnotation()', ->
it "doesn't declare annotation without targets as orphans", ->
guest = createGuest()
annotation = target: []
guest.setupAnnotation(annotation)
guest.plugins.Document = {uri: -> 'http://example.com'}
guest.setupAnnotation({})
setTimeout ->
assert.called(fakeCrossFrame.sync)
done()
describe 'setupAnnotation()', ->
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", ->
guest = createGuest()
annotation = target: ["test target"]
guest.setupAnnotation(annotation)
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", ->
guest = createGuest()
guest.anchoring.createAnchor = -> result: null
annotation = target: ["broken target"]
guest.setupAnnotation(annotation)
it "declares annotations with broken targets as orphans", (done) ->
guest = createGuest()
sandbox.stub(guest, 'anchorTarget').returns(Promise.reject())
annotation = target: [{selector: 'broken selector'}]
guest.setupAnnotation(annotation)
setTimeout ->
assert !!annotation.$orphan
done()
This diff is collapsed.
// 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