Commit 5445c2a3 authored by Robert Knight's avatar Robert Knight

Add `TextPosition` and `TextRange` classes

Add a `TextRange` class which represents a region of the document as an
immutable start/end pair of (element, text position) points. Compared to
a DOM `Range`, this representation of a document region is more robust to
changes in the DOM structure of the region that don't affect the text
content, such as wrapping parts of the range in elements to insert
accessible highlights.
parent 99704aef
import { TextPosition, TextRange } from '../text-range';
import { assertNodesEqual } from '../../../test-util/compare-dom';
const html = `
<main>
<article>
<p>This is <b>a</b> <i>test paragraph</i>.</p>
<!-- Comment in middle of HTML -->
<pre>Some content</pre>
</article>
</main>
`;
/**
* Return all the `Text` descendants of `node`
*
* @param {Node} node
* @return {Text[]}
*/
function textNodes(node) {
const nodes = [];
const iter = document.createNodeIterator(node, NodeFilter.SHOW_TEXT);
let current;
while ((current = iter.nextNode())) {
nodes.push(current);
}
return nodes;
}
describe('annotator/anchoring/text-range', () => {
describe('TextPosition', () => {
let container;
before(() => {
container = document.createElement('div');
container.innerHTML = html;
});
describe('#constructor', () => {
it('throws if offset is negative', () => {
assert.throws(() => {
new TextPosition(container, -1);
}, 'Offset is invalid');
});
});
describe('#resolve', () => {
[
// Position at the start of the element.
{
getPosition: () => 0,
getExpected: () => {
const firstNode = textNodes(container)[0];
return { node: firstNode, offset: 0 };
},
},
// Position in the middle of the element.
{
getPosition: () => container.textContent.indexOf('is a'),
getExpected: () => ({
node: container.querySelector('p').firstChild,
offset: 'This '.length,
}),
},
// Position at the end of the element.
{
getPosition: () => container.textContent.length,
getExpected: () => {
const lastText = textNodes(container).slice(-1)[0];
return { node: lastText, offset: lastText.data.length };
},
},
].forEach(({ getPosition, getExpected }) => {
it('resolves text position to correct node and offset', () => {
const pos = new TextPosition(container, getPosition());
const { node, offset } = pos.resolve();
const { node: expectedNode, offset: expectedOffset } = getExpected();
assertNodesEqual(node, expectedNode);
assert.equal(offset, expectedOffset);
});
});
it('throws if offset exceeds current text content length', () => {
const pos = new TextPosition(
container,
container.textContent.length + 1
);
assert.throws(() => {
pos.resolve();
}, 'Offset exceeds text length');
});
});
describe('fromPoint', () => {
it('returns TextPosition for offset in Text node', () => {
const el = document.createElement('div');
el.append('One', 'two', 'three');
const pos = TextPosition.fromPoint(el.childNodes[1], 0);
assertNodesEqual(pos.element, el);
assert.equal(pos.offset, el.textContent.indexOf('two'));
});
it('returns TextPosition for offset in Element node', () => {
const el = document.createElement('div');
el.innerHTML = '<b>foo</b><i>bar</i><u>baz</u>';
const pos = TextPosition.fromPoint(el, 1);
assertNodesEqual(pos.element, el);
assert.equal(pos.offset, el.textContent.indexOf('bar'));
});
it('throws if node is not a Text or Element', () => {
assert.throws(() => {
TextPosition.fromPoint(document, 0);
}, 'Point is not in an element or text node');
});
it('throws if Text node has no parent', () => {
assert.throws(() => {
TextPosition.fromPoint(document.createTextNode('foo'), 0);
}, 'Text node has no parent');
});
it('throws if node is a Text node and offset is invalid', () => {
const container = document.createElement('div');
container.textContent = 'This is a test';
assert.throws(() => {
TextPosition.fromPoint(container.firstChild, 100);
}, 'Text node offset is out of range');
});
it('throws if Node is an Element node and offset is invalid', () => {
const container = document.createElement('div');
const child = document.createElement('span');
container.appendChild(child);
assert.throws(() => {
TextPosition.fromPoint(container, 2);
}, 'Child node offset is out of range');
});
});
});
describe('TextRange', () => {
describe('#toRange', () => {
it('resolves start and end points', () => {
const el = document.createElement('div');
el.textContent = 'one two three';
const textRange = new TextRange(
new TextPosition(el, 4),
new TextPosition(el, 7)
);
const range = textRange.toRange();
assert.equal(range.toString(), 'two');
});
it('throws if start or end points cannot be resolved', () => {
const el = document.createElement('div');
el.textContent = 'one two three';
const textRange = new TextRange(
new TextPosition(el, 4),
new TextPosition(el, 20)
);
assert.throws(() => {
textRange.toRange();
}, 'Offset exceeds text length');
});
});
describe('fromRange', () => {
it('sets `start` and `end` points of range', () => {
const el = document.createElement('div');
el.textContent = 'one two three';
const range = new Range();
range.selectNodeContents(el);
const textRange = TextRange.fromRange(range);
assert.equal(textRange.start.element, el);
assert.equal(textRange.start.offset, 0);
assert.equal(textRange.end.element, el);
assert.equal(textRange.end.offset, el.textContent.length);
});
it('throws if start or end points cannot be converted to a position', () => {
const range = new Range();
assert.throws(() => {
TextRange.fromRange(range);
}, 'Point is not in an element or text node');
});
});
});
});
/**
* Return the total length of the text of all previous siblings of `node`.
*
* @param {Node} node
*/
function previousSiblingTextLength(node) {
let sibling = node.previousSibling;
let length = 0;
while (sibling) {
length += sibling.textContent?.length ?? 0;
sibling = sibling.previousSibling;
}
return length;
}
/**
* Represents an offset within the text content of an element.
*
* This position can be resolved to a specific descendant node in the current
* DOM subtree of the element using the `resolve` method.
*/
export class TextPosition {
/**
* Construct a `TextPosition` that refers to the text position `offset` within
* the text content of `element`.
*
* @param {Element} element
* @param {number} offset
*/
constructor(element, offset) {
if (offset < 0) {
throw new Error('Offset is invalid');
}
this.element = element;
/** Character offset from the start of the element's `textContent`. */
this.offset = offset;
}
/**
* Resolve the position to a specific text node and offset within that node.
*
* Throws if `this.offset` exceeds the length of the element's text or if
* the element has no text. Offsets at the boundary between two nodes are
* resolved to the start of the node that begins at the boundary.
*
* @return {{ node: Text, offset: number }}
* @throws {RangeError}
*/
resolve() {
const root = this.element;
const nodeIter = /** @type {Document} */ (root.ownerDocument).createNodeIterator(
root,
NodeFilter.SHOW_TEXT
);
let currentNode;
let textNode;
let length = 0;
while ((currentNode = nodeIter.nextNode())) {
textNode = /** @type {Text} */ (currentNode);
if (length + textNode.data.length > this.offset) {
return { node: textNode, offset: this.offset - length };
}
length += textNode.data.length;
}
if (textNode && length === this.offset) {
return { node: textNode, offset: textNode.data.length };
}
throw new RangeError('Offset exceeds text length');
}
/**
* Construct a `TextPosition` representing the range start or end point (node, offset).
*
* @param {Node} node
* @param {number} offset
* @return {TextPosition}
*/
static fromPoint(node, offset) {
switch (node.nodeType) {
case Node.TEXT_NODE: {
if (offset < 0 || offset > /** @type {Text} */ (node).data.length) {
throw new Error('Text node offset is out of range');
}
if (!node.parentElement) {
throw new Error('Text node has no parent');
}
// Get the offset to the start of the parent element.
const textOffset = previousSiblingTextLength(node) + offset;
return new TextPosition(node.parentElement, textOffset);
}
case Node.ELEMENT_NODE: {
if (offset < 0 || offset > node.childNodes.length) {
throw new Error('Child node offset is out of range');
}
// Get the text length before the `offset`th child of element.
let textOffset = 0;
for (let i = 0; i < offset; i++) {
textOffset += node.childNodes[i].textContent?.length ?? 0;
}
return new TextPosition(/** @type {Element} */ (node), textOffset);
}
default:
throw new Error('Point is not in an element or text node');
}
}
}
/**
* Represents a region of a document as a (start, end) pair of `TextPosition` points.
*
* Representing a range in this way allows for changes in the DOM content of the
* range which don't affect its text content, without affecting the text content
* of the range itself.
*/
export class TextRange {
/**
* Construct an immutable `TextRange` from a `start` and `end` point.
*
* @param {TextPosition} start
* @param {TextPosition} end
*/
constructor(start, end) {
this.start = start;
this.end = end;
}
/**
* Resolve the `TextRange` to a DOM range.
*
* May throw if the `start` or `end` positions cannot be resolved to a range.
*
* @return {Range}
*/
toRange() {
const { node: startNode, offset: startOffset } = this.start.resolve();
const { node: endNode, offset: endOffset } = this.end.resolve();
const range = new Range();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
return range;
}
/**
* Convert an exiting DOM `Range` to a `TextRange`
*
* @param {Range} range
* @return {TextRange}
*/
static fromRange(range) {
const start = TextPosition.fromPoint(
range.startContainer,
range.startOffset
);
const end = TextPosition.fromPoint(range.endContainer, range.endOffset);
return new TextRange(start, end);
}
}
/**
* Utilities for comparing DOM nodes and trees etc. in tests or producing
* representations of them.
*/
/**
* Elide `text` if it exceeds `length`.
*
* @param {string} text
* @param {number} length
*/
function elide(text, length) {
return text.length < length ? text : text.slice(0, length) + '...';
}
/**
* Return a string representation of a node for use in asserts and debugging.
*
* @param {Node} node
*/
export function nodeToString(node) {
switch (node.nodeType) {
case Node.TEXT_NODE:
return `[Text: ${elide(node.data, 100)}]`;
case Node.ELEMENT_NODE:
return `[${node.localName} element: ${elide(node.innerHTML, 400)}]`;
case Node.DOCUMENT_NODE:
return '[Document]';
case Node.CDATA_SECTION_NODE:
return '[CData Node]';
default:
return '[Other node]';
}
}
/**
* Compare two nodes and throw if not equal.
*
* This produces more readable output than using `assert.equal(actual, expected)`
* if there is a mismatch.
*/
export function assertNodesEqual(actual, expected) {
if (actual !== expected) {
throw new Error(
`Expected ${nodeToString(actual)} to equal ${nodeToString(expected)}`
);
}
}
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