Commit 3d36ecee authored by Robert Knight's avatar Robert Knight

Handle start or end element with no text in `TextRange.toRange`

When resolving a `TextRange` to a DOM `Range` there is an edge case
where the `start` or `end` position have an `offset` of `0` and the
element contains no text nodes.

Before this commit `toRange` would throw an `Offset exceeds text length`
error. A more useful behavior though is:

 - For the `start` position, to resolve it to the start of the next text node after
   `this.start.element`

 - For the `end` position, to resolve it to the end of the previous text
   node before `this.end.element`

This commit implements this behavior by first adding a `dir` option to
`TextPosition.resolve` to control what happens when the position's
offset is 0 and the element has no text and then specifying this option
when calling `this.{start, end}.resolve` in `TextRange.toRange`.
parent 6550f1cf
import { TextPosition, TextRange } from '../text-range'; import {
TextPosition,
TextRange,
RESOLVE_FORWARDS,
RESOLVE_BACKWARDS,
} from '../text-range';
import { assertNodesEqual } from '../../../test-util/compare-dom'; import { assertNodesEqual } from '../../../test-util/compare-dom';
...@@ -100,6 +105,56 @@ describe('annotator/anchoring/text-range', () => { ...@@ -100,6 +105,56 @@ describe('annotator/anchoring/text-range', () => {
pos.resolve(); pos.resolve();
}, 'Offset exceeds text length'); }, 'Offset exceeds text length');
}); });
it('throws if offset is 0 and `dir` option is not specified', () => {
const el = document.createElement('div');
const pos = new TextPosition(el, 0);
assert.throws(() => {
pos.resolve();
});
});
describe('when `dir` is `RESOLVE_FORWARDS`', () => {
it('resolves to next text node if needed', () => {
const el = document.createElement('div');
el.innerHTML = '<b></b>bar';
const pos = new TextPosition(el.querySelector('b'), 0);
const resolved = pos.resolve({ dir: RESOLVE_FORWARDS });
assert.equal(resolved.node, el.childNodes[1]);
assert.equal(resolved.offset, 0);
});
it('throws if there is no next text node', () => {
const el = document.createElement('div');
const pos = new TextPosition(el, 0);
assert.throws(() => {
pos.resolve({ dir: RESOLVE_FORWARDS });
});
});
});
describe('when `dir` is `RESOLVE_BACKWARDS`', () => {
it('resolves to previous text node if needed', () => {
const el = document.createElement('div');
el.innerHTML = 'bar<b></b>';
const pos = new TextPosition(el.querySelector('b'), 0);
const resolved = pos.resolve({ dir: RESOLVE_BACKWARDS });
assert.equal(resolved.node, el.childNodes[0]);
assert.equal(resolved.offset, el.childNodes[0].data.length);
});
it('throws if there is no previous text node', () => {
const el = document.createElement('div');
const pos = new TextPosition(el, 0);
assert.throws(() => {
pos.resolve({ dir: RESOLVE_BACKWARDS });
});
});
});
}); });
describe('#relativeTo', () => { describe('#relativeTo', () => {
...@@ -273,7 +328,7 @@ describe('annotator/anchoring/text-range', () => { ...@@ -273,7 +328,7 @@ describe('annotator/anchoring/text-range', () => {
assert.equal(range.toString(), 'foobar'); assert.equal(range.toString(), 'foobar');
}); });
it('throws if start point cannot be resolved', () => { it('throws if start point in same element as end point cannot be resolved', () => {
const el = document.createElement('div'); const el = document.createElement('div');
el.textContent = 'one two three'; el.textContent = 'one two three';
...@@ -287,7 +342,7 @@ describe('annotator/anchoring/text-range', () => { ...@@ -287,7 +342,7 @@ describe('annotator/anchoring/text-range', () => {
}, 'Offset exceeds text length'); }, 'Offset exceeds text length');
}); });
it('throws if end point cannot be resolved', () => { it('throws if end point in same element as start point cannot be resolved', () => {
const el = document.createElement('div'); const el = document.createElement('div');
el.textContent = 'one two three'; el.textContent = 'one two three';
...@@ -300,6 +355,28 @@ describe('annotator/anchoring/text-range', () => { ...@@ -300,6 +355,28 @@ describe('annotator/anchoring/text-range', () => {
textRange.toRange(); textRange.toRange();
}, 'Offset exceeds text length'); }, 'Offset exceeds text length');
}); });
it('handles start or end point in element with no text', () => {
const el = document.createElement('div');
el.innerHTML = '<b></b><i>Foobar</i><u></u>';
const textRange = new TextRange(
new TextPosition(el.querySelector('b'), 0),
new TextPosition(el.querySelector('u'), 0)
);
const range = textRange.toRange();
assert.equal(range.toString(), 'Foobar');
// Start position is not in `textRange.start.element` but the subsequent
// text node.
assert.isTrue(
range.startContainer === el.querySelector('i').firstChild
);
// End position is not in `textRange.end.element` but the preceding
// text node.
assert.isTrue(range.endContainer === el.querySelector('i').firstChild);
});
}); });
describe('#relativeTo', () => { describe('#relativeTo', () => {
......
...@@ -77,6 +77,9 @@ function resolveOffsets(element, ...offsets) { ...@@ -77,6 +77,9 @@ function resolveOffsets(element, ...offsets) {
return results; return results;
} }
export let RESOLVE_FORWARDS = 1;
export let RESOLVE_BACKWARDS = 2;
/** /**
* Represents an offset within the text content of an element. * Represents an offset within the text content of an element.
* *
...@@ -128,15 +131,43 @@ export class TextPosition { ...@@ -128,15 +131,43 @@ export class TextPosition {
/** /**
* Resolve the position to a specific text node and offset within that node. * 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 * Throws if `this.offset` exceeds the length of the element's text. In the
* the element has no text. Offsets at the boundary between two nodes are * case where the element has no text and `this.offset` is 0, the `dir` option
* resolved to the start of the node that begins at the boundary. * determines what happens.
*
* Offsets at the boundary between two nodes are resolved to the start of the
* node that begins at the boundary.
* *
* @param {Object} [options]
* @param {RESOLVE_FORWARDS|RESOLVE_BACKWARDS} [options.dir] -
* Specifies in which direction to search for the nearest text node if
* `this.offset` is `0` and `this.element` has no text. If not specified
* an error is thrown.
* @return {{ node: Text, offset: number }} * @return {{ node: Text, offset: number }}
* @throws {RangeError} * @throws {RangeError}
*/ */
resolve() { resolve(options = {}) {
try {
return resolveOffsets(this.element, this.offset)[0]; return resolveOffsets(this.element, this.offset)[0];
} catch (err) {
if (this.offset === 0 && options.dir !== undefined) {
const tw = document.createTreeWalker(
this.element.getRootNode(),
NodeFilter.SHOW_TEXT
);
tw.currentNode = this.element;
const forwards = options.dir === RESOLVE_FORWARDS;
const text = /** @type {Text|null} */ (forwards
? tw.nextNode()
: tw.previousNode());
if (!text) {
throw err;
}
return { node: text, offset: forwards ? 0 : text.data.length };
} else {
throw err;
}
}
} }
/** /**
...@@ -239,8 +270,8 @@ export class TextRange { ...@@ -239,8 +270,8 @@ export class TextRange {
this.end.offset this.end.offset
); );
} else { } else {
start = this.start.resolve(); start = this.start.resolve({ dir: RESOLVE_FORWARDS });
end = this.end.resolve(); end = this.end.resolve({ dir: RESOLVE_BACKWARDS });
} }
const range = new Range(); const range = new Range();
......
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