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
or - # other self-closing tag, and we actually want the textNode - # that ends just before it - if node.nodeType is 1 and not node.firstChild - it = null # null out ref to node so offset is correctly calculated below. - node = node.previousSibling + # Is this an IMG? + isImg = node.nodeType is Node.ELEMENT_NODE and node.tagName.toLowerCase() is "img" + if isImg + # This is an img. Don't do anything. + offset = 0 + else + # if node doesn't have any children, it's a
or
or + # other self-closing tag, and we actually want the textNode + # that ends just before it + while node.nodeType is Node.ELEMENT_NODE and not node.firstChild and not isImg + it = null # null out ref to node so offset is correctly calculated below. + node = node.previousSibling - # textNode nodeType == 3 - while node.nodeType isnt 3 - node = node.firstChild + # Try to find a text child + while (node.nodeType isnt Node.TEXT_NODE) + node = node.firstChild - offset = if it then 0 else node.nodeValue.length + offset = if it then 0 else node.nodeValue.length r[p] = node r[p + 'Offset'] = offset + r[p + 'Img'] = isImg + - nr.start = if r.startOffset > 0 then r.start.splitText(r.startOffset) else r.start + changed = false + + if r.startOffset > 0 + if r.start.data.length > r.startOffset + nr.start = r.start.splitText r.startOffset +# console.log "Had to split element at start, at offset " + r.startOffset + changed = true + else + nr.start = r.start.nextSibling +# console.log "No split neaded at start, already cut." + else + nr.start = r.start +# console.log "No split needed at start, offset is 0." - if r.start is r.end + if r.start is r.end and not r.startImg if (r.endOffset - r.startOffset) < nr.start.nodeValue.length nr.start.splitText(r.endOffset - r.startOffset) +# console.log "But had to split element at end at offset " + +# (r.endOffset - r.startOffset) + changed = true + else +# console.log "End is clean, too." nr.end = nr.start else - if r.endOffset < r.end.nodeValue.length - r.end.splitText(r.endOffset) + if r.endOffset < r.end.nodeValue.length and not r.endImg + r.end.splitText r.endOffset +# console.log "Besides start, had to split element at end at offset" + +# r.endOffset + changed = true + else +# console.log "End is clean." nr.end = r.end # Make sure the common ancestor is an element node. nr.commonAncestor = @commonAncestorContainer # elementNode nodeType == 1 - while nr.commonAncestor.nodeType isnt 1 + while nr.commonAncestor.nodeType isnt Node.ELEMENT_NODE nr.commonAncestor = nr.commonAncestor.parentNode + if window.DomTextMapper? and changed +# console.log "Ranged normalization changed the DOM, updating d-t-m" + window.DomTextMapper.changed nr.commonAncestor, "range normalization" + new Range.NormalizedRange(nr) # Public: Creates a range suitable for storage. @@ -254,15 +302,18 @@ class Range.NormalizedRange for n in nodes offset += n.nodeValue.length - if isEnd then [xpath, offset + node.nodeValue.length] else [xpath, offset] + isImg = node.nodeType is Node.ELEMENT_NODE and + node.tagName.toLowerCase() is "img" + + if isEnd and not isImg then [xpath, offset + node.nodeValue.length] else [xpath, offset] start = serialization(@start) end = serialization(@end, true) new Range.SerializedRange({ # XPath strings - start: start[0] - end: end[0] + startContainer: start[0] + endContainer: end[0] # Character offsets (integer) startOffset: start[1] endOffset: end[1] @@ -309,18 +360,18 @@ class Range.SerializedRange # Public: Creates a SerializedRange # # obj - The stored object. It should have the following properties. - # start: An xpath to the Element containing the first TextNode - # relative to the root Element. - # startOffset: The offset to the start of the selection from obj.start. - # end: An xpath to the Element containing the last TextNode - # relative to the root Element. - # startOffset: The offset to the end of the selection from obj.end. + # startContainer: An xpath to the Element containing the first TextNode + # relative to the root Element. + # startOffset: The offset to the start of the selection from obj.start. + # endContainer: An xpath to the Element containing the last TextNode + # relative to the root Element. + # startOffset: The offset to the end of the selection from obj.end. # # Returns an instance of SerializedRange constructor: (obj) -> - @start = obj.start + @startContainer = obj.startContainer @startOffset = obj.startOffset - @end = obj.end + @endContainer = obj.endContainer @endOffset = obj.endOffset # Public: Creates a NormalizedRange. @@ -332,25 +383,33 @@ class Range.SerializedRange range = {} for p in ['start', 'end'] + xpath = this[p + 'Container'] try - node = Range.nodeFromXPath(this[p], root) + node = Range.nodeFromXPath(xpath, root) catch e - throw new Range.RangeError(p, "Error while finding #{p} node: #{this[p]}: " + e, e) + throw new Range.RangeError(p, "Error while finding #{p} node: #{xpath}: " + e, e) if not node - throw new Range.RangeError(p, "Couldn't find #{p} node: #{this[p]}") + throw new Range.RangeError(p, "Couldn't find #{p} node: #{xpath}") # Unfortunately, we *can't* guarantee only one textNode per # elementNode, so we have to walk along the element's textNodes until # the combined length of the textNodes to that point exceeds or # matches the value of the offset. length = 0 + targetOffset = this[p + 'Offset'] + if p is "start" then 1 else 0 +# console.log "*** Looking for " + p + ". targetOffset is " + targetOffset for tn in $(node).textNodes() - if (length + tn.nodeValue.length >= this[p + 'Offset']) +# console.log "Checking next TN. Length is: " + tn.nodeValue.length + if length + tn.nodeValue.length >= targetOffset +# console.log "**** Found! Position is in '" + tn.nodeValue + "'." range[p + 'Container'] = tn range[p + 'Offset'] = this[p + 'Offset'] - length break else +# console.log "Going on, because this ends at " + +# (length + tn.nodeValue.length) + ", and we are looking for " + +# targetOffset length += tn.nodeValue.length # If we fall off the end of the for loop without having set @@ -407,8 +466,8 @@ class Range.SerializedRange # Public: Returns the range as an Object literal. toObject: -> { - start: @start + startContainer: @startContainer startOffset: @startOffset - end: @end + endContainer: @endContainer endOffset: @endOffset }