Commit 1920e374 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Convert text-range to TS

parent 19486a61
import { import { TextPosition, TextRange, ResolveDirection } from '../text-range';
TextPosition,
TextRange,
RESOLVE_FORWARDS,
RESOLVE_BACKWARDS,
} from '../text-range';
import { assertNodesEqual } from '../../../test-util/compare-dom'; import { assertNodesEqual } from '../../../test-util/compare-dom';
...@@ -114,13 +109,15 @@ describe('annotator/anchoring/text-range', () => { ...@@ -114,13 +109,15 @@ describe('annotator/anchoring/text-range', () => {
}); });
}); });
describe('when `direction` is `RESOLVE_FORWARDS`', () => { describe('when resolve `direction` is `FORWARDS`', () => {
it('resolves to next text node if needed', () => { it('resolves to next text node if needed', () => {
const el = document.createElement('div'); const el = document.createElement('div');
el.innerHTML = '<b></b>bar'; el.innerHTML = '<b></b>bar';
const pos = new TextPosition(el.querySelector('b'), 0); const pos = new TextPosition(el.querySelector('b'), 0);
const resolved = pos.resolve({ direction: RESOLVE_FORWARDS }); const resolved = pos.resolve({
direction: ResolveDirection.FORWARDS,
});
assert.equal(resolved.node, el.childNodes[1]); assert.equal(resolved.node, el.childNodes[1]);
assert.equal(resolved.offset, 0); assert.equal(resolved.offset, 0);
...@@ -130,18 +127,20 @@ describe('annotator/anchoring/text-range', () => { ...@@ -130,18 +127,20 @@ describe('annotator/anchoring/text-range', () => {
const el = document.createElement('div'); const el = document.createElement('div');
const pos = new TextPosition(el, 0); const pos = new TextPosition(el, 0);
assert.throws(() => { assert.throws(() => {
pos.resolve({ direction: RESOLVE_FORWARDS }); pos.resolve({ direction: ResolveDirection.FORWARDS });
}); });
}); });
}); });
describe('when `direction` is `RESOLVE_BACKWARDS`', () => { describe('when resolve `direction` is `BACKWARDS`', () => {
it('resolves to previous text node if needed', () => { it('resolves to previous text node if needed', () => {
const el = document.createElement('div'); const el = document.createElement('div');
el.innerHTML = 'bar<b></b>'; el.innerHTML = 'bar<b></b>';
const pos = new TextPosition(el.querySelector('b'), 0); const pos = new TextPosition(el.querySelector('b'), 0);
const resolved = pos.resolve({ direction: RESOLVE_BACKWARDS }); const resolved = pos.resolve({
direction: ResolveDirection.BACKWARDS,
});
assert.equal(resolved.node, el.childNodes[0]); assert.equal(resolved.node, el.childNodes[0]);
assert.equal(resolved.offset, el.childNodes[0].data.length); assert.equal(resolved.offset, el.childNodes[0].data.length);
...@@ -151,7 +150,7 @@ describe('annotator/anchoring/text-range', () => { ...@@ -151,7 +150,7 @@ describe('annotator/anchoring/text-range', () => {
const el = document.createElement('div'); const el = document.createElement('div');
const pos = new TextPosition(el, 0); const pos = new TextPosition(el, 0);
assert.throws(() => { assert.throws(() => {
pos.resolve({ direction: RESOLVE_BACKWARDS }); pos.resolve({ direction: ResolveDirection.BACKWARDS });
}); });
}); });
}); });
......
/** /**
* Return the combined length of text nodes contained in `node`. * Return the combined length of text nodes contained in `node`.
*
* @param {Node} node
*/ */
function nodeTextLength(node) { function nodeTextLength(node: Node): number {
switch (node.nodeType) { switch (node.nodeType) {
case Node.ELEMENT_NODE: case Node.ELEMENT_NODE:
case Node.TEXT_NODE: case Node.TEXT_NODE:
// nb. `textContent` excludes text in comments and processing instructions // nb. `textContent` excludes text in comments and processing instructions
// when called on a parent element, so we don't need to subtract that here. // when called on a parent element, so we don't need to subtract that here.
return /** @type {string} */ (node.textContent).length; return node.textContent?.length ?? 0;
default: default:
return 0; return 0;
} }
...@@ -18,10 +16,8 @@ function nodeTextLength(node) { ...@@ -18,10 +16,8 @@ function nodeTextLength(node) {
/** /**
* Return the total length of the text of all previous siblings of `node`. * Return the total length of the text of all previous siblings of `node`.
*
* @param {Node} node
*/ */
function previousSiblingsTextLength(node) { function previousSiblingsTextLength(node: Node): number {
let sibling = node.previousSibling; let sibling = node.previousSibling;
let length = 0; let length = 0;
while (sibling) { while (sibling) {
...@@ -32,33 +28,37 @@ function previousSiblingsTextLength(node) { ...@@ -32,33 +28,37 @@ function previousSiblingsTextLength(node) {
} }
/** /**
* Resolve one or more character offsets within an element to (text node, position) * Resolve one or more character offsets within an element to (text node,
* pairs. * position) pairs.
* *
* @param {Element} element * @param element
* @param {number[]} offsets - Offsets, which must be sorted in ascending order * @param offsets - Offsets, which must be sorted in ascending order
* @return {{ node: Text, offset: number }[]} * @throws {RangeError}
*/ */
function resolveOffsets(element, ...offsets) { function resolveOffsets(
element: Element,
...offsets: number[]
): Array<{ node: Text; offset: number }> {
let nextOffset = offsets.shift(); let nextOffset = offsets.shift();
const nodeIter = /** @type {Document} */ ( const nodeIter = element.ownerDocument.createNodeIterator(
element.ownerDocument element,
).createNodeIterator(element, NodeFilter.SHOW_TEXT); NodeFilter.SHOW_TEXT
);
const results = []; const results = [];
let currentNode = nodeIter.nextNode(); let currentNode = nodeIter.nextNode() as Text | null;
let textNode; let textNode;
let length = 0; let length = 0;
// Find the text node containing the `nextOffset`th character from the start // Find the text node containing the `nextOffset`th character from the start
// of `element`. // of `element`.
while (nextOffset !== undefined && currentNode) { while (nextOffset !== undefined && currentNode) {
textNode = /** @type {Text} */ (currentNode); textNode = currentNode;
if (length + textNode.data.length > nextOffset) { if (length + textNode.data.length > nextOffset) {
results.push({ node: textNode, offset: nextOffset - length }); results.push({ node: textNode, offset: nextOffset - length });
nextOffset = offsets.shift(); nextOffset = offsets.shift();
} else { } else {
currentNode = nodeIter.nextNode(); currentNode = nodeIter.nextNode() as Text | null;
length += textNode.data.length; length += textNode.data.length;
} }
} }
...@@ -76,8 +76,14 @@ function resolveOffsets(element, ...offsets) { ...@@ -76,8 +76,14 @@ function resolveOffsets(element, ...offsets) {
return results; return results;
} }
export let RESOLVE_FORWARDS = 1; /**
export let RESOLVE_BACKWARDS = 2; * When resolving a TextPosition, specifies the direction to search for the
* nearest text node if `offset` is `0` and the element has no text.
*/
export enum ResolveDirection {
FORWARDS = 1,
BACKWARDS,
}
/** /**
* Represents an offset within the text content of an element. * Represents an offset within the text content of an element.
...@@ -86,14 +92,10 @@ export let RESOLVE_BACKWARDS = 2; ...@@ -86,14 +92,10 @@ export let RESOLVE_BACKWARDS = 2;
* DOM subtree of the element using the `resolve` method. * DOM subtree of the element using the `resolve` method.
*/ */
export class TextPosition { export class TextPosition {
/** public element: Element;
* Construct a `TextPosition` that refers to the text position `offset` within public offset: number;
* the text content of `element`.
* constructor(element: Element, offset: number) {
* @param {Element} element
* @param {number} offset
*/
constructor(element, offset) {
if (offset < 0) { if (offset < 0) {
throw new Error('Offset is invalid'); throw new Error('Offset is invalid');
} }
...@@ -109,10 +111,9 @@ export class TextPosition { ...@@ -109,10 +111,9 @@ export class TextPosition {
* Return a copy of this position with offset relative to a given ancestor * Return a copy of this position with offset relative to a given ancestor
* element. * element.
* *
* @param {Element} parent - Ancestor of `this.element` * @param parent - Ancestor of `this.element`
* @return {TextPosition}
*/ */
relativeTo(parent) { relativeTo(parent: Element): TextPosition {
if (!parent.contains(this.element)) { if (!parent.contains(this.element)) {
throw new Error('Parent is not an ancestor of current element'); throw new Error('Parent is not an ancestor of current element');
} }
...@@ -121,7 +122,7 @@ export class TextPosition { ...@@ -121,7 +122,7 @@ export class TextPosition {
let offset = this.offset; let offset = this.offset;
while (el !== parent) { while (el !== parent) {
offset += previousSiblingsTextLength(el); offset += previousSiblingsTextLength(el);
el = /** @type {Element} */ (el.parentElement); el = el.parentElement!;
} }
return new TextPosition(el, offset); return new TextPosition(el, offset);
...@@ -137,15 +138,17 @@ export class TextPosition { ...@@ -137,15 +138,17 @@ export class TextPosition {
* Offsets at the boundary between two nodes are resolved to the start of the * Offsets at the boundary between two nodes are resolved to the start of the
* node that begins at the boundary. * node that begins at the boundary.
* *
* @param {object} [options] * @param options.direction - Specifies in which direction to search for the
* @param {RESOLVE_FORWARDS|RESOLVE_BACKWARDS} [options.direction] - * nearest text node if `this.offset` is `0` and
* Specifies in which direction to search for the nearest text node if * `this.element` has no text. If not specified an
* `this.offset` is `0` and `this.element` has no text. If not specified * error is thrown.
* an error is thrown. *
* @return {{ node: Text, offset: number }}
* @throws {RangeError} * @throws {RangeError}
*/ */
resolve(options = {}) { resolve(options: { direction?: ResolveDirection } = {}): {
node: Text;
offset: number;
} {
try { try {
return resolveOffsets(this.element, this.offset)[0]; return resolveOffsets(this.element, this.offset)[0];
} catch (err) { } catch (err) {
...@@ -155,10 +158,10 @@ export class TextPosition { ...@@ -155,10 +158,10 @@ export class TextPosition {
NodeFilter.SHOW_TEXT NodeFilter.SHOW_TEXT
); );
tw.currentNode = this.element; tw.currentNode = this.element;
const forwards = options.direction === RESOLVE_FORWARDS; const forwards = options.direction === ResolveDirection.FORWARDS;
const text = /** @type {Text|null} */ ( const text = forwards
forwards ? tw.nextNode() : tw.previousNode() ? (tw.nextNode() as Text | null)
); : (tw.previousNode() as Text | null);
if (!text) { if (!text) {
throw err; throw err;
} }
...@@ -172,17 +175,13 @@ export class TextPosition { ...@@ -172,17 +175,13 @@ export class TextPosition {
/** /**
* Construct a `TextPosition` that refers to the `offset`th character within * Construct a `TextPosition` that refers to the `offset`th character within
* `node`. * `node`.
*
* @param {Node} node
* @param {number} offset
* @return {TextPosition}
*/ */
static fromCharOffset(node, offset) { static fromCharOffset(node: Node, offset: number): TextPosition {
switch (node.nodeType) { switch (node.nodeType) {
case Node.TEXT_NODE: case Node.TEXT_NODE:
return TextPosition.fromPoint(node, offset); return TextPosition.fromPoint(node, offset);
case Node.ELEMENT_NODE: case Node.ELEMENT_NODE:
return new TextPosition(/** @type {Element} */ (node), offset); return new TextPosition(node as Element, offset);
default: default:
throw new Error('Node is not an element or text node'); throw new Error('Node is not an element or text node');
} }
...@@ -191,14 +190,13 @@ export class TextPosition { ...@@ -191,14 +190,13 @@ export class TextPosition {
/** /**
* Construct a `TextPosition` representing the range start or end point (node, offset). * Construct a `TextPosition` representing the range start or end point (node, offset).
* *
* @param {Node} node - Text or Element node * @param node
* @param {number} offset - Offset within the node. * @param offset - Offset within the node
* @return {TextPosition}
*/ */
static fromPoint(node, offset) { static fromPoint(node: Node, offset: number): TextPosition {
switch (node.nodeType) { switch (node.nodeType) {
case Node.TEXT_NODE: { case Node.TEXT_NODE: {
if (offset < 0 || offset > /** @type {Text} */ (node).data.length) { if (offset < 0 || offset > (node as Text).data.length) {
throw new Error('Text node offset is out of range'); throw new Error('Text node offset is out of range');
} }
...@@ -222,7 +220,7 @@ export class TextPosition { ...@@ -222,7 +220,7 @@ export class TextPosition {
textOffset += nodeTextLength(node.childNodes[i]); textOffset += nodeTextLength(node.childNodes[i]);
} }
return new TextPosition(/** @type {Element} */ (node), textOffset); return new TextPosition(node as Element, textOffset);
} }
default: default:
throw new Error('Point is not in an element or text node'); throw new Error('Point is not in an element or text node');
...@@ -238,24 +236,20 @@ export class TextPosition { ...@@ -238,24 +236,20 @@ export class TextPosition {
* of the range itself. * of the range itself.
*/ */
export class TextRange { export class TextRange {
/** public start: TextPosition;
* Construct an immutable `TextRange` from a `start` and `end` point. public end: TextPosition;
*
* @param {TextPosition} start constructor(start: TextPosition, end: TextPosition) {
* @param {TextPosition} end
*/
constructor(start, end) {
this.start = start; this.start = start;
this.end = end; this.end = end;
} }
/** /**
* Return a copy of this range with start and end positions relative to a * Create a new TextRange whose `start` and `end` are computed relative to
* given ancestor. See `TextPosition.relativeTo`. * `element`. `element` must be an ancestor of both `start.element` and
* * `end.element`.
* @param {Element} element
*/ */
relativeTo(element) { relativeTo(element: Element): TextRange {
return new TextRange( return new TextRange(
this.start.relativeTo(element), this.start.relativeTo(element),
this.end.relativeTo(element) this.end.relativeTo(element)
...@@ -263,17 +257,15 @@ export class TextRange { ...@@ -263,17 +257,15 @@ export class TextRange {
} }
/** /**
* Resolve the `TextRange` to a DOM range. * Resolve this TextRange to a (DOM) Range.
* *
* The resulting DOM Range will always start and end in a `Text` node. * The resulting DOM Range will always start and end in a `Text` node.
* Hence `TextRange.fromRange(range).toRange()` can be used to "shrink" a * Hence `TextRange.fromRange(range).toRange()` can be used to "shrink" a
* range to the text it contains. * range to the text it contains.
* *
* May throw if the `start` or `end` positions cannot be resolved to a range. * May throw if the `start` or `end` positions cannot be resolved to a range.
*
* @return {Range}
*/ */
toRange() { toRange(): Range {
let start; let start;
let end; let end;
...@@ -288,8 +280,10 @@ export class TextRange { ...@@ -288,8 +280,10 @@ export class TextRange {
this.end.offset this.end.offset
); );
} else { } else {
start = this.start.resolve({ direction: RESOLVE_FORWARDS }); start = this.start.resolve({
end = this.end.resolve({ direction: RESOLVE_BACKWARDS }); direction: ResolveDirection.FORWARDS,
});
end = this.end.resolve({ direction: ResolveDirection.BACKWARDS });
} }
const range = new Range(); const range = new Range();
...@@ -299,12 +293,9 @@ export class TextRange { ...@@ -299,12 +293,9 @@ export class TextRange {
} }
/** /**
* Convert an existing DOM `Range` to a `TextRange` * Create a TextRange from a (DOM) Range
*
* @param {Range} range
* @return {TextRange}
*/ */
static fromRange(range) { static fromRange(range: Range): TextRange {
const start = TextPosition.fromPoint( const start = TextPosition.fromPoint(
range.startContainer, range.startContainer,
range.startOffset range.startOffset
...@@ -314,13 +305,10 @@ export class TextRange { ...@@ -314,13 +305,10 @@ export class TextRange {
} }
/** /**
* Return a `TextRange` from the `start`th to `end`th characters in `root`. * Create a TextRange representing the `start`th to `end`th characters in
* * `root`
* @param {Element} root
* @param {number} start
* @param {number} end
*/ */
static fromOffsets(root, start, end) { static fromOffsets(root: Element, start: number, end: number): TextRange {
return new TextRange( return new TextRange(
new TextPosition(root, start), new TextPosition(root, start),
new TextPosition(root, end) new TextPosition(root, end)
......
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