Commit 6943f6dd authored by Robert Knight's avatar Robert Knight

Convert `RangeAnchor` to use `TextRange`

Convert `RangeAnchor` to use `TextRange` and the XPath <-> Node mapping
functions in xpath.js directly, rather than the `SerializedRange` and
`NormalizedRange` classes.

This change will mean that all conversion between text positions and
(text node, offset) points in the client will use the same implementation.
For `RangeAnchor` selectors this conversion is used for the
`startOffset` and `endOffset` fields.

The new implementation also avoids modifying the DOM, unlike the
previous implementation which would sometimes split text nodes. Avoid
DOM modifications during anchoring opens up the possibility of
optimizing anchoring by caching text position <-> text node associations.
parent 0584d344
import { RangeAnchor, TextPositionAnchor, TextQuoteAnchor } from './types';
/**
* @typedef {import("./types").AnyRangeType} AnyRangeType
* @typedef {import('../../types/api').Selector} Selector
*/
......
......@@ -204,6 +204,37 @@ describe('annotator/anchoring/text-range', () => {
});
});
describe('fromCharOffset', () => {
let el;
beforeEach(() => {
el = document.createElement('div');
el.append('hello', 'world');
});
it('returns TextPosition for offset in Text node', () => {
assert.deepEqual(
TextPosition.fromCharOffset(el.firstChild, 1),
TextPosition.fromPoint(el.firstChild, 1)
);
});
it('returns TextPosition for offset in Element node', () => {
assert.deepEqual(
TextPosition.fromCharOffset(el, 5),
new TextPosition(el, 5)
);
});
it('throws for an offset in a non-Text/Element node', () => {
const comment = document.createComment('This is a test');
el.append(comment);
assert.throws(() => {
TextPosition.fromCharOffset(comment, 3);
}, 'Node is not an element or text node');
});
});
describe('fromPoint', () => {
it('returns TextPosition for offset in Text node', () => {
const el = document.createElement('div');
......
......@@ -28,26 +28,11 @@ describe('annotator/anchoring/types', () => {
});
describe('RangeAnchor', () => {
let fakeSniff;
let fakeSerializedRange;
let fakeNormalize;
let fakeSerialize;
let container;
beforeEach(() => {
fakeSerializedRange = sinon.stub();
fakeSerialize = sinon.stub();
fakeNormalize = sinon.stub().returns({
serialize: fakeSerialize,
});
fakeSniff = sinon.stub().returns({
normalize: fakeNormalize,
});
$imports.$mock({
'./range': {
sniff: fakeSniff,
SerializedRange: fakeSerializedRange,
},
});
container = document.createElement('div');
container.innerHTML = `<main><article><empty></empty><p>This is </p><p>a test article</p></article><empty-b></empty-b></main>`;
});
afterEach(() => {
......@@ -56,10 +41,14 @@ describe('annotator/anchoring/types', () => {
describe('#fromRange', () => {
it('returns a RangeAnchor instance', () => {
const anchor = RangeAnchor.fromRange(container, new Range());
assert.calledOnce(fakeNormalize);
const range = new Range();
range.selectNodeContents(container);
const anchor = RangeAnchor.fromRange(container, range);
assert.instanceOf(anchor, RangeAnchor);
assert.deepEqual(anchor.range, fakeNormalize());
assert.equal(anchor.root, container);
assert.equal(anchor.range, range);
});
});
......@@ -67,54 +56,114 @@ describe('annotator/anchoring/types', () => {
it('returns a RangeAnchor instance', () => {
const anchor = RangeAnchor.fromSelector(container, {
type: 'RangeSelector',
startContainer: '/div[1]',
startContainer: '/main[1]/article[1]',
startOffset: 0,
endContainer: '/div[1]',
endContainer: '/main[1]/article[1]/p[2]',
endOffset: 1,
});
assert.calledOnce(fakeSerializedRange);
assert.instanceOf(anchor, RangeAnchor);
assert.equal(anchor.range.toString(), 'This is a');
});
[
// Invalid `startContainer`
[
{
type: 'RangeSelector',
startContainer: '/main[1]/invalid[1]',
startOffset: 0,
endContainer: '/main[1]/article[1]',
endOffset: 1,
},
'Failed to resolve startContainer XPath',
],
// Invalid `endContainer`
[
{
type: 'RangeSelector',
startContainer: '/main[1]/article[1]',
startOffset: 0,
endContainer: '/main[1]/invalid[1]',
endOffset: 1,
},
'Failed to resolve endContainer XPath',
],
// Invalid `startOffset`
[
{
type: 'RangeSelector',
startContainer: '/main[1]/article[1]',
startOffset: 50,
endContainer: '/main[1]/article[1]',
endOffset: 1,
},
'Offset exceeds text length',
],
// Invalid `endOffset`
[
{
type: 'RangeSelector',
startContainer: '/main[1]/article[1]',
startOffset: 0,
endContainer: '/main[1]/article[1]',
endOffset: 50,
},
'Offset exceeds text length',
],
].forEach(([selector, expectedError], i) => {
it(`throws if selector fails to resolve (${i})`, () => {
assert.throws(() => {
RangeAnchor.fromSelector(container, selector);
}, expectedError);
});
});
});
describe('#toRange', () => {
it('returns a normalized range result', () => {
fakeNormalize.returns({
toRange: sinon.stub().returns('normalized range'),
});
const range = new RangeAnchor(container, new Range());
assert.equal(range.toRange(), 'normalized range');
it('returns the range', () => {
const range = new Range();
const anchor = new RangeAnchor(container, range);
assert.equal(anchor.toRange(), range);
});
});
describe('#toSelector', () => {
beforeEach(() => {
fakeSerialize.returns({
start: '/div[1]',
it('returns a valid `RangeSelector` selector', () => {
const range = new Range();
range.setStart(container.querySelector('p'), 0);
range.setEnd(container.querySelector('p:nth-of-type(2)').firstChild, 1);
const anchor = new RangeAnchor(container, range);
assert.deepEqual(anchor.toSelector(), {
type: 'RangeSelector',
startContainer: '/main[1]/article[1]/p[1]',
startOffset: 0,
end: '/div[1]',
endContainer: '/main[1]/article[1]/p[2]',
endOffset: 1,
});
});
function rangeSelectorResult() {
return {
it('returns a selector which references the closest elements to the text', () => {
const range = new Range();
range.setStart(container.querySelector('empty'), 0);
range.setEnd(container.querySelector('empty-b'), 0);
const anchor = new RangeAnchor(container, range);
// Even though the range starts and ends in `empty*` elements, the
// returned selector should reference the elements which most closely
// wrap the text.
assert.deepEqual(anchor.toSelector(), {
type: 'RangeSelector',
startContainer: '/div[1]',
startContainer: '/main[1]/article[1]/p[1]',
startOffset: 0,
endContainer: '/div[1]',
endOffset: 1,
};
}
it('returns a RangeSelector', () => {
const range = new RangeAnchor(container, new Range());
assert.deepEqual(range.toSelector({}), rangeSelectorResult());
endContainer: '/main[1]/article[1]/p[2]',
endOffset: 14,
});
it('returns a RangeSelector if options are missing', () => {
const range = new RangeAnchor(container, new Range());
assert.deepEqual(range.toSelector(), rangeSelectorResult());
});
});
});
......
......@@ -171,12 +171,31 @@ export class TextPosition {
}
/**
* Construct a `TextPosition` representing the range start or end point (node, offset).
* Construct a `TextPosition` that refers to the `offset`th character within
* `node`.
*
* @param {Node} node
* @param {number} offset
* @return {TextPosition}
*/
static fromCharOffset(node, offset) {
switch (node.nodeType) {
case Node.TEXT_NODE:
return TextPosition.fromPoint(node, offset);
case Node.ELEMENT_NODE:
return new TextPosition(/** @type {Element} */ (node), offset);
default:
throw new Error('Node is not an element or text node');
}
}
/**
* Construct a `TextPosition` representing the range start or end point (node, offset).
*
* @param {Node} node - Text or Element node
* @param {number} offset - Offset within the node.
* @return {TextPosition}
*/
static fromPoint(node, offset) {
switch (node.nodeType) {
case Node.TEXT_NODE: {
......
......@@ -8,21 +8,14 @@
* libraries.
*/
import { SerializedRange, sniff } from './range';
import { TextRange } from './text-range';
import { matchQuote } from './match-quote';
import { TextRange, TextPosition } from './text-range';
import { nodeFromXPath, xpathFromNode } from './xpath';
/**
* @typedef {import("./range").BrowserRange} BrowserRange}
* @typedef {import("./range").NormalizedRange} NormalizedRange}
* @typedef {Range|BrowserRange|NormalizedRange|SerializedRange} AnyRangeType
*
* @typedef {import('../../types/api').RangeSelector} RangeSelector
* @typedef {import('../../types/api').TextPositionSelector} TextPositionSelector
* @typedef {import('../../types/api').TextQuoteSelector} TextQuoteSelector
*
* @typedef TextContentNode
* @prop {string} textContent
*/
/**
......@@ -31,16 +24,16 @@ import { matchQuote } from './match-quote';
export class RangeAnchor {
/**
* @param {Node} root - A root element from which to anchor.
* @param {AnyRangeType} range - A range describing the anchor.
* @param {Range} range - A range describing the anchor.
*/
constructor(root, range) {
this.root = root;
this.range = sniff(range).normalize(this.root);
this.range = range;
}
/**
* @param {Node} root - A root element from which to anchor.
* @param {AnyRangeType} range - A range describing the anchor.
* @param {Range} range - A range describing the anchor.
*/
static fromRange(root, range) {
return new RangeAnchor(root, range);
......@@ -49,35 +42,55 @@ export class RangeAnchor {
/**
* Create an anchor from a serialized `RangeSelector` selector.
*
* @param {Node} root - A root element from which to anchor.
* @param {Element} root - A root element from which to anchor.
* @param {RangeSelector} selector
*/
static fromSelector(root, selector) {
const data = {
start: selector.startContainer,
startOffset: selector.startOffset,
end: selector.endContainer,
endOffset: selector.endOffset,
};
const range = new SerializedRange(data);
const startContainer = nodeFromXPath(selector.startContainer, root);
if (!startContainer) {
throw new Error('Failed to resolve startContainer XPath');
}
const endContainer = nodeFromXPath(selector.endContainer, root);
if (!endContainer) {
throw new Error('Failed to resolve endContainer XPath');
}
const startPos = TextPosition.fromCharOffset(
startContainer,
selector.startOffset
);
const endPos = TextPosition.fromCharOffset(
endContainer,
selector.endOffset
);
const range = new TextRange(startPos, endPos).toRange();
return new RangeAnchor(root, range);
}
toRange() {
return this.range.toRange();
return this.range;
}
/**
* @return {RangeSelector}
*/
toSelector() {
const range = this.range.serialize(this.root);
// "Shrink" the range so that it tightly wraps its text. This ensures more
// predictable output for a given text selection.
const normalizedRange = TextRange.fromRange(this.range).toRange();
const textRange = TextRange.fromRange(normalizedRange);
const startContainer = xpathFromNode(textRange.start.element, this.root);
const endContainer = xpathFromNode(textRange.end.element, this.root);
return {
type: 'RangeSelector',
startContainer: range.start,
startOffset: range.startOffset,
endContainer: range.end,
endOffset: range.endOffset,
startContainer,
startOffset: textRange.start.offset,
endContainer,
endOffset: textRange.end.offset,
};
}
}
......
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