From 9fb4e54dca708b03ef009c5b6f068bb3278deb09 Mon Sep 17 00:00:00 2001 From: Kyle Keating Date: Tue, 22 Sep 2020 10:35:52 -0700 Subject: [PATCH] Convert range.coffee to range.js - Remove xpath.coffee as it is no longer needed - Add missing unit tests for range.js module --- src/annotator/anchoring/pdf.js | 9 +- src/annotator/anchoring/range.coffee | 492 ---------------- src/annotator/anchoring/range.js | 489 ++++++++++++++++ src/annotator/anchoring/test/range-test.js | 525 ++++++++++++++++++ .../{xpath-evaluate-test.js => xpath-test.js} | 4 +- src/annotator/anchoring/types.coffee | 6 +- src/annotator/anchoring/xpath.coffee | 106 ---- .../anchoring/{xpath-evaluate.js => xpath.js} | 0 src/annotator/guest.js | 5 +- src/annotator/test/highlighter-test.js | 18 +- 10 files changed, 1036 insertions(+), 618 deletions(-) delete mode 100644 src/annotator/anchoring/range.coffee create mode 100644 src/annotator/anchoring/range.js create mode 100644 src/annotator/anchoring/test/range-test.js rename src/annotator/anchoring/test/{xpath-evaluate-test.js => xpath-test.js} (97%) delete mode 100644 src/annotator/anchoring/xpath.coffee rename src/annotator/anchoring/{xpath-evaluate.js => xpath.js} (100%) diff --git a/src/annotator/anchoring/pdf.js b/src/annotator/anchoring/pdf.js index 207ff30f6c7..a01c1264bb4 100644 --- a/src/annotator/anchoring/pdf.js +++ b/src/annotator/anchoring/pdf.js @@ -8,8 +8,7 @@ const createNodeIterator = require('dom-node-iterator/polyfill')(); import RenderingStates from '../pdfjs-rendering-states'; -// @ts-expect-error - `./range` needs to be converted to JS. -import xpathRange from './range'; +import { BrowserRange } from './range'; import { toRange as textPositionToRange } from './text-position'; // @ts-expect-error - `./types` needs to be converted to JS. @@ -434,7 +433,7 @@ export function anchor(root, selectors) { * @return {Promise<[TextPositionSelector, TextQuoteSelector]>} */ export function describe(root, range, options = {}) { - const normalizedRange = new xpathRange.BrowserRange(range).normalize(); + const normalizedRange = new BrowserRange(range).normalize(); const startTextLayer = getNodeTextLayer(normalizedRange.start); const endTextLayer = getNodeTextLayer(normalizedRange.end); @@ -448,6 +447,10 @@ export function describe(root, range, options = {}) { const startRange = normalizedRange.limit(startTextLayer); const endRange = normalizedRange.limit(endTextLayer); + if (!startRange || !endRange) { + return Promise.reject(new Error('range is outside text layer')); + } + const startPageIndex = getSiblingIndex(startTextLayer.parentNode); const iter = createNodeIterator.call( diff --git a/src/annotator/anchoring/range.coffee b/src/annotator/anchoring/range.coffee deleted file mode 100644 index 47a5e142e1f..00000000000 --- a/src/annotator/anchoring/range.coffee +++ /dev/null @@ -1,492 +0,0 @@ -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# This is a modified copy of -# https://github.com/openannotation/annotator/blob/v1.2.x/src/range.coffee - -$ = require('jquery') - -Util = require('./util') - -Range = {} - -# Public: Determines the type of Range of the provided object and returns -# a suitable Range instance. -# -# r - A range Object. -# -# Examples -# -# selection = window.getSelection() -# Range.sniff(selection.getRangeAt(0)) -# # => Returns a BrowserRange instance. -# -# Returns a Range object or false. -Range.sniff = (r) -> - if r.commonAncestorContainer? - new Range.BrowserRange(r) - else if typeof r.start is "string" - new Range.SerializedRange(r) - else if r.start and typeof r.start is "object" - new Range.NormalizedRange(r) - else - console.error("Could not sniff range type") - false - - -# Public: Finds an Element Node using an XPath relative to the document root. -# -# If the document is served as application/xhtml+xml it will try and resolve -# any namespaces within the XPath. -# -# xpath - An XPath String to query. -# -# Examples -# -# node = Range.nodeFromXPath('/html/body/div/p[2]') -# if node -# # Do something with the node. -# -# Returns the Node if found otherwise null. -Range.nodeFromXPath = (xpath, root=document) -> - evaluateXPath = (xp, nsResolver=null) -> - try - document.evaluate('.' + xp, root, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue - catch exception - # There are cases when the evaluation fails, because the - # HTML documents contains nodes with invalid names, - # for example tags with equal signs in them, or something like that. - # In these cases, the XPath expressions will have these abominations, - # too, and then they can not be evaluated. - # In these cases, we get an XPathException, with error code 52. - # See http://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathException - # This does not necessarily make any sense, but this what we see - # happening. - console.log "XPath evaluation failed." - console.log "Trying fallback..." - # We have a an 'evaluator' for the really simple expressions that - # should work for the simple expressions we generate. - Util.nodeFromXPath(xp, root) - - if not $.isXMLDoc document.documentElement - evaluateXPath xpath - else - # We're in an XML document, create a namespace resolver function to try - # and resolve any namespaces in the current document. - # https://developer.mozilla.org/en/DOM/document.createNSResolver - customResolver = document.createNSResolver( - if document.ownerDocument == null - document.documentElement - else - document.ownerDocument.documentElement - ) - node = evaluateXPath xpath, customResolver - - unless node - # If the previous search failed to find a node then we must try to - # provide a custom namespace resolver to take into account the default - # namespace. We also prefix all node names with a custom xhtml namespace - # eg. 'div' => 'xhtml:div'. - xpath = (for segment in xpath.split '/' - if segment and segment.indexOf(':') == -1 - segment.replace(/^([a-z]+)/, 'xhtml:$1') - else segment - ).join('/') - - # Find the default document namespace. - namespace = document.lookupNamespaceURI null - - # Try and resolve the namespace, first seeing if it is an xhtml node - # otherwise check the head attributes. - customResolver = (ns) -> - if ns == 'xhtml' then namespace - else document.documentElement.getAttribute('xmlns:' + ns) - - node = evaluateXPath xpath, customResolver - node - -class Range.RangeError extends Error - constructor: (@type, @message, @parent=null) -> - super(@message) - -# Public: Creates a wrapper around a range object obtained from a DOMSelection. -class Range.BrowserRange - - # Public: Creates an instance of BrowserRange. - # - # object - A range object obtained via DOMSelection#getRangeAt(). - # - # Examples - # - # selection = window.getSelection() - # range = new Range.BrowserRange(selection.getRangeAt(0)) - # - # Returns an instance of BrowserRange. - constructor: (obj) -> - @commonAncestorContainer = obj.commonAncestorContainer - @startContainer = obj.startContainer - @startOffset = obj.startOffset - @endContainer = obj.endContainer - @endOffset = obj.endOffset - - # Public: normalize works around the fact that browsers don't generate - # ranges/selections in a consistent manner. Some (Safari) will create - # ranges that have (say) a textNode startContainer and elementNode - # endContainer. Others (Firefox) seem to only ever generate - # textNode/textNode or elementNode/elementNode pairs. - # - # Returns an instance of Range.NormalizedRange - normalize: (root) -> - if @tainted - console.error("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 - if @startOffset < @startContainer.childNodes.length - r.start = Util.getFirstTextNodeNotBefore @startContainer.childNodes[@startOffset] - else - r.start = Util.getFirstTextNodeNotBefore @startContainer - 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? - 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 = {} - - if r.startOffset > 0 - # Do we really have to cut? - if !r.start.nextSibling || r.start.nodeValue.length > r.startOffset - # Yes. Cut. - nr.start = r.start.splitText(r.startOffset) - 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) - 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) - 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 - - new Range.NormalizedRange(nr) - - # Public: Creates a range suitable for storage. - # - # root - A root Element from which to anchor the serialisation. - # ignoreSelector - A selector String of elements to ignore. For example - # elements injected by the annotator. - # - # Returns an instance of SerializedRange. - serialize: (root, ignoreSelector) -> - this.normalize(root).serialize(root, ignoreSelector) - -# Public: A normalised range is most commonly used throughout the annotator. -# its the result of a deserialised SerializedRange or a BrowserRange with -# out browser inconsistencies. -class Range.NormalizedRange - - # Public: Creates an instance of a NormalizedRange. - # - # This is usually created by calling the .normalize() method on one of the - # other Range classes rather than manually. - # - # obj - An Object literal. Should have the following properties. - # commonAncestor: A Element that encompasses both the start and end nodes - # start: The first TextNode in the range. - # end The last TextNode in the range. - # - # Returns an instance of NormalizedRange. - constructor: (obj) -> - @commonAncestor = obj.commonAncestor - @start = obj.start - @end = obj.end - - # Public: For API consistency. - # - # Returns itself. - normalize: (root) -> - this - - # Public: Limits the nodes within the NormalizedRange to those contained - # withing the bounds parameter. It returns an updated range with all - # properties updated. NOTE: Method returns null if all nodes fall outside - # of the bounds. - # - # bounds - An Element to limit the range to. - # - # Returns updated self or null. - limit: (bounds) -> - nodes = $.grep this.textNodes(), (node) -> - node.parentNode == bounds or $.contains(bounds, node.parentNode) - - return null unless nodes.length - - @start = nodes[0] - @end = nodes[nodes.length - 1] - - startParents = $(@start).parents() - for parent in $(@end).parents() - if startParents.index(parent) != -1 - @commonAncestor = parent - break - this - - # Convert this range into an object consisting of two pairs of (xpath, - # character offset), which can be easily stored in a database. - # - # root - The root Element relative to which XPaths should be calculated - # ignoreSelector - A selector String of elements to ignore. For example - # elements injected by the annotator. - # - # Returns an instance of SerializedRange. - serialize: (root, ignoreSelector) -> - - serialization = (node, isEnd) -> - if ignoreSelector - origParent = $(node).parents(":not(#{ignoreSelector})").eq(0) - else - origParent = $(node).parent() - - xpath = Util.xpathFromNode(origParent, root)[0] - textNodes = Util.getTextNodes(origParent) - - # Calculate real offset as the combined length of all the - # preceding textNode siblings. We include the length of the - # node if it's the end node. - nodes = textNodes.slice(0, textNodes.index(node)) - offset = 0 - for n in nodes - offset += n.nodeValue.length - - if isEnd 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] - # Character offsets (integer) - startOffset: start[1] - endOffset: end[1] - }) - - # Public: Creates a concatenated String of the contents of all the text nodes - # within the range. - # - # Returns a String. - text: -> - (for node in this.textNodes() - node.nodeValue - ).join '' - - # Public: Fetches only the text nodes within th range. - # - # Returns an Array of TextNode instances. - textNodes: -> - textNodes = Util.getTextNodes($(this.commonAncestor)) - [start, end] = [textNodes.index(this.start), textNodes.index(this.end)] - # Return the textNodes that fall between the start and end indexes. - $.makeArray textNodes[start..end] - - # Public: Converts the Normalized range to a native browser range. - # - # See: https://developer.mozilla.org/en/DOM/range - # - # Examples - # - # selection = window.getSelection() - # selection.removeAllRanges() - # selection.addRange(normedRange.toRange()) - # - # Returns a Range object. - toRange: -> - range = document.createRange() - range.setStartBefore(@start) - range.setEndAfter(@end) - range - -# Public: A range suitable for storing in local storage or serializing to JSON. -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. - # - # Returns an instance of SerializedRange - constructor: (obj) -> - @start = obj.start - @startOffset = obj.startOffset - @end = obj.end - @endOffset = obj.endOffset - - # Public: Creates a NormalizedRange. - # - # root - The root Element from which the XPaths were generated. - # - # Returns a NormalizedRange instance. - normalize: (root) -> - range = {} - - for p in ['start', 'end'] - try - node = Range.nodeFromXPath(this[p], root) - catch e - throw new Range.RangeError(p, "Error while finding #{p} node: #{this[p]}: " + e, e) - - if not node - throw new Range.RangeError(p, "Couldn't find #{p} node: #{this[p]}") - - # 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'] - - # Range excludes its endpoint because it describes the boundary position. - # Target the string index of the last character inside the range. - if p is 'end' then targetOffset-- - - for tn in Util.getTextNodes($(node)) - if (length + tn.nodeValue.length > targetOffset) - range[p + 'Container'] = tn - range[p + 'Offset'] = this[p + 'Offset'] - length - break - else - length += tn.nodeValue.length - - # If we fall off the end of the for loop without having set - # 'startOffset'/'endOffset', the element has shorter content than when - # we annotated, so throw an error: - if not range[p + 'Offset']? - throw new Range.RangeError("#{p}offset", "Couldn't find offset #{this[p + 'Offset']} in element #{this[p]}") - - # Here's an elegant next step... - # - # range.commonAncestorContainer = $(range.startContainer).parents().has(range.endContainer)[0] - # - # ...but unfortunately Node.contains() is broken in Safari 5.1.5 (7534.55.3) - # and presumably other earlier versions of WebKit. In particular, in a - # document like - # - #

Hello

- # - # the code - # - # p = document.getElementsByTagName('p')[0] - # p.contains(p.firstChild) - # - # returns `false`. Yay. - # - # So instead, we step through the parents from the bottom up and use - # Node.compareDocumentPosition() to decide when to set the - # commonAncestorContainer and bail out. - - contains = - if not document.compareDocumentPosition? - # IE - (a, b) -> a.contains(b) - else - # Everyone else - (a, b) -> a.compareDocumentPosition(b) & 16 - - $(range.startContainer).parents().each -> - if contains(this, range.endContainer) - range.commonAncestorContainer = this - return false - - new Range.BrowserRange(range).normalize(root) - - # Public: Creates a range suitable for storage. - # - # root - A root Element from which to anchor the serialisation. - # ignoreSelector - A selector String of elements to ignore. For example - # elements injected by the annotator. - # - # Returns an instance of SerializedRange. - serialize: (root, ignoreSelector) -> - this.normalize(root).serialize(root, ignoreSelector) - - # Public: Returns the range as an Object literal. - toObject: -> - { - start: @start - startOffset: @startOffset - end: @end - endOffset: @endOffset - } - -module.exports = Range diff --git a/src/annotator/anchoring/range.js b/src/annotator/anchoring/range.js new file mode 100644 index 00000000000..45b7094a647 --- /dev/null +++ b/src/annotator/anchoring/range.js @@ -0,0 +1,489 @@ +import $ from 'jquery'; + +import { xpathFromNode, nodeFromXPath } from './xpath'; +import { + getFirstTextNodeNotBefore, + getLastTextNodeUpTo, + getTextNodes, +} from './xpath-util'; + +/** + * Creates a wrapper around a range object obtained from a DOMSelection. + */ +export class BrowserRange { + /** + * Creates an instance of BrowserRange. + * + * object - A range object obtained via DOMSelection#getRangeAt(). + * + * Examples + * + * selection = window.getSelection() + * range = new Range.BrowserRange(selection.getRangeAt(0)) + * + * Returns an instance of BrowserRange. + */ + constructor(obj) { + this.commonAncestorContainer = obj.commonAncestorContainer; + this.startContainer = obj.startContainer; + this.startOffset = obj.startOffset; + this.endContainer = obj.endContainer; + this.endOffset = obj.endOffset; + this.tainted = false; + } + + /** + * normalize works around the fact that browsers don't generate + * ranges/selections in a consistent manner. Some (Safari) will create + * ranges that have (say) a textNode startContainer and elementNode + * endContainer. Others (Firefox) seem to only ever generate + * textNode/textNode or elementNode/elementNode pairs. + * + * Returns an instance of NormalizedRange + */ + normalize() { + if (this.tainted) { + throw new Error('You may only call normalize() once on a BrowserRange!'); + } else { + this.tainted = true; + } + const range = {}; + + // Look at the start + if (this.startContainer.nodeType === Node.ELEMENT_NODE) { + // We are dealing with element nodes + if (this.startOffset < this.startContainer.childNodes.length) { + range.start = getFirstTextNodeNotBefore( + this.startContainer.childNodes[this.startOffset] + ); + } else { + range.start = getFirstTextNodeNotBefore(this.startContainer); + } + range.startOffset = 0; + } else { + // We are dealing with simple text nodes + range.start = this.startContainer; + range.startOffset = this.startOffset; + } + + // Look at the end + if (this.endContainer.nodeType === Node.ELEMENT_NODE) { + // Get specified node. + let node = this.endContainer.childNodes[this.endOffset]; + // Does that node exist? + if (node) { + // Look for a text node either at the immediate beginning of node + let n = node; + while (n && n.nodeType !== Node.TEXT_NODE) { + n = n.firstChild; + } + // Did we find a text node at the start of this element? + if (n) { + range.end = n; + range.endOffset = 0; + } + } + + if (!range.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 (this.endOffset) { + node = this.endContainer.childNodes[this.endOffset - 1]; + } else { + node = this.endContainer.previousSibling; + } + range.end = getLastTextNodeUpTo(node); + range.endOffset = range.end.nodeValue.length; + } + } else { + // We are dealing with simple text nodes + range.end = this.endContainer; + range.endOffset = this.endOffset; + } + + // We have collected the initial data. + // Now let's start to slice & dice the text elements! + const normalRange = {}; + + if (range.startOffset > 0) { + // Do we really have to cut? + if ( + !range.start.nextSibling || + range.start.nodeValue.length > range.startOffset + ) { + // Yes. Cut. + normalRange.start = range.start.splitText(range.startOffset); + } else { + // Avoid splitting off zero-length pieces. + normalRange.start = getFirstTextNodeNotBefore(range.start.nextSibling); + } + } else { + normalRange.start = range.start; + } + + // Is the whole selection inside one text element? + if (range.start === range.end) { + if ( + normalRange.start.nodeValue.length > + range.endOffset - range.startOffset + ) { + normalRange.start.splitText(range.endOffset - range.startOffset); + } + normalRange.end = normalRange.start; + } else { + // No, the end of the selection is in a separate text element + // does the end need to be cut? + if (range.end.nodeValue.length > range.endOffset) { + range.end.splitText(range.endOffset); + } + normalRange.end = range.end; + } + + // Make sure the common ancestor is an element node. + normalRange.commonAncestor = this.commonAncestorContainer; + while (normalRange.commonAncestor.nodeType !== Node.ELEMENT_NODE) { + normalRange.commonAncestor = normalRange.commonAncestor.parentNode; + } + + // Circular dependency. Remove this once *Range classes are refactored + // eslint-disable-next-line no-use-before-define + return new NormalizedRange(normalRange); + } + + /** + * Creates a range suitable for storage. + * + * root - A root Element from which to anchor the serialization. + * ignoreSelector - A selector String of elements to ignore. For example + * elements injected by the annotator. + * + * Returns an instance of SerializedRange. + */ + serialize(root, ignoreSelector) { + return this.normalize().serialize(root, ignoreSelector); + } +} + +/** + * A normalized range is most commonly used throughout the annotator. + * its the result of a deserialized SerializedRange or a BrowserRange without + * browser inconsistencies. + */ +export class NormalizedRange { + /** + * Creates an instance of a NormalizedRange. + * + * This is usually created by calling the .normalize() method on one of the + * other Range classes rather than manually. + * + * obj - An Object literal. Should have the following properties. + * commonAncestor: A Element that encompasses both the start and end nodes + * start: The first TextNode in the range. + * end The last TextNode in the range. + * + * Returns an instance of NormalizedRange. + */ + constructor(obj) { + this.commonAncestor = obj.commonAncestor; + this.start = obj.start; + this.end = obj.end; + } + + /** + * For API consistency. + * + * Returns itself. + */ + normalize() { + return this; + } + + /** + * Limits the nodes within the NormalizedRange to those contained + * withing the bounds parameter. It returns an updated range with all + * properties updated. NOTE: Method returns null if all nodes fall outside + * of the bounds. + * + * bounds - An Element to limit the range to. + * + * Returns updated self or null. + */ + limit(bounds) { + const nodes = $.grep( + this.textNodes(), + node => node.parentNode === bounds || $.contains(bounds, node.parentNode) + ); + if (!nodes.length) { + return null; + } + + this.start = nodes[0]; + this.end = nodes[nodes.length - 1]; + + const startParents = $(this.start).parents(); + + for (let parent of $(this.end).parents()) { + if (startParents.index(parent) !== -1) { + this.commonAncestor = parent; + break; + } + } + return this; + } + + /** + * Convert this range into an object consisting of two pairs of (xpath, + * character offset), which can be easily stored in a database. + * + * root - The root Element relative to which XPaths should be calculated + * ignoreSelector - A selector String of elements to ignore. For example + * elements injected by the annotator. + * + * Returns an instance of SerializedRange. + */ + serialize(root, ignoreSelector) { + const serialization = (node, isEnd) => { + let origParent; + if (ignoreSelector) { + origParent = $(node).parents(`:not(${ignoreSelector})`).eq(0); + } else { + origParent = $(node).parent(); + } + const xpath = xpathFromNode(origParent, root)[0]; + const textNodes = getTextNodes(origParent); + // Calculate real offset as the combined length of all the + // preceding textNode siblings. We include the length of the + // node if it's the end node. + const nodes = textNodes.slice(0, textNodes.index(node)); + let offset = 0; + for (let n of nodes) { + offset += n.nodeValue.length; + } + + if (isEnd) { + return [xpath, offset + node.nodeValue.length]; + } else { + return [xpath, offset]; + } + }; + + const start = serialization(this.start); + const end = serialization(this.end, true); + + // Circular dependency. Remove this once *Range classes are refactored + // eslint-disable-next-line no-use-before-define + return new SerializedRange({ + // XPath strings + start: start[0], + end: end[0], + // Character offsets (integer) + startOffset: start[1], + endOffset: end[1], + }); + } + + /** + * Creates a concatenated String of the contents of all the text nodes + * within the range. + * + * Returns a String. + */ + text() { + return this.textNodes() + .map(node => node.nodeValue) + .join(''); + } + + /** + * Fetches only the text nodes within the range. + * + * Returns an Array of TextNode instances. + */ + textNodes() { + const textNodes = getTextNodes($(this.commonAncestor)); + const start = textNodes.index(this.start); + const end = textNodes.index(this.end); + // Return the textNodes that fall between the start and end indexes. + return $.makeArray(textNodes.slice(start, +end + 1 || undefined)); + } + + /** + * Converts the Normalized range to a native browser range. + * + * See: https://developer.mozilla.org/en/DOM/range + * + * Examples + * + * selection = window.getSelection() + * selection.removeAllRanges() + * selection.addRange(normedRange.toRange()) + * + * Returns a Range object. + */ + toRange() { + const range = document.createRange(); + range.setStartBefore(this.start); + range.setEndAfter(this.end); + return range; + } +} + +/** + * A range suitable for storing in local storage or serializing to JSON. + */ +export class SerializedRange { + /** + * 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. + * + * Returns an instance of SerializedRange + */ + constructor(obj) { + this.start = obj.start; + this.startOffset = obj.startOffset; + this.end = obj.end; + this.endOffset = obj.endOffset; + } + + /** + * Creates a NormalizedRange. + * + * root - The root Element from which the XPaths were generated. + * + * Returns a NormalizedRange instance. + */ + normalize(root) { + const range = {}; + + for (let p of ['start', 'end']) { + let node; + try { + node = nodeFromXPath(this[p], root); + } catch (e) { + throw new RangeError(`Error while finding ${p} node: ${this[p]}: ` + e); + } + // 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. + let length = 0; + let targetOffset = this[p + 'Offset']; + + // Range excludes its endpoint because it describes the boundary position. + // Target the string index of the last character inside the range. + if (p === 'end') { + targetOffset--; + } + + for (let tn of getTextNodes($(node))) { + if (length + tn.nodeValue.length > targetOffset) { + range[p + 'Container'] = tn; + range[p + 'Offset'] = this[p + 'Offset'] - length; + break; + } else { + length += tn.nodeValue.length; + } + } + + // If we fall off the end of the for loop without having set + // 'startOffset'/'endOffset', the element has shorter content than when + // we annotated, so throw an error: + if (range[p + 'Offset'] === undefined) { + throw new RangeError( + `Couldn't find offset ${this[p + 'Offset']} in element ${this[p]}` + ); + } + } + + // Here's an elegant next step... + // + // range.commonAncestorContainer = $(range.startContainer).parents().has(range.endContainer)[0] + // + // ...but unfortunately Node.contains() is broken in Safari 5.1.5 (7534.55.3) + // and presumably other earlier versions of WebKit. In particular, in a + // document like + // + //

Hello

+ // + // the code + // + // p = document.getElementsByTagName('p')[0] + // p.contains(p.firstChild) + // + // returns `false`. Yay. + // + // So instead, we step through the parents from the bottom up and use + // Node.compareDocumentPosition() to decide when to set the + // commonAncestorContainer and bail out. + + const contains = (a, b) => a.compareDocumentPosition(b) & 16; + $(range.startContainer) + .parents() + .each(function () { + if (contains(this, range.endContainer)) { + range.commonAncestorContainer = this; + // bail out of loop + return false; + } + return true; + }); + + return new BrowserRange(range).normalize(); + } + + /** + * Creates a range suitable for storage. + * + * root - A root Element from which to anchor the serialization. + * ignoreSelector - A selector String of elements to ignore. For example + * elements injected by the annotator. + * + * Returns an instance of SerializedRange. + */ + serialize(root, ignoreSelector) { + return this.normalize(root).serialize(root, ignoreSelector); + } + + // Returns the range as an Object literal. + toObject() { + return { + start: this.start, + startOffset: this.startOffset, + end: this.end, + endOffset: this.endOffset, + }; + } +} + +/** + * Determines the type of Range of the provided object and returns + * a suitable Range instance. + * + * r - A range Object. + * + * Examples + * + * selection = window.getSelection() + * Range.sniff(selection.getRangeAt(0)) + * # => Returns a BrowserRange instance. + * + * Returns a Range object or false. + */ +export function sniff(range) { + if (range.commonAncestorContainer !== undefined) { + return new BrowserRange(range); + } else if (typeof range.start === 'string') { + return new SerializedRange(range); + } else if (range.start && typeof range.start === 'object') { + return new NormalizedRange(range); + } else { + throw new Error('Could not sniff range type'); + } +} diff --git a/src/annotator/anchoring/test/range-test.js b/src/annotator/anchoring/test/range-test.js new file mode 100644 index 00000000000..b28bef1aab6 --- /dev/null +++ b/src/annotator/anchoring/test/range-test.js @@ -0,0 +1,525 @@ +import { + sniff, + BrowserRange, + NormalizedRange, + SerializedRange, + $imports, +} from '../range'; + +describe('annotator/anchoring/range', () => { + let container; + const html = ` +
+

text 1

+

text 2a
text 2b

+ +

text 3

+

+


+
+
+ `; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + // Remove extraneous white space which can affect offsets in tests. + // 1. Two or more spaces in a row + // 2. New lines + container.innerHTML = html.replace(/[\s+]{2,}|\n+/g, ''); + }); + + afterEach(() => { + container.remove(); + }); + + function createBrowserRange(props) { + return new BrowserRange({ + commonAncestorContainer: container, + startContainer: container.querySelector('#p-1').firstChild, + startOffset: 0, + endContainer: container.querySelector('#p-3').firstChild, + endOffset: 1, + ...props, + }); + } + + function createNormalizedRange(props) { + return new NormalizedRange({ + commonAncestor: container, + start: container.querySelector('#p-1').firstChild, + end: container.querySelector('#p-3').firstChild, + ...props, + }); + } + + function createSerializedRange(props) { + return new SerializedRange({ + start: '/section[1]/p[1]', + startOffset: 0, + end: '/section[1]/span[1]/p[1]', + endOffset: 6, + ...props, + }); + } + + describe('sniff', () => { + it('returns a BrowserRange', () => { + const result = sniff({ + commonAncestorContainer: container, + startContainer: container.querySelector('#p-1').firstChild, + startOffset: 0, + endContainer: container.querySelector('#p-3').firstChild, + endOffset: 1, + }); + assert.isTrue(result instanceof BrowserRange); + }); + + it('returns a SerializedRange', () => { + const result = sniff({ + start: '/section[1]/p[1]', + startOffset: 0, + end: '/section[1]/span[1]/p[1]', + endOffset: 6, + }); + assert.isTrue(result instanceof SerializedRange); + }); + + it('returns a NormalizedRange', () => { + const result = sniff({ + commonAncestor: container, + start: container.querySelector('#p-1').firstChild, + end: container.querySelector('#p-3').firstChild, + }); + assert.isTrue(result instanceof NormalizedRange); + }); + + it("throws an error if it can't detect a range type", () => { + assert.throws(() => { + sniff({ + fake: true, + }); + }, 'Could not sniff range type'); + }); + }); + + describe('BrowserRange', () => { + describe('#constructor', () => { + it('creates a BrowserRange instance', () => { + const range = createBrowserRange(); + assert.deepEqual(range, { + commonAncestorContainer: container, + startContainer: container.querySelector('#p-1').firstChild, + startOffset: 0, + endContainer: container.querySelector('#p-3').firstChild, + endOffset: 1, + tainted: false, + }); + }); + }); + + describe('#normalize', () => { + it('throws an error if BrowserRange instance is normalized more than once', () => { + const browserRange = createBrowserRange(); + assert.throws(() => { + browserRange.normalize(); + browserRange.normalize(); + }, 'You may only call normalize() once on a BrowserRange!'); + }); + + it('converts BrowserRange instance to NormalizedRange instance', () => { + const browserRange = createBrowserRange(); + const normalizedRange = browserRange.normalize(); + assert.isTrue(normalizedRange instanceof NormalizedRange); + assert.deepEqual(normalizedRange, createNormalizedRange()); + }); + + context('`startContainer` is ELEMENT_NODE', () => { + it('handles ELEMENT_NODE start node', () => { + const browserRange = createBrowserRange({ + startContainer: container.querySelector('#p-1'), + }); + const normalizedRange = browserRange.normalize(); + assert.deepEqual(normalizedRange, createNormalizedRange()); + }); + + it('handles ELEMENT_NODE start node when `startOffset` > than number of child nodes', () => { + const browserRange = createBrowserRange({ + startContainer: container.querySelector('#p-1'), + startOffset: 1, + }); + const normalizedRange = browserRange.normalize(); + assert.deepEqual(normalizedRange, createNormalizedRange()); + }); + }); + + context('endContainer is ELEMENT_NODE', () => { + it('handles ELEMENT_NODE end node', () => { + const browserRange = createBrowserRange({ + endContainer: container.querySelector('#p-3'), + }); + const normalizedRange = browserRange.normalize(); + assert.deepEqual(normalizedRange, createNormalizedRange()); + }); + + it('handles ELEMENT_NODE end node with no `endOffset` and no children', () => { + const browserRange = createBrowserRange({ + endContainer: container.querySelector('#p-4'), + endOffset: 0, + }); + const normalizedRange = browserRange.normalize(); + assert.deepEqual(normalizedRange, createNormalizedRange()); + }); + + it('handles ELEMENT_NODE end nodes with no `endOffset` and children', () => { + const browserRange = createBrowserRange({ + endContainer: container.querySelector('#p-3'), + endOffset: 0, + }); + const normalizedRange = browserRange.normalize(); + assert.deepEqual(normalizedRange, createNormalizedRange()); + }); + + it('handles ELEMENT_NODE end nodes with no `endOffset` and non-TEXT_NODE children', () => { + const browserRange = createBrowserRange({ + endContainer: container.querySelector('#p-5'), + endOffset: 0, + }); + const normalizedRange = browserRange.normalize(); + assert.deepEqual(normalizedRange, createNormalizedRange()); + }); + }); + + context('slices the text elements', () => { + it('cuts the text node if there is a next sibling', () => { + const browserRange = createBrowserRange({ + startOffset: 1, + }); + const normalizedRange = browserRange.normalize(); + assert.equal(normalizedRange.start.data, 'ext 1'); + }); + + it('cuts the text node if node length > `startOffset`', () => { + const browserRange = createBrowserRange({ + startContainer: container.querySelector('#p-2').firstChild, + startOffset: 1, + }); + const normalizedRange = browserRange.normalize(); + assert.equal(normalizedRange.start.data, 'ext 2a'); + }); + + it('does not cut the text node when there is no next sibling and node length < `startOffset`', () => { + const browserRange = createBrowserRange({ + startContainer: container.querySelector('#p-2').firstChild, + startOffset: 7, + endOffset: 14, + }); + const normalizedRange = browserRange.normalize(); + assert.equal(normalizedRange.start.data, 'text 2b'); + }); + }); + + context('the whole selection is inside one text node', () => { + [ + { + startOffset: 1, + endOffset: 3, + result: 'ex', + }, + { + startOffset: 0, + endOffset: 7, + result: 'text 1', + }, + ].forEach(test => { + it('crops the text node appropriately', () => { + const browserRange = createBrowserRange({ + startContainer: container.querySelector('#p-1').firstChild, + endContainer: container.querySelector('#p-1').firstChild, + startOffset: test.startOffset, + endOffset: test.endOffset, + }); + const normalizedRange = browserRange.normalize(); + assert.equal(normalizedRange.start.data, test.result); + }); + }); + }); + + context('common ancestor is not ELEMENT_NODE', () => { + it('corrects the common ancestor to the first non-text ancestor', () => { + const browserRange = createBrowserRange({ + commonAncestorContainer: container.querySelector('#p-1').firstChild, + }); + const normalizedRange = browserRange.normalize(); + assert.deepEqual( + normalizedRange.commonAncestor, + container.querySelector('#p-1') + ); + }); + }); + }); + + describe('#serialize', () => { + it('converts BrowserRange to SerializedRange instance', () => { + const browserRange = createBrowserRange(); + const result = browserRange.serialize(container); + assert.isTrue(result instanceof SerializedRange); + assert.deepEqual(result, { + start: '/section[1]/p[1]', + startOffset: 0, + end: '/section[1]/span[1]/p[1]', + endOffset: 1, + }); + }); + + it('converts BrowserRange to SerializedRange instance with `ignoreSelector` condition', () => { + const browserRange = createBrowserRange(); + const result = browserRange.serialize(container, 'p'); + assert.deepEqual(result, { + start: '/section[1]', // /p[1] selector in xpath ignored + startOffset: 0, + end: '/section[1]/span[1]', // /p[1] selector in xpath ignored + endOffset: 1, + }); + }); + }); + }); + + describe('NormalizedRange', () => { + describe('#limit, #text and #textNodes methods', () => { + it('does not limit range if the limit node resides outside of bounds', () => { + const limit = createNormalizedRange().limit( + container.querySelector('#span-2') + ); + assert.isNull(limit); + }); + + [ + { + bound: '#p-1', + textNodes: ['text 1'], + }, + { + bound: '#p-2', + textNodes: ['text 2a', 'text 2b'], + }, + { + bound: '#section-1', + textNodes: ['text 1', 'text 2a', 'text 2b', 'text 3'], + }, + ].forEach(test => { + it('limits range to the bounding node', () => { + const limit = createNormalizedRange().limit( + container.querySelector(test.bound) + ); + assert.equal(limit.text(), test.textNodes.join('')); + // To get a node value from jquery, use ".data". + assert.deepEqual( + test.textNodes, + limit.textNodes().map(n => n.data) + ); + }); + }); + }); + + describe('#toRange', () => { + let fakeSetStartBefore; + let fakeSetEndAfter; + + beforeEach(() => { + sinon.stub(document, 'createRange'); + fakeSetStartBefore = sinon.stub(); + fakeSetEndAfter = sinon.stub(); + document.createRange.returns({ + setStartBefore: fakeSetStartBefore, + setEndAfter: fakeSetEndAfter, + }); + }); + + afterEach(() => { + document.createRange.restore(); + }); + + it('converts normalized range to native range', () => { + const range = createNormalizedRange(); + const nativeRange = range.toRange(); + assert.deepEqual(nativeRange, document.createRange()); + assert.calledWith(fakeSetStartBefore, range.start); + assert.calledWith(fakeSetEndAfter, range.end); + }); + }); + + describe('#normalize', () => { + it('returns itself', () => { + const initialRange = createNormalizedRange(); + const normalizedRange = initialRange.normalize(); + assert.equal(initialRange, normalizedRange); + }); + }); + + describe('#serialize', () => { + it('serialize the range with relative parent', () => { + const serializedRange = createNormalizedRange().serialize(container); + assert.deepEqual(serializedRange, { + start: '/section[1]/p[1]', + end: '/section[1]/span[1]/p[1]', + startOffset: 0, + endOffset: 6, + }); + }); + + it('serialize the range with no relative parent', () => { + const serializedRange = createNormalizedRange().serialize(); + assert.deepEqual(serializedRange, { + start: '/html[1]/body[1]/div[1]/section[1]/p[1]', + end: '/html[1]/body[1]/div[1]/section[1]/span[1]/p[1]', + startOffset: 0, + endOffset: 6, + }); + }); + + it('serialize the range with `ignoreSelector` condition', () => { + const serializedRange = createNormalizedRange().serialize( + container, + '#p-3' + ); + assert.deepEqual(serializedRange, { + start: '/section[1]/p[1]', + end: '/section[1]/span[1]', + startOffset: 0, + endOffset: 6, + }); + }); + + it('serialize the range with multiple text nodes', () => { + const serializedRange = createNormalizedRange({ + start: container.querySelector('#p-2').firstChild.nextSibling + .nextSibling, + end: container.querySelector('#p-3').firstChild, + startOffset: 7, + endOffset: 6, + }).serialize(); + assert.deepEqual(serializedRange, { + start: '/html[1]/body[1]/div[1]/section[1]/p[2]', + end: '/html[1]/body[1]/div[1]/section[1]/span[1]/p[1]', + startOffset: 7, + endOffset: 6, + }); + }); + }); + }); + + describe('SerializedRange', () => { + describe('#constructor', () => { + it('creates a SerializedRange instance', () => { + const range = createSerializedRange(); + assert.deepEqual(range, { + start: '/section[1]/p[1]', + startOffset: 0, + end: '/section[1]/span[1]/p[1]', + endOffset: 6, + }); + }); + }); + + describe('#toObject', () => { + it('returns an object literal', () => { + const range = createSerializedRange(); + assert.deepEqual(range.toObject(), { + start: '/section[1]/p[1]', + startOffset: 0, + end: '/section[1]/span[1]/p[1]', + endOffset: 6, + }); + }); + }); + + describe('#normalize', () => { + it('converts a SerializedRange instance to a NormalizedRange instance', () => { + const serializedRange = createSerializedRange(); + const normalizedRange = serializedRange.normalize(container); + assert.isTrue(normalizedRange instanceof NormalizedRange); + assert.equal(normalizedRange.start.data, 'text 1'); + assert.equal(normalizedRange.end.data, 'text 3'); + }); + + it('adjusts starting offset to second text node', () => { + const serializedRange = createSerializedRange({ + start: '/section[1]/p[2]', + end: '/section[1]/p[2]', + startOffset: 7, + endOffset: 14, + }); + const normalizedRange = serializedRange.normalize(container); + assert.isTrue(normalizedRange instanceof NormalizedRange); + assert.equal(normalizedRange.start.data, 'text 2b'); + assert.equal(normalizedRange.end.data, 'text 2b'); + }); + + context('when offsets are invalid', () => { + it("throws an error if it can't find a valid start offset", () => { + const serializedRange = createSerializedRange({ + startOffset: 99, + }); + assert.throws(() => { + serializedRange.normalize(container); + }, "Couldn't find offset 99 in element /section[1]/p[1]"); + }); + + it("throws an error if it can't find a valid end offset", () => { + const serializedRange = createSerializedRange({ + startOffset: 1, + endOffset: 99, + }); + assert.throws(() => { + serializedRange.normalize(container); + }, "Couldn't find offset 99 in element /section[1]/span[1]/p[1]"); + }); + }); + + context('nodeFromXPath() does not return a valid node', () => { + let fakeNodeFromXPath; + + beforeEach(() => { + fakeNodeFromXPath = sinon.stub(); + $imports.$mock({ + './xpath': { + nodeFromXPath: fakeNodeFromXPath, + }, + }); + }); + + afterEach(() => { + $imports.$restore(); + }); + + it('throws a range error if nodeFromXPath() throws an error', () => { + fakeNodeFromXPath.throws(new Error('error message')); + const serializedRange = createSerializedRange(); + assert.throws(() => { + serializedRange.normalize(container); + }, 'Error while finding start node: /section[1]/p[1]: Error: error message'); + }); + }); + }); + + describe('#serialize', () => { + it('converts a SerializedRange to a new SerializedRange instance', () => { + const serializedRange = createSerializedRange(); + const result = serializedRange.serialize(container); + assert.isTrue(result instanceof SerializedRange); + // The copied instance shall be identical to the initial. + assert.deepEqual(result, serializedRange); + }); + + it('converts a SerializedRange to a new SerializedRange instance with `ignoreSelector` condition', () => { + const serializedRange = createSerializedRange(); + const result = serializedRange.serialize(container, '#p-3'); + // End xpath shall ignore the provided selector and not + // be identical to the initial end xpath. + assert.notEqual(serializedRange.end, result.end); + assert.equal(result.end, '/section[1]/span[1]'); + }); + }); + }); +}); diff --git a/src/annotator/anchoring/test/xpath-evaluate-test.js b/src/annotator/anchoring/test/xpath-test.js similarity index 97% rename from src/annotator/anchoring/test/xpath-evaluate-test.js rename to src/annotator/anchoring/test/xpath-test.js index 3b6bdd556ff..f5c0d67b537 100644 --- a/src/annotator/anchoring/test/xpath-evaluate-test.js +++ b/src/annotator/anchoring/test/xpath-test.js @@ -1,8 +1,8 @@ import $ from 'jquery'; -import { nodeFromXPath, xpathFromNode, $imports } from '../xpath-evaluate'; +import { nodeFromXPath, xpathFromNode, $imports } from '../xpath'; -describe('annotator/anchoring/xpath-evaluate', () => { +describe('annotator/anchoring/xpath', () => { describe('xpathFromNode', () => { let container; let fakeSimpleXPathJQuery; diff --git a/src/annotator/anchoring/types.coffee b/src/annotator/anchoring/types.coffee index 479b526cfb4..1229589936b 100644 --- a/src/annotator/anchoring/types.coffee +++ b/src/annotator/anchoring/types.coffee @@ -9,7 +9,7 @@ domAnchorTextPosition = require('dom-anchor-text-position') domAnchorTextQuote = require('dom-anchor-text-quote') -xpathRange = require('./range') +{ SerializedRange, sniff} = require('./range') # Helper function for throwing common errors missingParameter = (name) -> @@ -28,7 +28,7 @@ class RangeAnchor unless root? then missingParameter('root') unless range? then missingParameter('range') @root = root - @range = xpathRange.sniff(range).normalize(@root) + @range = sniff(range).normalize(@root) @fromRange: (root, range) -> return new RangeAnchor(root, range) @@ -41,7 +41,7 @@ class RangeAnchor end: selector.endContainer endOffset: selector.endOffset } - range = new xpathRange.SerializedRange(data) + range = new SerializedRange(data) return new RangeAnchor(root, range) toRange: () -> diff --git a/src/annotator/anchoring/xpath.coffee b/src/annotator/anchoring/xpath.coffee deleted file mode 100644 index e99b1bb562a..00000000000 --- a/src/annotator/anchoring/xpath.coffee +++ /dev/null @@ -1,106 +0,0 @@ -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# This is a modified copy of -# https://github.com/openannotation/annotator/blob/v1.2.x/src/xpath.coffee - -$ = require('jquery') - -# A simple XPath evaluator using jQuery which can evaluate queries of -simpleXPathJQuery = (relativeRoot) -> - jq = this.map -> - path = '' - elem = this - - while elem?.nodeType == Node.ELEMENT_NODE and elem isnt relativeRoot - tagName = elem.tagName.replace(":", "\\:") - idx = $(elem.parentNode).children(tagName).index(elem) + 1 - - idx = "[#{idx}]" - path = "/" + elem.tagName.toLowerCase() + idx + path - elem = elem.parentNode - - path - - jq.get() - -# A simple XPath evaluator using only standard DOM methods which can -# evaluate queries of the form /tag[index]/tag[index]. -simpleXPathPure = (relativeRoot) -> - - getPathSegment = (node) -> - name = getNodeName node - pos = getNodePosition node - "#{name}[#{pos}]" - - rootNode = relativeRoot - - getPathTo = (node) -> - xpath = ''; - while node != rootNode - unless node? - throw new Error "Called getPathTo on a node which was not a descendant of @rootNode. " + rootNode - xpath = (getPathSegment node) + '/' + xpath - node = node.parentNode - xpath = '/' + xpath - xpath = xpath.replace /\/$/, '' - xpath - - jq = this.map -> - path = getPathTo this - - path - - jq.get() - -findChild = (node, type, index) -> - unless node.hasChildNodes() - throw new Error "XPath error: node has no children!" - children = node.childNodes - found = 0 - for child in children - name = getNodeName child - if name is type - found += 1 - if found is index - return child - throw new Error "XPath error: wanted child not found." - -# Get the node name for use in generating an xpath expression. -getNodeName = (node) -> - nodeName = node.nodeName.toLowerCase() - switch nodeName - when "#text" then return "text()" - when "#comment" then return "comment()" - when "#cdata-section" then return "cdata-section()" - else return nodeName - -# Get the index of the node as it appears in its parent's child list -getNodePosition = (node) -> - pos = 0 - tmp = node - while tmp - if tmp.nodeName is node.nodeName - pos++ - tmp = tmp.previousSibling - pos - -module.exports = { - simpleXPathJQuery, - simpleXPathPure, -} diff --git a/src/annotator/anchoring/xpath-evaluate.js b/src/annotator/anchoring/xpath.js similarity index 100% rename from src/annotator/anchoring/xpath-evaluate.js rename to src/annotator/anchoring/xpath.js diff --git a/src/annotator/guest.js b/src/annotator/guest.js index 7654ee89052..0cdb50a519b 100644 --- a/src/annotator/guest.js +++ b/src/annotator/guest.js @@ -6,9 +6,8 @@ import { Adder } from './adder'; // @ts-expect-error - Module is CoffeeScript import * as htmlAnchoring from './anchoring/html'; -// @ts-expect-error - Module is CoffeeScript -import * as xpathRange from './anchoring/range'; +import { sniff } from './anchoring/range'; import { getHighlightsContainingNode, highlightRange, @@ -380,7 +379,7 @@ export default class Guest extends Delegator { if (!anchor.range) { return anchor; } - const range = xpathRange.sniff(anchor.range); + const range = sniff(anchor.range); const normedRange = range.normalize(root); const highlights = /** @type {AnnotationHighlight[]} */ (highlightRange( normedRange diff --git a/src/annotator/test/highlighter-test.js b/src/annotator/test/highlighter-test.js index b57a84cd516..69aa1aa5ed2 100644 --- a/src/annotator/test/highlighter-test.js +++ b/src/annotator/test/highlighter-test.js @@ -1,6 +1,6 @@ import { createElement, render } from 'preact'; -import Range from '../anchoring/range'; +import { NormalizedRange } from '../anchoring/range'; import { getBoundingClientRect, @@ -54,7 +54,7 @@ function PdfPage({ showPlaceholder = false }) { */ function highlightPdfRange(pageContainer) { const textSpan = pageContainer.querySelector('.testText'); - const r = new Range.NormalizedRange({ + const r = new NormalizedRange({ commonAncestor: textSpan, start: textSpan.childNodes[0], end: textSpan.childNodes[0], @@ -82,7 +82,7 @@ describe('annotator/highlighter', () => { const txt = document.createTextNode('test highlight span'); const el = document.createElement('span'); el.appendChild(txt); - const r = new Range.NormalizedRange({ + const r = new NormalizedRange({ commonAncestor: el, start: txt, end: txt, @@ -107,7 +107,7 @@ describe('annotator/highlighter', () => { el.append(childEl); }); - const r = new Range.NormalizedRange({ + const r = new NormalizedRange({ commonAncestor: el, start: textNodes[0], end: textNodes[textNodes.length - 1], @@ -128,7 +128,7 @@ describe('annotator/highlighter', () => { const el = document.createElement('span'); textNodes.forEach(n => el.append(n)); - const r = new Range.NormalizedRange({ + const r = new NormalizedRange({ commonAncestor: el, start: textNodes[0], end: textNodes[textNodes.length - 1], @@ -149,7 +149,7 @@ describe('annotator/highlighter', () => { el.appendChild(txt); el.appendChild(blank); el.appendChild(txt2); - const r = new Range.NormalizedRange({ + const r = new NormalizedRange({ commonAncestor: el, start: txt, end: txt2, @@ -166,7 +166,7 @@ describe('annotator/highlighter', () => { el.appendChild(document.createTextNode(' ')); el.appendChild(document.createTextNode('')); el.appendChild(document.createTextNode(' ')); - const r = new Range.NormalizedRange({ + const r = new NormalizedRange({ commonAncestor: el, start: el.childNodes[0], end: el.childNodes[2], @@ -348,7 +348,7 @@ describe('annotator/highlighter', () => { for (let i = 0; i < 3; i++) { const span = document.createElement('span'); span.textContent = 'Test text'; - const range = new Range.NormalizedRange({ + const range = new NormalizedRange({ commonAncestor: span, start: span.childNodes[0], end: span.childNodes[0], @@ -431,7 +431,7 @@ describe('annotator/highlighter', () => { describe('getHighlightsContainingNode', () => { const makeRange = (start, end = start) => - new Range.NormalizedRange({ + new NormalizedRange({ commonAncestor: start.parentNode, start, end,