Commit a5540581 authored by Robert Knight's avatar Robert Knight

Remove jQuery usage from range.js

As part of the removal of jQuery from the annotator, remove usage from
`range.js`:

 - Replace `parents()` method with a small helper function

 - Rewrite `getTextNodes` function to accept and return DOM nodes rather
   than jQuery collections

 - Use `Node.contains` instead of jQuery's `contains` method. Note that there
   is a semantic difference that `contains(nodeA, nodeA)` returns
   `false` whereas `nodeA.contains(nodeA)` returns `true`. This enabled
   simplifying a condition.
parent 74921778
import $ from 'jquery';
import { xpathFromNode, nodeFromXPath } from './xpath'; import { xpathFromNode, nodeFromXPath } from './xpath';
import { import {
getFirstTextNodeNotBefore, getFirstTextNodeNotBefore,
...@@ -7,6 +5,23 @@ import { ...@@ -7,6 +5,23 @@ import {
getTextNodes, getTextNodes,
} from './xpath-util'; } from './xpath-util';
/**
* Return ancestors of `element`, optionally filtered by a CSS `selector`.
*
* @param {Node} node
* @param {string} [selector]
*/
function parents(node, selector) {
const parents = [];
while (node.parentElement) {
if (!selector || node.parentElement.matches(selector)) {
parents.push(node.parentElement);
}
node = node.parentElement;
}
return parents;
}
/** /**
* Creates a wrapper around a range object obtained from a DOMSelection. * Creates a wrapper around a range object obtained from a DOMSelection.
*/ */
...@@ -209,9 +224,8 @@ export class NormalizedRange { ...@@ -209,9 +224,8 @@ export class NormalizedRange {
* Returns updated self or null. * Returns updated self or null.
*/ */
limit(bounds) { limit(bounds) {
const nodes = $.grep( const nodes = this.textNodes().filter(node =>
this.textNodes(), bounds.contains(node.parentNode)
node => node.parentNode === bounds || $.contains(bounds, node.parentNode)
); );
if (!nodes.length) { if (!nodes.length) {
return null; return null;
...@@ -220,10 +234,10 @@ export class NormalizedRange { ...@@ -220,10 +234,10 @@ export class NormalizedRange {
this.start = nodes[0]; this.start = nodes[0];
this.end = nodes[nodes.length - 1]; this.end = nodes[nodes.length - 1];
const startParents = $(this.start).parents(); const startParents = parents(this.start);
for (let parent of $(this.end).parents()) { for (let parent of parents(this.end)) {
if (startParents.index(parent) !== -1) { if (startParents.indexOf(parent) !== -1) {
this.commonAncestor = parent; this.commonAncestor = parent;
break; break;
} }
...@@ -245,19 +259,19 @@ export class NormalizedRange { ...@@ -245,19 +259,19 @@ export class NormalizedRange {
const serialization = (node, isEnd) => { const serialization = (node, isEnd) => {
let origParent; let origParent;
if (ignoreSelector) { if (ignoreSelector) {
origParent = $(node).parents(`:not(${ignoreSelector})`).eq(0); origParent = parents(node, `:not(${ignoreSelector})`)[0];
} else { } else {
origParent = $(node).parent(); origParent = node.parentElement;
} }
const xpath = xpathFromNode(origParent[0], root ?? document); const xpath = xpathFromNode(origParent, root ?? document);
const textNodes = getTextNodes(origParent); const textNodes = getTextNodes(origParent);
// Calculate real offset as the combined length of all the // Calculate real offset as the combined length of all the
// preceding textNode siblings. We include the length of the // preceding textNode siblings. We include the length of the
// node if it's the end node. // node if it's the end node.
const nodes = textNodes.slice(0, textNodes.index(node)); const nodes = textNodes.slice(0, textNodes.indexOf(node));
let offset = 0; let offset = 0;
for (let n of nodes) { for (let n of nodes) {
offset += n.nodeValue.length; offset += n.nodeValue?.length ?? 0;
} }
if (isEnd) { if (isEnd) {
...@@ -300,11 +314,11 @@ export class NormalizedRange { ...@@ -300,11 +314,11 @@ export class NormalizedRange {
* Returns an Array of TextNode instances. * Returns an Array of TextNode instances.
*/ */
textNodes() { textNodes() {
const textNodes = getTextNodes($(this.commonAncestor)); const textNodes = getTextNodes(this.commonAncestor);
const start = textNodes.index(this.start); const start = textNodes.indexOf(this.start);
const end = textNodes.index(this.end); const end = textNodes.indexOf(this.end);
// Return the textNodes that fall between the start and end indexes. // Return the textNodes that fall between the start and end indexes.
return $.makeArray(textNodes.slice(start, +end + 1 || undefined)); return textNodes.slice(start, +end + 1 || undefined);
} }
/** /**
...@@ -382,7 +396,10 @@ export class SerializedRange { ...@@ -382,7 +396,10 @@ export class SerializedRange {
targetOffset--; targetOffset--;
} }
for (let tn of getTextNodes($(node))) { for (let tn of getTextNodes(node)) {
if (tn.nodeValue === null) {
continue;
}
if (length + tn.nodeValue.length > targetOffset) { if (length + tn.nodeValue.length > targetOffset) {
range[p + 'Container'] = tn; range[p + 'Container'] = tn;
range[p + 'Offset'] = this[p + 'Offset'] - length; range[p + 'Offset'] = this[p + 'Offset'] - length;
...@@ -423,17 +440,15 @@ export class SerializedRange { ...@@ -423,17 +440,15 @@ export class SerializedRange {
// Node.compareDocumentPosition() to decide when to set the // Node.compareDocumentPosition() to decide when to set the
// commonAncestorContainer and bail out. // commonAncestorContainer and bail out.
const contains = (a, b) => a.compareDocumentPosition(b) & 16; const contains = (a, b) =>
$(range.startContainer) a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_CONTAINED_BY;
.parents()
.each(function () { for (let parent of parents(range.startContainer)) {
if (contains(this, range.endContainer)) { if (contains(parent, range.endContainer)) {
range.commonAncestorContainer = this; range.commonAncestorContainer = parent;
// bail out of loop break;
return false; }
} }
return true;
});
return new BrowserRange(range).normalize(); return new BrowserRange(range).normalize();
} }
......
import $ from 'jquery';
import { import {
findChild, findChild,
getTextNodes, getTextNodes,
...@@ -61,8 +59,7 @@ describe('annotator/anchoring/xpath-util', () => { ...@@ -61,8 +59,7 @@ describe('annotator/anchoring/xpath-util', () => {
describe('getTextNodes', () => { describe('getTextNodes', () => {
let container; let container;
// Helper (expects a jquery array) const nodeValues = nodes => nodes.map(n => n.nodeValue);
const nodeValues = nodes => nodes.map((i, n) => n.nodeValue);
beforeEach(() => { beforeEach(() => {
container = document.createElement('div'); container = document.createElement('div');
...@@ -75,26 +72,22 @@ describe('annotator/anchoring/xpath-util', () => { ...@@ -75,26 +72,22 @@ describe('annotator/anchoring/xpath-util', () => {
it('finds basic text nodes', () => { it('finds basic text nodes', () => {
container.innerHTML = `<span>text 1</span><span>text 2</span>`; container.innerHTML = `<span>text 1</span><span>text 2</span>`;
const result = getTextNodes($(container)); const result = getTextNodes(container);
assert.deepEqual(nodeValues(result).toArray(), ['text 1', 'text 2']); assert.deepEqual(nodeValues(result), ['text 1', 'text 2']);
}); });
it('finds basic text nodes and whitespace', () => { it('finds basic text nodes and whitespace', () => {
container.innerHTML = `<span>text 1</span> container.innerHTML = `<span>text 1</span>
<span>text 2</span>`; <span>text 2</span>`;
const result = getTextNodes($(container)); const result = getTextNodes(container);
assert.deepEqual(nodeValues(result).toArray(), [ assert.deepEqual(nodeValues(result), ['text 1', '\n ', 'text 2']);
'text 1',
'\n ',
'text 2',
]);
}); });
it('finds basic text nodes and whitespace but ignores comments', () => { it('finds basic text nodes and whitespace but ignores comments', () => {
container.innerHTML = `<span>text 1</span> container.innerHTML = `<span>text 1</span>
<!--span>text 2</span-->`; <!--span>text 2</span-->`;
const result = getTextNodes($(container)); const result = getTextNodes(container);
assert.deepEqual(nodeValues(result).toArray(), ['text 1', '\n ']); assert.deepEqual(nodeValues(result), ['text 1', '\n ']);
}); });
}); });
......
...@@ -85,45 +85,21 @@ export function xpathFromNode(node, root) { ...@@ -85,45 +85,21 @@ export function xpathFromNode(node, root) {
} }
/** /**
* Flatten a nested array structure. * Return all text node descendants of `element`.
* TODO: use Array.prototype.flat and polyfill *
*/ * @param {Element} element
function flatten(array) { * @return {Text[]}
const flatten = ary => {
let flat = [];
ary.forEach(el => {
if (el && Array.isArray(el)) {
flat = flat.concat(flatten(el));
} else {
flat = flat.concat(el);
}
});
return flat;
};
return flatten(array);
}
/**
* Finds all text nodes within the elements in the current collection.
* Returns a new jQuery collection of text nodes.
*/ */
export function getTextNodes(jq) { export function getTextNodes(element) {
const getTextNodes = node => { const nodes = [];
if (node && node.nodeType !== Node.TEXT_NODE) { for (let node of Array.from(element.childNodes)) {
const nodes = []; if (node instanceof Text) {
if (node.nodeType !== Node.COMMENT_NODE) { nodes.push(node);
[...node.childNodes].forEach(child => { } else if (node instanceof Element) {
nodes.push(getTextNodes(child)); nodes.push(...getTextNodes(node));
});
}
return nodes;
} else {
return node;
} }
}; }
return jq.map((index, node) => { return nodes;
return flatten(getTextNodes(node));
});
} }
/** /**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment