@readium/navigator-html-injectables 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.MD +13 -0
  2. package/dist/index.js +3144 -0
  3. package/dist/index.umd.cjs +69 -0
  4. package/package.json +58 -0
  5. package/src/Loader.ts +85 -0
  6. package/src/comms/comms.ts +168 -0
  7. package/src/comms/index.ts +3 -0
  8. package/src/comms/keys.ts +42 -0
  9. package/src/comms/mid.ts +5 -0
  10. package/src/helpers/animation.ts +5 -0
  11. package/src/helpers/css.ts +16 -0
  12. package/src/helpers/document.ts +43 -0
  13. package/src/helpers/dom.ts +126 -0
  14. package/src/helpers/locator.ts +67 -0
  15. package/src/helpers/rect.ts +301 -0
  16. package/src/helpers/scrollSnapperHelper.ts +43 -0
  17. package/src/index.ts +3 -0
  18. package/src/modules/Decorator.ts +462 -0
  19. package/src/modules/Module.ts +23 -0
  20. package/src/modules/ModuleLibrary.ts +44 -0
  21. package/src/modules/Peripherals.ts +148 -0
  22. package/src/modules/index.ts +3 -0
  23. package/src/modules/setup/FixedSetup.ts +73 -0
  24. package/src/modules/setup/ReflowableSetup.ts +65 -0
  25. package/src/modules/setup/Setup.ts +131 -0
  26. package/src/modules/snapper/ColumnSnapper.ts +468 -0
  27. package/src/modules/snapper/ScrollSnapper.ts +168 -0
  28. package/src/modules/snapper/Snapper.ts +49 -0
  29. package/src/vendor/approx-string-match/LICENSE +21 -0
  30. package/src/vendor/approx-string-match/README.MD +1 -0
  31. package/src/vendor/approx-string-match/index.ts +362 -0
  32. package/src/vendor/hypothesis/README.MD +1 -0
  33. package/src/vendor/hypothesis/anchoring/api-types.ts +309 -0
  34. package/src/vendor/hypothesis/anchoring/html.ts +134 -0
  35. package/src/vendor/hypothesis/anchoring/match-quote.ts +163 -0
  36. package/src/vendor/hypothesis/anchoring/placeholder.ts +59 -0
  37. package/src/vendor/hypothesis/anchoring/text-range.ts +327 -0
  38. package/src/vendor/hypothesis/anchoring/trim-range.ts +220 -0
  39. package/src/vendor/hypothesis/anchoring/types.ts +377 -0
  40. package/src/vendor/hypothesis/anchoring/xpath.ts +164 -0
  41. package/src/vendor/hypothesis/tsconfig.json +33 -0
  42. package/src/vendor/hypothesis/types/shared.ts +40 -0
  43. package/types/src/Loader.d.ts +33 -0
  44. package/types/src/comms/comms.d.ts +40 -0
  45. package/types/src/comms/index.d.ts +3 -0
  46. package/types/src/comms/keys.d.ts +2 -0
  47. package/types/src/comms/mid.d.ts +1 -0
  48. package/types/src/helpers/animation.d.ts +1 -0
  49. package/types/src/helpers/css.d.ts +4 -0
  50. package/types/src/helpers/document.d.ts +8 -0
  51. package/types/src/helpers/dom.d.ts +15 -0
  52. package/types/src/helpers/locator.d.ts +2 -0
  53. package/types/src/helpers/rect.d.ts +10 -0
  54. package/types/src/helpers/scrollSnapperHelper.d.ts +5 -0
  55. package/types/src/index.d.ts +3 -0
  56. package/types/src/modules/Decorator.d.ts +43 -0
  57. package/types/src/modules/Module.d.ts +11 -0
  58. package/types/src/modules/ModuleLibrary.d.ts +5 -0
  59. package/types/src/modules/Peripherals.d.ts +39 -0
  60. package/types/src/modules/ReflowablePeripherals.d.ts +37 -0
  61. package/types/src/modules/index.d.ts +3 -0
  62. package/types/src/modules/setup/FixedSetup.d.ts +9 -0
  63. package/types/src/modules/setup/ReflowableSetup.d.ts +9 -0
  64. package/types/src/modules/setup/Setup.d.ts +17 -0
  65. package/types/src/modules/snapper/ColumnSnapper.d.ts +41 -0
  66. package/types/src/modules/snapper/ScrollSnapper.d.ts +16 -0
  67. package/types/src/modules/snapper/Snapper.d.ts +11 -0
  68. package/types/src/vendor/approx-string-match/index.d.ts +54 -0
  69. package/types/src/vendor/hypothesis/anchoring/api-types.d.ts +266 -0
  70. package/types/src/vendor/hypothesis/anchoring/html.d.ts +17 -0
  71. package/types/src/vendor/hypothesis/anchoring/match-quote.d.ts +30 -0
  72. package/types/src/vendor/hypothesis/anchoring/placeholder.d.ts +32 -0
  73. package/types/src/vendor/hypothesis/anchoring/text-range.d.ts +103 -0
  74. package/types/src/vendor/hypothesis/anchoring/trim-range.d.ts +17 -0
  75. package/types/src/vendor/hypothesis/anchoring/types.d.ts +102 -0
  76. package/types/src/vendor/hypothesis/anchoring/xpath.d.ts +15 -0
  77. package/types/src/vendor/hypothesis/types/shared.d.ts +32 -0
@@ -0,0 +1,134 @@
1
+ import type {
2
+ MediaTimeSelector,
3
+ RangeSelector,
4
+ Selector,
5
+ TextPositionSelector,
6
+ TextQuoteSelector,
7
+ } from './api-types';
8
+ import {
9
+ MediaTimeAnchor,
10
+ RangeAnchor,
11
+ TextPositionAnchor,
12
+ TextQuoteAnchor,
13
+ } from './types';
14
+
15
+ type Options = {
16
+ hint?: number;
17
+ };
18
+
19
+ async function querySelector(
20
+ anchor: MediaTimeAnchor | RangeAnchor | TextPositionAnchor | TextQuoteAnchor,
21
+ options: Options
22
+ ) {
23
+ return anchor.toRange(options);
24
+ }
25
+
26
+ /**
27
+ * Anchor a set of selectors.
28
+ *
29
+ * This function converts a set of selectors into a document range.
30
+ * It encapsulates the core anchoring algorithm, using the selectors alone or
31
+ * in combination to establish the best anchor within the document.
32
+ *
33
+ * @param root - The root element of the anchoring context
34
+ * @param selectors - The selectors to try
35
+ */
36
+ export function anchor(
37
+ root: Element,
38
+ selectors: Selector[],
39
+ options: Options = {}
40
+ ) {
41
+ let mediaTime: MediaTimeSelector | null = null;
42
+ let position: TextPositionSelector | null = null;
43
+ let quote: TextQuoteSelector | null = null;
44
+ let range: RangeSelector | null = null;
45
+
46
+ // Collect all the selectors
47
+ for (const selector of selectors) {
48
+ switch (selector.type) {
49
+ case 'TextPositionSelector':
50
+ position = selector;
51
+ options.hint = position.start; // TextQuoteAnchor hint
52
+ break;
53
+ case 'TextQuoteSelector':
54
+ quote = selector;
55
+ break;
56
+ case 'RangeSelector':
57
+ range = selector;
58
+ break;
59
+ case 'MediaTimeSelector':
60
+ mediaTime = selector;
61
+ break;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Assert the quote matches the stored quote, if applicable
67
+ */
68
+ const maybeAssertQuote = (range: Range) => {
69
+ if (quote?.exact && range.toString() !== quote.exact) {
70
+ throw new Error('quote mismatch');
71
+ } else {
72
+ return range;
73
+ }
74
+ };
75
+
76
+ // From a default of failure, we build up catch clauses to try selectors in
77
+ // order, from simple to complex.
78
+ let promise: Promise<Range> = Promise.reject('unable to anchor');
79
+
80
+ if (range) {
81
+ // Const binding assures TS that it won't be re-assigned when callback runs.
82
+ const range_ = range;
83
+ promise = promise.catch(() => {
84
+ const anchor = RangeAnchor.fromSelector(root, range_);
85
+ return querySelector(anchor, options).then(maybeAssertQuote);
86
+ });
87
+ }
88
+
89
+ if (position) {
90
+ const position_ = position;
91
+ promise = promise.catch(() => {
92
+ const anchor = TextPositionAnchor.fromSelector(root, position_);
93
+ return querySelector(anchor, options).then(maybeAssertQuote);
94
+ });
95
+ }
96
+
97
+ if (quote) {
98
+ const quote_ = quote;
99
+ promise = promise.catch(() => {
100
+ const anchor = TextQuoteAnchor.fromSelector(root, quote_);
101
+ return querySelector(anchor, options);
102
+ });
103
+ }
104
+
105
+ if (mediaTime) {
106
+ const mediaTime_ = mediaTime;
107
+ promise = promise.catch(() =>
108
+ MediaTimeAnchor.fromSelector(root, mediaTime_).toRange()
109
+ );
110
+ }
111
+
112
+ return promise;
113
+ }
114
+
115
+ export function describe(root: Element, range: Range) {
116
+ const types = [
117
+ MediaTimeAnchor,
118
+ RangeAnchor,
119
+ TextPositionAnchor,
120
+ TextQuoteAnchor,
121
+ ];
122
+ const result = [];
123
+ for (const type of types) {
124
+ try {
125
+ const anchor = type.fromRange(root, range);
126
+ if (anchor) {
127
+ result.push(anchor.toSelector());
128
+ }
129
+ } catch (error) {
130
+ // If resolving some anchor fails, we just want to skip it silently
131
+ }
132
+ }
133
+ return result;
134
+ }
@@ -0,0 +1,163 @@
1
+ import approxSearch from '../../approx-string-match';
2
+ import type { Match as StringMatch } from '../../approx-string-match';
3
+
4
+ type Match = {
5
+ /** Start offset of match in text */
6
+ start: number;
7
+ /** End offset of match in text */
8
+ end: number;
9
+
10
+ /**
11
+ * Score for the match between 0 and 1.0, where 1.0 indicates a perfect match
12
+ * for the quote and context.
13
+ */
14
+ score: number;
15
+ };
16
+
17
+ /**
18
+ * Find the best approximate matches for `str` in `text` allowing up to
19
+ * `maxErrors` errors.
20
+ */
21
+ function search(text: string, str: string, maxErrors: number): StringMatch[] {
22
+ // Do a fast search for exact matches. The `approx-string-match` library
23
+ // doesn't currently incorporate this optimization itself.
24
+ let matchPos = 0;
25
+ const exactMatches: StringMatch[] = [];
26
+ while (matchPos !== -1) {
27
+ matchPos = text.indexOf(str, matchPos);
28
+ if (matchPos !== -1) {
29
+ exactMatches.push({
30
+ start: matchPos,
31
+ end: matchPos + str.length,
32
+ errors: 0,
33
+ });
34
+ matchPos += 1;
35
+ }
36
+ }
37
+ if (exactMatches.length > 0) {
38
+ return exactMatches;
39
+ }
40
+
41
+ // If there are no exact matches, do a more expensive search for matches
42
+ // with errors.
43
+ return approxSearch(text, str, maxErrors);
44
+ }
45
+
46
+ /**
47
+ * Compute a score between 0 and 1.0 for the similarity between `text` and `str`.
48
+ */
49
+ function textMatchScore(text: string, str: string) {
50
+ // `search` will return no matches if either the text or pattern is empty,
51
+ // otherwise it will return at least one match if the max allowed error count
52
+ // is at least `str.length`.
53
+ if (str.length === 0 || text.length === 0) {
54
+ return 0.0;
55
+ }
56
+
57
+ const matches = search(text, str, str.length);
58
+
59
+ // prettier-ignore
60
+ return 1 - (matches[0].errors / str.length);
61
+ }
62
+
63
+ type Context = {
64
+ /** Expected text before the quote */
65
+ prefix?: string;
66
+ /** Expected text after the quote */
67
+ suffix?: string;
68
+ /** Expected offset of match within text */
69
+ hint?: number;
70
+ };
71
+
72
+ /**
73
+ * Find the best approximate match for `quote` in `text`.
74
+ *
75
+ * @param text - Document text to search
76
+ * @param quote - String to find within `text`
77
+ * @param context - Context in which the quote originally appeared. This is
78
+ * used to choose the best match.
79
+ * @return `null` if no match exceeding the minimum quality threshold was found.
80
+ */
81
+ export function matchQuote(
82
+ text: string,
83
+ quote: string,
84
+ context: Context = {}
85
+ ): Match | null {
86
+ if (quote.length === 0) {
87
+ return null;
88
+ }
89
+
90
+ // Choose the maximum number of errors to allow for the initial search.
91
+ // This choice involves a tradeoff between:
92
+ //
93
+ // - Recall (proportion of "good" matches found)
94
+ // - Precision (proportion of matches found which are "good")
95
+ // - Cost of the initial search and of processing the candidate matches [1]
96
+ //
97
+ // [1] Specifically, the expected-time complexity of the initial search is
98
+ // `O((maxErrors / 32) * text.length)`. See `approx-string-match` docs.
99
+ const maxErrors = Math.min(256, quote.length / 2);
100
+
101
+ // Find the closest matches for `quote` in `text` based on edit distance.
102
+ const matches = search(text, quote, maxErrors);
103
+
104
+ if (matches.length === 0) {
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Compute a score between 0 and 1.0 for a match candidate.
110
+ */
111
+ const scoreMatch = (match: StringMatch) => {
112
+ const quoteWeight = 50; // Similarity of matched text to quote.
113
+ const prefixWeight = 20; // Similarity of text before matched text to `context.prefix`.
114
+ const suffixWeight = 20; // Similarity of text after matched text to `context.suffix`.
115
+ const posWeight = 2; // Proximity to expected location. Used as a tie-breaker.
116
+
117
+ const quoteScore = 1 - match.errors / quote.length;
118
+
119
+ const prefixScore = context.prefix
120
+ ? textMatchScore(
121
+ text.slice(
122
+ Math.max(0, match.start - context.prefix.length),
123
+ match.start
124
+ ),
125
+ context.prefix
126
+ )
127
+ : 1.0;
128
+ const suffixScore = context.suffix
129
+ ? textMatchScore(
130
+ text.slice(match.end, match.end + context.suffix.length),
131
+ context.suffix
132
+ )
133
+ : 1.0;
134
+
135
+ let posScore = 1.0;
136
+ if (typeof context.hint === 'number') {
137
+ const offset = Math.abs(match.start - context.hint);
138
+ posScore = 1.0 - offset / text.length;
139
+ }
140
+
141
+ const rawScore =
142
+ quoteWeight * quoteScore +
143
+ prefixWeight * prefixScore +
144
+ suffixWeight * suffixScore +
145
+ posWeight * posScore;
146
+ const maxScore = quoteWeight + prefixWeight + suffixWeight + posWeight;
147
+ const normalizedScore = rawScore / maxScore;
148
+
149
+ return normalizedScore;
150
+ };
151
+
152
+ // Rank matches based on similarity of actual and expected surrounding text
153
+ // and actual/expected offset in the document text.
154
+ const scoredMatches = matches.map(m => ({
155
+ start: m.start,
156
+ end: m.end,
157
+ score: scoreMatch(m),
158
+ }));
159
+
160
+ // Choose match with the highest score.
161
+ scoredMatches.sort((a, b) => b.score - a.score);
162
+ return scoredMatches[0];
163
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * CSS selector that will match the placeholder within a page/tile container.
3
+ */
4
+ const placeholderSelector = '.annotator-placeholder';
5
+
6
+ /**
7
+ * Create or return a placeholder element for anchoring.
8
+ *
9
+ * In document viewers such as PDF.js which only render a subset of long
10
+ * documents at a time, it may not be possible to anchor annotations to the
11
+ * actual text in pages which are off-screen. For these non-rendered pages,
12
+ * a "placeholder" element is created in the approximate X/Y location (eg.
13
+ * middle of the page) where the content will appear. Any highlights for that
14
+ * page are then rendered inside the placeholder.
15
+ *
16
+ * When the viewport is scrolled to the non-rendered page, the placeholder
17
+ * is removed and annotations are re-anchored to the real content.
18
+ *
19
+ * @param container - The container element for the page or tile which is not
20
+ * rendered.
21
+ */
22
+ export function createPlaceholder(container: HTMLElement) {
23
+ let placeholder = container.querySelector(placeholderSelector);
24
+ if (placeholder) {
25
+ return placeholder;
26
+ }
27
+ placeholder = document.createElement('span');
28
+ placeholder.classList.add('annotator-placeholder');
29
+ placeholder.textContent = 'Loading annotations...';
30
+ container.appendChild(placeholder);
31
+ return placeholder;
32
+ }
33
+
34
+ /**
35
+ * Return true if a page/tile container has a placeholder.
36
+ */
37
+ export function hasPlaceholder(container: HTMLElement): boolean {
38
+ return container.querySelector(placeholderSelector) !== null;
39
+ }
40
+
41
+ /**
42
+ * Remove the placeholder element in `container`, if present.
43
+ */
44
+ export function removePlaceholder(container: HTMLElement) {
45
+ container.querySelector(placeholderSelector)?.remove();
46
+ }
47
+
48
+ /**
49
+ * Return true if `node` is inside a placeholder element created with `createPlaceholder`.
50
+ *
51
+ * This is typically used to test if a highlight element associated with an
52
+ * anchor is inside a placeholder.
53
+ */
54
+ export function isInPlaceholder(node: Node): boolean {
55
+ if (!node.parentElement) {
56
+ return false;
57
+ }
58
+ return node.parentElement.closest(placeholderSelector) !== null;
59
+ }
@@ -0,0 +1,327 @@
1
+ import { trimRange } from './trim-range';
2
+
3
+ /**
4
+ * Return the combined length of text nodes contained in `node`.
5
+ */
6
+ function nodeTextLength(node: Node): number {
7
+ switch (node.nodeType) {
8
+ case Node.ELEMENT_NODE:
9
+ case Node.TEXT_NODE:
10
+ // nb. `textContent` excludes text in comments and processing instructions
11
+ // when called on a parent element, so we don't need to subtract that here.
12
+
13
+ return node.textContent?.length ?? 0;
14
+ default:
15
+ return 0;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Return the total length of the text of all previous siblings of `node`.
21
+ */
22
+ function previousSiblingsTextLength(node: Node): number {
23
+ let sibling = node.previousSibling;
24
+ let length = 0;
25
+ while (sibling) {
26
+ length += nodeTextLength(sibling);
27
+ sibling = sibling.previousSibling;
28
+ }
29
+ return length;
30
+ }
31
+
32
+ /**
33
+ * Resolve one or more character offsets within an element to (text node,
34
+ * position) pairs.
35
+ *
36
+ * @param element
37
+ * @param offsets - Offsets, which must be sorted in ascending order
38
+ * @throws {RangeError}
39
+ */
40
+ function resolveOffsets(
41
+ element: Element,
42
+ ...offsets: number[]
43
+ ): Array<{ node: Text; offset: number }> {
44
+ let nextOffset = offsets.shift();
45
+ const nodeIter = element.ownerDocument.createNodeIterator(
46
+ element,
47
+ NodeFilter.SHOW_TEXT
48
+ );
49
+ const results = [];
50
+
51
+ let currentNode = nodeIter.nextNode() as Text | null;
52
+ let textNode;
53
+ let length = 0;
54
+
55
+ // Find the text node containing the `nextOffset`th character from the start
56
+ // of `element`.
57
+ while (nextOffset !== undefined && currentNode) {
58
+ textNode = currentNode;
59
+ if (length + textNode.data.length > nextOffset) {
60
+ results.push({ node: textNode, offset: nextOffset - length });
61
+ nextOffset = offsets.shift();
62
+ } else {
63
+ currentNode = nodeIter.nextNode() as Text | null;
64
+ length += textNode.data.length;
65
+ }
66
+ }
67
+
68
+ // Boundary case.
69
+ while (nextOffset !== undefined && textNode && length === nextOffset) {
70
+ results.push({ node: textNode, offset: textNode.data.length });
71
+ nextOffset = offsets.shift();
72
+ }
73
+
74
+ if (nextOffset !== undefined) {
75
+ throw new RangeError('Offset exceeds text length');
76
+ }
77
+
78
+ return results;
79
+ }
80
+
81
+ /**
82
+ * When resolving a TextPosition, specifies the direction to search for the
83
+ * nearest text node if `offset` is `0` and the element has no text.
84
+ */
85
+ export enum ResolveDirection {
86
+ FORWARDS = 1,
87
+ BACKWARDS,
88
+ }
89
+
90
+ /**
91
+ * Represents an offset within the text content of an element.
92
+ *
93
+ * This position can be resolved to a specific descendant node in the current
94
+ * DOM subtree of the element using the `resolve` method.
95
+ */
96
+ export class TextPosition {
97
+ public element: Element;
98
+ public offset: number;
99
+
100
+ constructor(element: Element, offset: number) {
101
+ if (offset < 0) {
102
+ throw new Error('Offset is invalid');
103
+ }
104
+
105
+ /** Element that `offset` is relative to. */
106
+ this.element = element;
107
+
108
+ /** Character offset from the start of the element's `textContent`. */
109
+ this.offset = offset;
110
+ }
111
+
112
+ /**
113
+ * Return a copy of this position with offset relative to a given ancestor
114
+ * element.
115
+ *
116
+ * @param parent - Ancestor of `this.element`
117
+ */
118
+ relativeTo(parent: Element): TextPosition {
119
+ if (!parent.contains(this.element)) {
120
+ throw new Error('Parent is not an ancestor of current element');
121
+ }
122
+
123
+ let el = this.element;
124
+ let offset = this.offset;
125
+ while (el !== parent) {
126
+ offset += previousSiblingsTextLength(el);
127
+ el = el.parentElement!;
128
+ }
129
+
130
+ return new TextPosition(el, offset);
131
+ }
132
+
133
+ /**
134
+ * Resolve the position to a specific text node and offset within that node.
135
+ *
136
+ * Throws if `this.offset` exceeds the length of the element's text. In the
137
+ * case where the element has no text and `this.offset` is 0, the `direction`
138
+ * option determines what happens.
139
+ *
140
+ * Offsets at the boundary between two nodes are resolved to the start of the
141
+ * node that begins at the boundary.
142
+ *
143
+ * @param options.direction - Specifies in which direction to search for the
144
+ * nearest text node if `this.offset` is `0` and
145
+ * `this.element` has no text. If not specified an
146
+ * error is thrown.
147
+ *
148
+ * @throws {RangeError}
149
+ */
150
+ resolve(options: { direction?: ResolveDirection } = {}): {
151
+ node: Text;
152
+ offset: number;
153
+ } {
154
+ try {
155
+ return resolveOffsets(this.element, this.offset)[0];
156
+ } catch (err) {
157
+ if (this.offset === 0 && options.direction !== undefined) {
158
+ const tw = document.createTreeWalker(
159
+ this.element.getRootNode(),
160
+ NodeFilter.SHOW_TEXT
161
+ );
162
+ tw.currentNode = this.element;
163
+ const forwards = options.direction === ResolveDirection.FORWARDS;
164
+ const text = forwards
165
+ ? (tw.nextNode() as Text | null)
166
+ : (tw.previousNode() as Text | null);
167
+ if (!text) {
168
+ throw err;
169
+ }
170
+ return { node: text, offset: forwards ? 0 : text.data.length };
171
+ } else {
172
+ throw err;
173
+ }
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Construct a `TextPosition` that refers to the `offset`th character within
179
+ * `node`.
180
+ */
181
+ static fromCharOffset(node: Node, offset: number): TextPosition {
182
+ switch (node.nodeType) {
183
+ case Node.TEXT_NODE:
184
+ return TextPosition.fromPoint(node, offset);
185
+ case Node.ELEMENT_NODE:
186
+ return new TextPosition(node as Element, offset);
187
+ default:
188
+ throw new Error('Node is not an element or text node');
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Construct a `TextPosition` representing the range start or end point (node, offset).
194
+ *
195
+ * @param node
196
+ * @param offset - Offset within the node
197
+ */
198
+ static fromPoint(node: Node, offset: number): TextPosition {
199
+ switch (node.nodeType) {
200
+ case Node.TEXT_NODE: {
201
+ if (offset < 0 || offset > (node as Text).data.length) {
202
+ throw new Error('Text node offset is out of range');
203
+ }
204
+
205
+ if (!node.parentElement) {
206
+ throw new Error('Text node has no parent');
207
+ }
208
+
209
+ // Get the offset from the start of the parent element.
210
+ const textOffset = previousSiblingsTextLength(node) + offset;
211
+
212
+ return new TextPosition(node.parentElement, textOffset);
213
+ }
214
+ case Node.ELEMENT_NODE: {
215
+ if (offset < 0 || offset > node.childNodes.length) {
216
+ throw new Error('Child node offset is out of range');
217
+ }
218
+
219
+ // Get the text length before the `offset`th child of element.
220
+ let textOffset = 0;
221
+ for (let i = 0; i < offset; i++) {
222
+ textOffset += nodeTextLength(node.childNodes[i]);
223
+ }
224
+
225
+ return new TextPosition(node as Element, textOffset);
226
+ }
227
+ default:
228
+ throw new Error('Point is not in an element or text node');
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Represents a region of a document as a (start, end) pair of `TextPosition` points.
235
+ *
236
+ * Representing a range in this way allows for changes in the DOM content of the
237
+ * range which don't affect its text content, without affecting the text content
238
+ * of the range itself.
239
+ */
240
+ export class TextRange {
241
+ public start: TextPosition;
242
+ public end: TextPosition;
243
+
244
+ constructor(start: TextPosition, end: TextPosition) {
245
+ this.start = start;
246
+ this.end = end;
247
+ }
248
+
249
+ /**
250
+ * Create a new TextRange whose `start` and `end` are computed relative to
251
+ * `element`. `element` must be an ancestor of both `start.element` and
252
+ * `end.element`.
253
+ */
254
+ relativeTo(element: Element): TextRange {
255
+ return new TextRange(
256
+ this.start.relativeTo(element),
257
+ this.end.relativeTo(element)
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Resolve this TextRange to a (DOM) Range.
263
+ *
264
+ * The resulting DOM Range will always start and end in a `Text` node.
265
+ * Hence `TextRange.fromRange(range).toRange()` can be used to "shrink" a
266
+ * range to the text it contains.
267
+ *
268
+ * May throw if the `start` or `end` positions cannot be resolved to a range.
269
+ */
270
+ toRange(): Range {
271
+ let start;
272
+ let end;
273
+
274
+ if (
275
+ this.start.element === this.end.element &&
276
+ this.start.offset <= this.end.offset
277
+ ) {
278
+ // Fast path for start and end points in same element.
279
+ [start, end] = resolveOffsets(
280
+ this.start.element,
281
+ this.start.offset,
282
+ this.end.offset
283
+ );
284
+ } else {
285
+ start = this.start.resolve({
286
+ direction: ResolveDirection.FORWARDS,
287
+ });
288
+ end = this.end.resolve({ direction: ResolveDirection.BACKWARDS });
289
+ }
290
+
291
+ const range = new Range();
292
+ range.setStart(start.node, start.offset);
293
+ range.setEnd(end.node, end.offset);
294
+ return range;
295
+ }
296
+
297
+ /**
298
+ * Create a TextRange from a (DOM) Range
299
+ */
300
+ static fromRange(range: Range): TextRange {
301
+ const start = TextPosition.fromPoint(
302
+ range.startContainer,
303
+ range.startOffset
304
+ );
305
+ const end = TextPosition.fromPoint(range.endContainer, range.endOffset);
306
+ return new TextRange(start, end);
307
+ }
308
+
309
+ /**
310
+ * Create a TextRange representing the `start`th to `end`th characters in
311
+ * `root`
312
+ */
313
+ static fromOffsets(root: Element, start: number, end: number): TextRange {
314
+ return new TextRange(
315
+ new TextPosition(root, start),
316
+ new TextPosition(root, end)
317
+ );
318
+ }
319
+
320
+ /**
321
+ * Return a new Range representing `range` trimmed of any leading or trailing
322
+ * whitespace
323
+ */
324
+ static trimmedRange(range: Range): Range {
325
+ return trimRange(TextRange.fromRange(range).toRange());
326
+ }
327
+ }