diff --git a/src/annotator.coffee b/src/annotator.coffee
index 0fe83d2dc..2f0cee766 100644
--- a/src/annotator.coffee
+++ b/src/annotator.coffee
@@ -57,7 +57,7 @@ class Annotator extends Delegator
viewer: null
- selectedRanges: null
+ selectedTargets: null
mouseIsDown: false
@@ -101,6 +101,12 @@ class Annotator extends Delegator
# Create adder
this.adder = $(this.html.adder).appendTo(@wrapper).hide()
+ _setupMatching: ->
+ this.domMapper = new DomTextMapper()
+ this.domMatcher = new DomTextMatcher @domMapper
+
+ this
+
# Wraps the children of @element in a @wrapper div. NOTE: This method will also
# remove any script elements inside @element to prevent them re-executing.
#
@@ -116,6 +122,9 @@ class Annotator extends Delegator
@element.wrapInner(@wrapper)
@wrapper = @element.find('.annotator-wrapper')
+ # TODO: do somthing like this:
+ # this.domMapper.setRootNode @wrapper[0].get()
+
this
# Creates an instance of Annotator.Viewer and assigns it to the @viewer
@@ -202,6 +211,50 @@ class Annotator extends Delegator
this
+ getHref: =>
+ uri = decodeURIComponent document.location.href
+ if document.location.hash then uri = uri.slice 0, (-1 * location.hash.length)
+ $('meta[property^="og:url"]').each -> uri = decodeURIComponent this.content
+ $('link[rel^="canonical"]').each -> uri = decodeURIComponent this.href
+ return uri
+
+ getRangeSelector: (range) ->
+ sr = range.serialize @wrapper[0]
+ selector =
+ type: "RangeSelector"
+ startContainer: sr.startContainer
+ startOffset: sr.startOffset
+ endContainer: sr.endContainer
+ endOffset: sr.endOffset
+
+ getTextQuoteSelector: (range) ->
+ startOffset = (@domMapper.getInfoForNode range.start).start
+ endOffset = (@domMapper.getInfoForNode range.end).end
+
+ quote = @domMapper.getContentForCharRange startOffset, endOffset
+ [prefix, suffix] = @domMapper.getContextForCharRange startOffset, endOffset
+ selector =
+ type: "TextQuoteSelector"
+ exact: quote
+ prefix: prefix
+ suffix: suffix
+
+ getTextPositionSelector: (range) ->
+ startOffset = (@domMapper.getInfoForNode range.start).start
+ endOffset = (@domMapper.getInfoForNode range.end).end
+
+ selector =
+ type: "TextPositionSelector"
+ start: startOffset
+ end: endOffset
+
+ getQuoteForTarget: (target) ->
+ selector = this.findSelector target.selector, "TextQuoteSelector"
+ if selector?
+ this.normalizeString selector.exact
+ else
+ null
+
# Public: Gets the current selection excluding any nodes that fall outside of
# the @wrapper. Then returns and Array of NormalizedRange instances.
#
@@ -216,23 +269,29 @@ class Annotator extends Delegator
# # => Returns []
#
# Returns Array of NormalizedRange instances.
- getSelectedRanges: ->
+ getSelectedTargets: ->
selection = util.getGlobal().getSelection()
+ source = this.getHref()
- ranges = []
+ targets = []
rangesToIgnore = []
unless selection.isCollapsed
- ranges = for i in [0...selection.rangeCount]
- r = selection.getRangeAt(i)
- browserRange = new Range.BrowserRange(r)
- normedRange = browserRange.normalize().limit(@wrapper[0])
+ targets = for i in [0...selection.rangeCount]
+ realRange = selection.getRangeAt i
+ browserRange = new Range.BrowserRange realRange
+ normedRange = browserRange.normalize().limit @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
+ selector: [
+ this.getRangeSelector normedRange
+ this.getTextQuoteSelector normedRange
+ this.getTextPositionSelector normedRange
+ ]
+ source: source
# BrowserRange#normalize() modifies the DOM structure and deselects the
# underlying text as a result. So here we remove the selected ranges and
@@ -242,11 +301,15 @@ class Annotator extends Delegator
for r in rangesToIgnore
selection.addRange(r)
- # Remove any ranges that fell outside of @wrapper.
- $.grep ranges, (range) ->
+ # Remove any targets that's range fell outside of @wrapper.
+ $.grep targets, (target) =>
# Add the normed range back to the selection if it exists.
- selection.addRange(range.toRange()) if range
- range
+ selector = this.findSelector target.selector, "RangeSelector"
+ if selector?
+ range = (Range.sniff selector).normalize @wrapper[0]
+ if range?
+ selection.addRange range.toRange()
+ true
# Public: Creates and returns a new annotation object. Publishes the
# 'beforeAnnotationCreated' event to allow the new annotation to be modified.
@@ -265,9 +328,204 @@ class Annotator extends Delegator
this.publish('beforeAnnotationCreated', [annotation])
annotation
+ # 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
+
+ # Try to determine the anchor position for a target
+ # using the saved Range selector. The quote is verified.
+ findAnchorFromRangeSelector: (target) ->
+ selector = this.findSelector target.selector, "RangeSelector"
+ unless selector? then return null
+ try
+ # Try to apply the saved XPath
+ normalizedRange = Range.sniff(selector).normalize @wrapper[0]
+ # Look up the saved quote
+ savedQuote = this.getQuoteForTarget target
+ if savedQuote?
+ # We have a saved quote, let's compare it to current content
+ startInfo = @domMapper.getInfoForNode normalizedRange.start
+ startOffset = startInfo.start
+ endInfo = @domMapper.getInfoForNode normalizedRange.end
+ endOffset = endInfo.end
+ content = @domMapper.getContentForCharRange startOffset, endOffset
+ currentQuote = this.normalizeString content
+ if 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
+ else
+ console.log "Saved quote matches."
+ else
+ console.log "No saved quote, nothing to compare. Assume that it's OK."
+ range: normalizedRange
+ quote: savedQuote
+ catch exception
+ if exception instanceof Range.RangeError
+ console.log "Could not apply XPath selector to current document. \
+ The document structure may have changed."
+ null
+ else
+ throw exception
+
+
+ # Try to determine the anchor position for a target
+ # using the saved position selector. The quote is verified.
+ findAnchorFromPositionSelector: (target) ->
+ selector = this.findSelector target.selector, "TextPositionSelector"
+ unless selector? then return null
+ savedQuote = this.getQuoteForTarget target
+ if savedQuote?
+ # We have a saved quote, let's compare it to current content
+ content = @domMapper.getContentForCharRange selector.start, selector.end
+ currentQuote = this.normalizeString content
+ if currentQuote isnt savedQuote
+ console.log "Could not apply position selector to current document \
+ because the quote has changed. (Saved quote is '#{savedQuote}'. \
+ Current quote is '#{currentQuote}'.)"
+ return null
+ else
+ console.log "Saved quote matches."
+ else
+ console.log "No saved quote, nothing to compare. Assume that it's okay."
+
+ # OK, we have everything. Create a range from this.
+ mappings = this.domMapper.getMappingsForCharRange selector.start,
+ selector.end
+ browserRange = new Range.BrowserRange mappings.realRange
+ normalizedRange = browserRange.normalize @wrapper[0]
+ range: normalizedRange
+ quote: savedQuote
+
+ findAnchorWithTwoPhaseFuzzyMatching: (target) ->
+ # Fetch the quote and the context
+ quoteSelector = this.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 = this.findSelector target.selector, "TextPositionSelector"
+ expectedStart = posSelector?.start
+ expectedEnd = posSelector?.end
+
+ options =
+ contextMatchDistance: @domMapper.getDocLength() * 2
+ contextMatchThreshold: 0.5
+ patternMatchThreshold: 0.5
+ result = @domMatcher.searchFuzzyWithContext prefix, suffix, quote,
+ expectedStart, expectedEnd, false, null, 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 "Fuzzy found match:"
+ console.log match
+
+ # convert it to a Range
+ browserRange = new Range.BrowserRange match.realRange
+ normalizedRange = browserRange.normalize @wrapper[0]
+
+ # return the anchor
+ anchor =
+ range: normalizedRange
+ quote: unless match.exact then match.found
+ diffHTML: unless match.exact then match.comparison.diffHTML
+
+ anchor
+
+ findAnchorWithFuzzyMatching: (target) ->
+ # Fetch the quote
+ quoteSelector = this.findSelector target.selector, "TextQuoteSelector"
+ quote = quoteSelector?.exact
+
+ # No quote, no joy
+ unless quote? then return null
+
+ # Get a starting position for the search
+ posSelector = this.findSelector target.selector, "TextPositionSelector"
+ expectedStart = posSelector?.start
+
+ # Get full document length
+ len = this.domMapper.getDocLength()
+
+ # If we don't have the position saved, start at the middle of the doc
+ expectedStart ?= len / 2
+
+ # Do the fuzzy search
+ options =
+ matchDistance: len * 2
+ withFuzzyComparison: true
+ result = @domMatcher.searchFuzzy quote, expectedStart, false, null, 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 "Fuzzy found match:"
+ console.log match
+
+ # convert it to a Range
+ browserRange = new Range.BrowserRange match.realRange
+ normalizedRange = browserRange.normalize @wrapper[0]
+
+ # return the anchor
+ anchor =
+ range: normalizedRange
+ quote: unless match.exact then match.found
+ diffHTML: unless match.exact then match.comparison.diffHTML
+
+ anchor
+
+ # Try to find the right anchoring point for a given target
+ #
+ # Returns a normalized range if succeeded, null otherwise
+ findAnchor: (target) ->
+ console.log "Trying to find anchor for target: "
+ console.log target
+
+ # Simple strategy based on DOM Range
+ anchor = this.findAnchorFromRangeSelector target
+
+ # Position-based strategy. (The quote is verified.)
+ # This can handle document structure changes,
+ # but not the content changes.
+ anchor ?= this.findAnchorFromPositionSelector target
+
+ # Two-phased fuzzy text matching strategy. (Using context and quote.)
+ # This can handle document structure changes,
+ # and also content changes.
+ anchor ?= this.findAnchorWithTwoPhaseFuzzyMatching target
+
+ # Naive fuzzy text matching strategy. (Using only the quote.)
+ # This can handle document structure changes,
+ # and also content changes.
+ anchor ?= this.findAnchorWithFuzzyMatching target
+
+ anchor
+
# Public: Initialises an annotation either from an object representation or
# an annotation created with Annotator#createAnnotation(). It finds the
- # selected range and higlights the selection in the DOM.
+ # selected range and higlights the selection in the DOM, extracts the
+ # quoted text and serializes the range.
#
# annotation - An annotation Object to initialise.
#
@@ -286,30 +544,44 @@ class Annotator extends Delegator
# Returns the initialised annotation.
setupAnnotation: (annotation) ->
root = @wrapper[0]
- annotation.ranges or= @selectedRanges
+ annotation.target or= @selectedTargets
+
+ unless annotation.target instanceof Array
+ annotation.target = [annotation.target]
normedRanges = []
- for r in annotation.ranges
+ for t in annotation.target
try
- normedRanges.push(Range.sniff(r).normalize(root))
- catch e
- if e instanceof Range.RangeError
- this.publish('rangeNormalizeFail', [annotation, r, e])
+ anchor = this.findAnchor t
+ if anchor?.quote?
+ # We have found a changed quote.
+ # Save it for this target (currently not used)
+ t.quote = anchor.quote
+ t.diffHTML = anchor.diffHTML
+ if anchor?.range?
+ normedRanges.push anchor.range
else
- # Oh Javascript, why you so crap? This will lose the traceback.
- throw e
-
- annotation.quote = []
- annotation.ranges = []
+ console.log "Could not find anchor target for annotation '" +
+ annotation.id + "'."
+ catch exception
+ if exception.stack? then console.log exception.stack
+ console.log exception.message
+ console.log exception
+
+# TODO for resurrecting Annotator
+# annotation.currentQuote = []
+# annotation.currentRanges = []
annotation.highlights = []
for normed in normedRanges
- annotation.quote.push $.trim(normed.text())
- annotation.ranges.push normed.serialize(@wrapper[0], '.annotator-hl')
+# TODO for resurrecting Annotator
+# annotation.currentQuote.push $.trim(normed.text())
+# annotation.currentRanges.push normed.serialize(@wrapper[0], '.annotator-hl')
$.merge annotation.highlights, this.highlightRange(normed)
# Join all the quotes into one string.
- annotation.quote = annotation.quote.join(' / ')
+# TODO for resurrecting Annotator#
+# annotation.currentQuote = annotation.currentQuote.join(' / ')
# Save the annotation data on each highlighter element.
$(annotation.highlights).data('annotation', annotation)
@@ -349,6 +621,8 @@ class Annotator extends Delegator
for h in annotation.highlights when h.parentNode?
child = h.childNodes[0]
$(h).replaceWith(h.childNodes)
+ window.DomTextMapper.changed child.parentNode,
+ "removed hilite (annotation deleted)"
this.publish('annotationDeleted', [annotation])
annotation
@@ -408,8 +682,11 @@ class Annotator extends Delegator
# 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.
- for node in normedRange.textNodes() when not white.test(node.nodeValue)
- $(node).wrapAll(hl).parent().show()[0]
+
+ for node in normedRange.textNodes() when not white.test node.nodeValue
+ r = $(node).wrapAll(hl).parent().show()[0]
+ window.DomTextMapper.changed node, "created hilite"
+ r
# Public: highlight a list of ranges
#
@@ -559,15 +836,24 @@ class Annotator extends Delegator
return
# Get the currently selected ranges.
- @selectedRanges = this.getSelectedRanges()
+ try
+ @selectedTargets = this.getSelectedTargets()
+ catch exception
+ console.log "Error while checking selection:"
+ console.log exception
+ console.log exception.stack
+ alert "There is something very strange about the current selection. Sorry, but I can not annotate this."
+ return
- for range in @selectedRanges
+ for target in @selectedTargets
+ selector = this.findSelector target.selector, "RangeSelector"
+ range = (Range.sniff selector).normalize @wrapper[0]
container = range.commonAncestor
if $(container).hasClass('annotator-hl')
container = $(container).parents('[class^=annotator-hl]')[0]
return if this.isAnnotator(container)
- if event and @selectedRanges.length
+ if event and @selectedTargets.length
@adder
.css(util.mousePosition(event, @wrapper[0]))
.show()
@@ -636,13 +922,15 @@ class Annotator extends Delegator
position = @adder.position()
@adder.hide()
+ # Create a new annotation.
+ annotation = this.createAnnotation()
+
+ # Extract the quotation and serialize the ranges
+ annotation = this.setupAnnotation(annotation)
+
# Show a temporary highlight so the user can see what they selected
- # Also extract the quotation and serialize the ranges
- annotation = this.setupAnnotation(this.createAnnotation())
$(annotation.highlights).addClass('annotator-hl-temporary')
- # Subscribe to the editor events
-
# Make the highlights permanent if the annotation is saved
save = =>
do cleanup
@@ -660,6 +948,7 @@ class Annotator extends Delegator
this.unsubscribe('annotationEditorHidden', cancel)
this.unsubscribe('annotationEditorSubmit', save)
+ # Subscribe to the editor events
this.subscribe('annotationEditorHidden', cancel)
this.subscribe('annotationEditorSubmit', save)
@@ -676,18 +965,17 @@ class Annotator extends Delegator
onEditAnnotation: (annotation) =>
offset = @viewer.element.position()
- # Subscribe once to editor events
-
# Update the annotation when the editor is saved
update = =>
do cleanup
this.updateAnnotation(annotation)
- # Remove handlers when the editor is hidden
+ # Remove handlers when finished
cleanup = =>
this.unsubscribe('annotationEditorHidden', cleanup)
this.unsubscribe('annotationEditorSubmit', update)
+ # Subscribe to the editor events
this.subscribe('annotationEditorHidden', cleanup)
this.subscribe('annotationEditorSubmit', update)
diff --git a/src/extensions.coffee b/src/extensions.coffee
index ffef384c7..a6fc5afdb 100644
--- a/src/extensions.coffee
+++ b/src/extensions.coffee
@@ -71,15 +71,15 @@ $.fn.textNodes = ->
this.map -> $.flatten(getTextNodes(this))
-$.fn.xpath = (relativeRoot) ->
+$.fn.xpath1 = (relativeRoot) ->
jq = this.map ->
path = ''
elem = this
# elementNode nodeType == 1
while elem and elem.nodeType == 1 and elem isnt relativeRoot
- idx = $(elem.parentNode).children(elem.tagName).index(elem) + 1
-
+ tagName = elem.tagName.replace(":", "\\:")
+ idx = $(elem.parentNode).children(tagName).index(elem) + 1
idx = "[#{idx}]"
path = "/" + elem.tagName.toLowerCase() + idx + path
elem = elem.parentNode
@@ -88,6 +88,82 @@ $.fn.xpath = (relativeRoot) ->
jq.get()
+$.getProperNodeName = (node) ->
+ nodeName = node.nodeName.toLowerCase()
+ switch nodeName
+ when "#text" then return "text()"
+ when "#comment" then return "comment()"
+ when "#cdata-section" then return "cdata-section()"
+ else return nodeName
+
+$.fn.xpath2 = (relativeRoot) ->
+
+ getNodePosition = (node) ->
+ pos = 0
+ tmp = node
+ while tmp
+ if tmp.nodeName is node.nodeName
+ pos++
+ tmp = tmp.previousSibling
+ pos
+
+ getPathSegment = (node) ->
+ name = $.getProperNodeName node
+ pos = getNodePosition node
+ name + (if pos > 1 then "[#{pos}]" else "")
+
+ rootNode = relativeRoot
+
+ getPathTo = (node) ->
+ xpath = '';
+ while node != rootNode
+ unless node?
+ throw new Error "Called getPathTo on a node which was not a descendant of @rootNode. " + rootNode
+ xpath = (getPathSegment node) + '/' + xpath
+ node = node.parentNode
+ xpath = '/' + xpath
+ xpath = xpath.replace /\/$/, ''
+ xpath
+
+ jq = this.map ->
+ path = getPathTo this
+
+ path
+
+ jq.get()
+
+$.fn.xpath = (relativeRoot) ->
+ try
+ result = this.xpath1 relativeRoot
+ catch exception
+ console.log "jQuery-based XPath construction failed! Falling back to manual."
+ result = this.xpath2 relativeRoot
+ result
+
+$.findChild = (node, type, index) ->
+ unless node.hasChildNodes()
+ throw new Error "XPath error: node has no children!"
+ children = node.childNodes
+ found = 0
+ for child in children
+ name = $.getProperNodeName child
+ if name is type
+ found += 1
+ if found is index
+ return child
+ throw new Error "XPath error: wanted child not found."
+
+
+$.dummyXPathEvaluate = (xp, root) ->
+ steps = xp.substring(1).split("/")
+ node = root
+ for step in steps
+ [name, idx] = step.split "["
+ idx = if idx? then parseInt (idx?.split "]")[0] else 1
+ node = $.findChild node, name.toLowerCase(), idx
+
+ node
+
$.escape = (html) ->
html.replace(/&(?!\w+;)/g, '&').replace(//g, '>').replace(/"/g, '"')
diff --git a/src/range.coffee b/src/range.coffee
index 189fe8fd5..7716b223a 100644
--- a/src/range.coffee
+++ b/src/range.coffee
@@ -16,6 +16,13 @@ Range.sniff = (r) ->
if r.commonAncestorContainer?
new Range.BrowserRange(r)
else if typeof r.start is "string"
+ # Annotator <= 1.2.6 upgrade code
+ new Range.SerializedRange
+ startContainer: r.start
+ startOffset: r.startOffset
+ endContainer: r.end
+ endOffset: r.endOffset
+ else if typeof r.startContainer is "string"
new Range.SerializedRange(r)
else if r.start and typeof r.start is "object"
new Range.NormalizedRange(r)
@@ -39,7 +46,14 @@ Range.sniff = (r) ->
# Returns the Node if found otherwise null.
Range.nodeFromXPath = (xpath, root=document) ->
evaluateXPath = (xp, nsResolver=null) ->
- document.evaluate('.' + xp, root, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
+ try
+ document.evaluate('.' + xp, root, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
+ catch exception
+ if exception?.code is 52
+ console.log "XPath evaluation failed with code 52. Trying manual..."
+ $.dummyXPathEvaluate xp, root
+ else
+ throw exception
if not $.isXMLDoc document.documentElement
evaluateXPath xpath
@@ -122,48 +136,82 @@ class Range.BrowserRange
for p in ['start', 'end']
node = this[p + 'Container']
offset = this[p + 'Offset']
+# console.log p + " node: " + node + "; offset: " + offset
- # elementNode nodeType == 1
- if node.nodeType is 1
+ if node.nodeType is Node.ELEMENT_NODE
# Get specified node.
it = node.childNodes[offset]
# If it doesn't exist, that means we need the end of the
# previous one.
node = it or node.childNodes[offset - 1]
- # if node doesn't have any children, it's a
or