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 {
getFirstTextNodeNotBefore,
......@@ -7,6 +5,23 @@ import {
getTextNodes,
} 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.
*/
......@@ -209,9 +224,8 @@ export class NormalizedRange {
* Returns updated self or null.
*/
limit(bounds) {
const nodes = $.grep(
this.textNodes(),
node => node.parentNode === bounds || $.contains(bounds, node.parentNode)
const nodes = this.textNodes().filter(node =>
bounds.contains(node.parentNode)
);
if (!nodes.length) {
return null;
......@@ -220,10 +234,10 @@ export class NormalizedRange {
this.start = nodes[0];
this.end = nodes[nodes.length - 1];
const startParents = $(this.start).parents();
const startParents = parents(this.start);
for (let parent of $(this.end).parents()) {
if (startParents.index(parent) !== -1) {
for (let parent of parents(this.end)) {
if (startParents.indexOf(parent) !== -1) {
this.commonAncestor = parent;
break;
}
......@@ -245,19 +259,19 @@ export class NormalizedRange {
const serialization = (node, isEnd) => {
let origParent;
if (ignoreSelector) {
origParent = $(node).parents(`:not(${ignoreSelector})`).eq(0);
origParent = parents(node, `:not(${ignoreSelector})`)[0];
} else {
origParent = $(node).parent();
origParent = node.parentElement;
}
const xpath = xpathFromNode(origParent[0], root ?? document);
const xpath = xpathFromNode(origParent, root ?? document);
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));
const nodes = textNodes.slice(0, textNodes.indexOf(node));
let offset = 0;
for (let n of nodes) {
offset += n.nodeValue.length;
offset += n.nodeValue?.length ?? 0;
}
if (isEnd) {
......@@ -300,11 +314,11 @@ export class NormalizedRange {
* Returns an Array of TextNode instances.
*/
textNodes() {
const textNodes = getTextNodes($(this.commonAncestor));
const start = textNodes.index(this.start);
const end = textNodes.index(this.end);
const textNodes = getTextNodes(this.commonAncestor);
const start = textNodes.indexOf(this.start);
const end = textNodes.indexOf(this.end);
// 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 {
targetOffset--;
}
for (let tn of getTextNodes($(node))) {
for (let tn of getTextNodes(node)) {
if (tn.nodeValue === null) {
continue;
}
if (length + tn.nodeValue.length > targetOffset) {
range[p + 'Container'] = tn;
range[p + 'Offset'] = this[p + 'Offset'] - length;
......@@ -423,17 +440,15 @@ export class SerializedRange {
// 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;
});
const contains = (a, b) =>
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_CONTAINED_BY;
for (let parent of parents(range.startContainer)) {
if (contains(parent, range.endContainer)) {
range.commonAncestorContainer = parent;
break;
}
}
return new BrowserRange(range).normalize();
}
......
import $ from 'jquery';
import {
findChild,
getTextNodes,
......@@ -61,8 +59,7 @@ describe('annotator/anchoring/xpath-util', () => {
describe('getTextNodes', () => {
let container;
// Helper (expects a jquery array)
const nodeValues = nodes => nodes.map((i, n) => n.nodeValue);
const nodeValues = nodes => nodes.map(n => n.nodeValue);
beforeEach(() => {
container = document.createElement('div');
......@@ -75,26 +72,22 @@ describe('annotator/anchoring/xpath-util', () => {
it('finds basic text nodes', () => {
container.innerHTML = `<span>text 1</span><span>text 2</span>`;
const result = getTextNodes($(container));
assert.deepEqual(nodeValues(result).toArray(), ['text 1', 'text 2']);
const result = getTextNodes(container);
assert.deepEqual(nodeValues(result), ['text 1', 'text 2']);
});
it('finds basic text nodes and whitespace', () => {
container.innerHTML = `<span>text 1</span>
<span>text 2</span>`;
const result = getTextNodes($(container));
assert.deepEqual(nodeValues(result).toArray(), [
'text 1',
'\n ',
'text 2',
]);
const result = getTextNodes(container);
assert.deepEqual(nodeValues(result), ['text 1', '\n ', 'text 2']);
});
it('finds basic text nodes and whitespace but ignores comments', () => {
container.innerHTML = `<span>text 1</span>
<!--span>text 2</span-->`;
const result = getTextNodes($(container));
assert.deepEqual(nodeValues(result).toArray(), ['text 1', '\n ']);
const result = getTextNodes(container);
assert.deepEqual(nodeValues(result), ['text 1', '\n ']);
});
});
......
......@@ -85,45 +85,21 @@ export function xpathFromNode(node, root) {
}
/**
* Flatten a nested array structure.
* TODO: use Array.prototype.flat and polyfill
*/
function flatten(array) {
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.
* Return all text node descendants of `element`.
*
* @param {Element} element
* @return {Text[]}
*/
export function getTextNodes(jq) {
const getTextNodes = node => {
if (node && node.nodeType !== Node.TEXT_NODE) {
const nodes = [];
if (node.nodeType !== Node.COMMENT_NODE) {
[...node.childNodes].forEach(child => {
nodes.push(getTextNodes(child));
});
}
return nodes;
} else {
return node;
export function getTextNodes(element) {
const nodes = [];
for (let node of Array.from(element.childNodes)) {
if (node instanceof Text) {
nodes.push(node);
} else if (node instanceof Element) {
nodes.push(...getTextNodes(node));
}
};
return jq.map((index, node) => {
return flatten(getTextNodes(node));
});
}
return nodes;
}
/**
......
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