@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,220 @@
1
+ /**
2
+ * From which direction to evaluate strings or nodes: from the start of a string
3
+ * or range seeking Forwards, or from the end seeking Backwards.
4
+ */
5
+ enum TrimDirection {
6
+ Forwards = 1,
7
+ Backwards,
8
+ }
9
+
10
+ /**
11
+ * An object representing metadata for a Range position (e.g. for use with
12
+ * Range.setStart or Range.setEnd)
13
+ */
14
+ type RangePosition = {
15
+ offset: number;
16
+ node: Node;
17
+ };
18
+
19
+ /**
20
+ * Return the offset of the nearest non-whitespace character to `baseOffset`
21
+ * within the string `text`, looking in the `direction` indicated. Return -1 if
22
+ * no non-whitespace character exists between `baseOffset` (inclusive) and the
23
+ * terminus of the string (start or end depending on `direction`).
24
+ */
25
+ function closestNonSpaceInString(
26
+ text: string,
27
+ baseOffset: number,
28
+ direction: TrimDirection
29
+ ): number {
30
+ const nextChar =
31
+ direction === TrimDirection.Forwards ? baseOffset : baseOffset - 1;
32
+ if (text.charAt(nextChar).trim() !== '') {
33
+ // baseOffset is already valid: it points at a non-whitespace character
34
+ return baseOffset;
35
+ }
36
+
37
+ let availableChars: string;
38
+ let availableNonWhitespaceChars: string;
39
+
40
+ if (direction === TrimDirection.Backwards) {
41
+ availableChars = text.substring(0, baseOffset);
42
+ availableNonWhitespaceChars = availableChars.trimEnd();
43
+ } else {
44
+ availableChars = text.substring(baseOffset);
45
+ availableNonWhitespaceChars = availableChars.trimStart();
46
+ }
47
+
48
+ if (!availableNonWhitespaceChars.length) {
49
+ return -1;
50
+ }
51
+
52
+ const offsetDelta =
53
+ availableChars.length - availableNonWhitespaceChars.length;
54
+
55
+ return direction === TrimDirection.Backwards
56
+ ? baseOffset - offsetDelta
57
+ : baseOffset + offsetDelta;
58
+ }
59
+
60
+ /**
61
+ * Calculate a new Range start position (TrimDirection.Forwards) or end position
62
+ * (Backwards) for `range` that represents the nearest non-whitespace character,
63
+ * moving into the `range` away from the relevant initial boundary node towards
64
+ * the terminating boundary node.
65
+ *
66
+ * @throws {RangeError} If no text node with non-whitespace characters found
67
+ */
68
+ function closestNonSpaceInRange(
69
+ range: Range,
70
+ direction: TrimDirection
71
+ ): RangePosition {
72
+ const nodeIter =
73
+ range.commonAncestorContainer.ownerDocument!.createNodeIterator(
74
+ range.commonAncestorContainer,
75
+ NodeFilter.SHOW_TEXT
76
+ );
77
+
78
+ const initialBoundaryNode =
79
+ direction === TrimDirection.Forwards
80
+ ? range.startContainer
81
+ : range.endContainer;
82
+
83
+ const terminalBoundaryNode =
84
+ direction === TrimDirection.Forwards
85
+ ? range.endContainer
86
+ : range.startContainer;
87
+
88
+ let currentNode = nodeIter.nextNode();
89
+
90
+ // Advance the NodeIterator to the `initialBoundaryNode`
91
+ while (currentNode && currentNode !== initialBoundaryNode) {
92
+ currentNode = nodeIter.nextNode();
93
+ }
94
+
95
+ if (direction === TrimDirection.Backwards) {
96
+ // Reverse the NodeIterator direction. This will return the same node
97
+ // as the previous `nextNode()` call (initial boundary node).
98
+ currentNode = nodeIter.previousNode();
99
+ }
100
+
101
+ let trimmedOffset = -1;
102
+
103
+ const advance = () => {
104
+ currentNode =
105
+ direction === TrimDirection.Forwards
106
+ ? nodeIter.nextNode()
107
+ : nodeIter.previousNode();
108
+
109
+ if (currentNode) {
110
+ const nodeText = currentNode.textContent!;
111
+ const baseOffset =
112
+ direction === TrimDirection.Forwards ? 0 : nodeText.length;
113
+ trimmedOffset = closestNonSpaceInString(nodeText, baseOffset, direction);
114
+ }
115
+ };
116
+
117
+ while (
118
+ currentNode &&
119
+ trimmedOffset === -1 &&
120
+ currentNode !== terminalBoundaryNode
121
+ ) {
122
+ advance();
123
+ }
124
+
125
+ if (currentNode && trimmedOffset >= 0) {
126
+ return { node: currentNode, offset: trimmedOffset };
127
+ }
128
+ /* istanbul ignore next */
129
+ throw new RangeError('No text nodes with non-whitespace text found in range');
130
+ }
131
+
132
+ /**
133
+ * Return a new DOM Range that adjusts the start and end positions of `range` as
134
+ * needed such that:
135
+ *
136
+ * - `startContainer` and `endContainer` text nodes both contain at least one
137
+ * non-whitespace character within the Range's text content
138
+ * - `startOffset` and `endOffset` both reference non-whitespace characters,
139
+ * with `startOffset` immediately before the first non-whitespace character
140
+ * and `endOffset` immediately after the last
141
+ *
142
+ * Whitespace characters are those that are removed by `String.prototype.trim()`
143
+ *
144
+ * @param range - A DOM Range that whose `startContainer` and `endContainer` are
145
+ * both text nodes, and which contains at least one non-whitespace character.
146
+ * @throws {RangeError}
147
+ */
148
+ export function trimRange(range: Range): Range {
149
+ if (!range.toString().trim().length) {
150
+ throw new RangeError('Range contains no non-whitespace text');
151
+ }
152
+ if (range.startContainer.nodeType !== Node.TEXT_NODE) {
153
+ throw new RangeError('Range startContainer is not a text node');
154
+ }
155
+ if (range.endContainer.nodeType !== Node.TEXT_NODE) {
156
+ throw new RangeError('Range endContainer is not a text node');
157
+ }
158
+
159
+ const trimmedRange = range.cloneRange();
160
+
161
+ let startTrimmed = false;
162
+ let endTrimmed = false;
163
+
164
+ const trimmedOffsets = {
165
+ start: closestNonSpaceInString(
166
+ range.startContainer.textContent!,
167
+ range.startOffset,
168
+ TrimDirection.Forwards
169
+ ),
170
+ end: closestNonSpaceInString(
171
+ range.endContainer.textContent!,
172
+ range.endOffset,
173
+ TrimDirection.Backwards
174
+ ),
175
+ };
176
+
177
+ if (trimmedOffsets.start >= 0) {
178
+ trimmedRange.setStart(range.startContainer, trimmedOffsets.start);
179
+ startTrimmed = true;
180
+ }
181
+
182
+ // Note: An offset of 0 is invalid for an end offset, as no text in the
183
+ // node would be included in the range.
184
+ if (trimmedOffsets.end > 0) {
185
+ trimmedRange.setEnd(range.endContainer, trimmedOffsets.end);
186
+ endTrimmed = true;
187
+ }
188
+
189
+ if (startTrimmed && endTrimmed) {
190
+ return trimmedRange;
191
+ }
192
+
193
+ if (!startTrimmed) {
194
+ // There are no (non-whitespace) characters between `startOffset` and the
195
+ // end of the `startContainer` node.
196
+ const { node, offset } = closestNonSpaceInRange(
197
+ trimmedRange,
198
+ TrimDirection.Forwards
199
+ );
200
+
201
+ if (node && offset >= 0) {
202
+ trimmedRange.setStart(node, offset);
203
+ }
204
+ }
205
+
206
+ if (!endTrimmed) {
207
+ // There are no (non-whitespace) characters between the start of the Range's
208
+ // `endContainer` text content and the `endOffset`.
209
+ const { node, offset } = closestNonSpaceInRange(
210
+ trimmedRange,
211
+ TrimDirection.Backwards
212
+ );
213
+
214
+ if (node && offset > 0) {
215
+ trimmedRange.setEnd(node, offset);
216
+ }
217
+ }
218
+
219
+ return trimmedRange;
220
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * This module exports a set of classes for converting between DOM `Range`
3
+ * objects and different types of selectors. It is mostly a thin wrapper around a
4
+ * set of anchoring libraries. It serves two main purposes:
5
+ *
6
+ * 1. Providing a consistent interface across different types of anchors.
7
+ * 2. Insulating the rest of the code from API changes in the underlying anchoring
8
+ * libraries.
9
+ */
10
+ import type {
11
+ MediaTimeSelector,
12
+ RangeSelector,
13
+ TextPositionSelector,
14
+ TextQuoteSelector,
15
+ } from './api-types';
16
+ import { matchQuote } from './match-quote';
17
+ import { TextRange, TextPosition } from './text-range';
18
+ import { nodeFromXPath, xpathFromNode } from './xpath';
19
+
20
+ /**
21
+ * Converts between `RangeSelector` selectors and `Range` objects.
22
+ */
23
+ export class RangeAnchor {
24
+ root: Node;
25
+ range: Range;
26
+
27
+ /**
28
+ * @param root - A root element from which to anchor.
29
+ * @param range - A range describing the anchor.
30
+ */
31
+ constructor(root: Node, range: Range) {
32
+ this.root = root;
33
+ this.range = range;
34
+ }
35
+
36
+ /**
37
+ * @param root - A root element from which to anchor.
38
+ * @param range - A range describing the anchor.
39
+ */
40
+ static fromRange(root: Node, range: Range): RangeAnchor {
41
+ return new RangeAnchor(root, range);
42
+ }
43
+
44
+ /**
45
+ * Create an anchor from a serialized `RangeSelector` selector.
46
+ *
47
+ * @param root - A root element from which to anchor.
48
+ */
49
+ static fromSelector(root: Element, selector: RangeSelector): RangeAnchor {
50
+ const startContainer = nodeFromXPath(selector.startContainer, root);
51
+ if (!startContainer) {
52
+ throw new Error('Failed to resolve startContainer XPath');
53
+ }
54
+
55
+ const endContainer = nodeFromXPath(selector.endContainer, root);
56
+ if (!endContainer) {
57
+ throw new Error('Failed to resolve endContainer XPath');
58
+ }
59
+
60
+ const startPos = TextPosition.fromCharOffset(
61
+ startContainer,
62
+ selector.startOffset
63
+ );
64
+ const endPos = TextPosition.fromCharOffset(
65
+ endContainer,
66
+ selector.endOffset
67
+ );
68
+
69
+ const range = new TextRange(startPos, endPos).toRange();
70
+ return new RangeAnchor(root, range);
71
+ }
72
+
73
+ toRange(): Range {
74
+ return this.range;
75
+ }
76
+
77
+ toSelector(): RangeSelector {
78
+ // "Shrink" the range so that it tightly wraps its text. This ensures more
79
+ // predictable output for a given text selection.
80
+ const normalizedRange = TextRange.fromRange(this.range).toRange();
81
+
82
+ const textRange = TextRange.fromRange(normalizedRange);
83
+ const startContainer = xpathFromNode(textRange.start.element, this.root);
84
+ const endContainer = xpathFromNode(textRange.end.element, this.root);
85
+
86
+ return {
87
+ type: 'RangeSelector',
88
+ startContainer,
89
+ startOffset: textRange.start.offset,
90
+ endContainer,
91
+ endOffset: textRange.end.offset,
92
+ };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Converts between `TextPositionSelector` selectors and `Range` objects.
98
+ */
99
+ export class TextPositionAnchor {
100
+ root: Element;
101
+ start: number;
102
+ end: number;
103
+
104
+ constructor(root: Element, start: number, end: number) {
105
+ this.root = root;
106
+ this.start = start;
107
+ this.end = end;
108
+ }
109
+
110
+ static fromRange(root: Element, range: Range): TextPositionAnchor {
111
+ const textRange = TextRange.fromRange(range).relativeTo(root);
112
+ return new TextPositionAnchor(
113
+ root,
114
+ textRange.start.offset,
115
+ textRange.end.offset
116
+ );
117
+ }
118
+
119
+ static fromSelector(
120
+ root: Element,
121
+ selector: TextPositionSelector
122
+ ): TextPositionAnchor {
123
+ return new TextPositionAnchor(root, selector.start, selector.end);
124
+ }
125
+
126
+ toSelector(): TextPositionSelector {
127
+ return {
128
+ type: 'TextPositionSelector',
129
+ start: this.start,
130
+ end: this.end,
131
+ };
132
+ }
133
+
134
+ toRange(): Range {
135
+ return TextRange.fromOffsets(this.root, this.start, this.end).toRange();
136
+ }
137
+ }
138
+
139
+ type QuoteMatchOptions = {
140
+ /** Expected position of match in text. See `matchQuote`. */
141
+ hint?: number;
142
+ };
143
+
144
+ export type TextQuoteAnchorContext = {
145
+ prefix?: string;
146
+ suffix?: string;
147
+ };
148
+
149
+ /**
150
+ * Converts between `TextQuoteSelector` selectors and `Range` objects.
151
+ */
152
+ export class TextQuoteAnchor {
153
+ root: Element;
154
+ exact: string;
155
+ context: TextQuoteAnchorContext;
156
+
157
+ /**
158
+ * @param root - A root element from which to anchor.
159
+ */
160
+ constructor(
161
+ root: Element,
162
+ exact: string,
163
+ context: TextQuoteAnchorContext = {}
164
+ ) {
165
+ this.root = root;
166
+ this.exact = exact;
167
+ this.context = context;
168
+ }
169
+
170
+ /**
171
+ * Create a `TextQuoteAnchor` from a range.
172
+ *
173
+ * Will throw if `range` does not contain any text nodes.
174
+ */
175
+ static fromRange(root: Element, range: Range): TextQuoteAnchor {
176
+ const text = root.textContent!;
177
+ const textRange = TextRange.fromRange(range).relativeTo(root);
178
+
179
+ const start = textRange.start.offset;
180
+ const end = textRange.end.offset;
181
+
182
+ // Number of characters around the quote to capture as context. We currently
183
+ // always use a fixed amount, but it would be better if this code was aware
184
+ // of logical boundaries in the document (paragraph, article etc.) to avoid
185
+ // capturing text unrelated to the quote.
186
+ //
187
+ // In regular prose the ideal content would often be the surrounding sentence.
188
+ // This is a natural unit of meaning which enables displaying quotes in
189
+ // context even when the document is not available. We could use `Intl.Segmenter`
190
+ // for this when available.
191
+ const contextLen = 32;
192
+
193
+ return new TextQuoteAnchor(root, text.slice(start, end), {
194
+ prefix: text.slice(Math.max(0, start - contextLen), start),
195
+ suffix: text.slice(end, Math.min(text.length, end + contextLen)),
196
+ });
197
+ }
198
+
199
+ static fromSelector(
200
+ root: Element,
201
+ selector: TextQuoteSelector
202
+ ): TextQuoteAnchor {
203
+ const { prefix, suffix } = selector;
204
+ return new TextQuoteAnchor(root, selector.exact, { prefix, suffix });
205
+ }
206
+
207
+ toSelector(): TextQuoteSelector {
208
+ return {
209
+ type: 'TextQuoteSelector',
210
+ exact: this.exact,
211
+ prefix: this.context.prefix,
212
+ suffix: this.context.suffix,
213
+ };
214
+ }
215
+
216
+ toRange(options: QuoteMatchOptions = {}): Range {
217
+ return this.toPositionAnchor(options).toRange();
218
+ }
219
+
220
+ toPositionAnchor(options: QuoteMatchOptions = {}): TextPositionAnchor {
221
+ const text = this.root.textContent!;
222
+ const match = matchQuote(text, this.exact, {
223
+ ...this.context,
224
+ hint: options.hint,
225
+ });
226
+ if (!match) {
227
+ throw new Error('Quote not found');
228
+ }
229
+ return new TextPositionAnchor(this.root, match.start, match.end);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Parse a string containing a time offset in seconds, since the start of some
235
+ * media, into a float.
236
+ */
237
+ function parseMediaTime(timeStr: string): number | null {
238
+ const val = parseFloat(timeStr);
239
+ if (!Number.isFinite(val) || val < 0) {
240
+ return null;
241
+ }
242
+ return val;
243
+ }
244
+
245
+ /** Implementation of {@link Array.prototype.findLastIndex} */
246
+ function findLastIndex<T>(ary: T[], pred: (val: T) => boolean): number {
247
+ for (let i = ary.length - 1; i >= 0; i--) {
248
+ if (pred(ary[i])) {
249
+ return i;
250
+ }
251
+ }
252
+ return -1;
253
+ }
254
+
255
+ function closestElement(node: Node) {
256
+ return node instanceof Element ? node : node.parentElement;
257
+ }
258
+
259
+ /**
260
+ * Get the media time range associated with an element or pair of elements,
261
+ * from `data-time-{start, end}` attributes on them.
262
+ */
263
+ function getMediaTimeRange(
264
+ start: Element | undefined | null,
265
+ end: Element | undefined | null = start
266
+ ): [number, number] | null {
267
+ const startTime = parseMediaTime(
268
+ start?.getAttribute('data-time-start') ?? ''
269
+ );
270
+ const endTime = parseMediaTime(end?.getAttribute('data-time-end') ?? '');
271
+ if (
272
+ typeof startTime !== 'number' ||
273
+ typeof endTime !== 'number' ||
274
+ endTime < startTime
275
+ ) {
276
+ return null;
277
+ }
278
+ return [startTime, endTime];
279
+ }
280
+
281
+ export class MediaTimeAnchor {
282
+ root: Element;
283
+
284
+ /** Offset from start of media in seconds. */
285
+ start: number;
286
+ /** Offset from end of media in seconds. */
287
+ end: number;
288
+
289
+ constructor(root: Element, start: number, end: number) {
290
+ this.root = root;
291
+ this.start = start;
292
+ this.end = end;
293
+ }
294
+
295
+ /**
296
+ * Return a {@link MediaTimeAnchor} that represents a range, or `null` if
297
+ * no time range information is present on elements in the range.
298
+ */
299
+ static fromRange(root: Element, range: Range): MediaTimeAnchor | null {
300
+ const start = closestElement(range.startContainer)?.closest(
301
+ '[data-time-start]'
302
+ );
303
+ const end = closestElement(range.endContainer)?.closest('[data-time-end]');
304
+ const timeRange = getMediaTimeRange(start, end);
305
+ if (!timeRange) {
306
+ return null;
307
+ }
308
+ const [startTime, endTime] = timeRange;
309
+ return new MediaTimeAnchor(root, startTime, endTime);
310
+ }
311
+
312
+ /**
313
+ * Convert this anchor to a DOM range.
314
+ *
315
+ * This returned range will start from the beginning of the element whose
316
+ * associated time range includes `start` and continue to the end of the
317
+ * element whose associated time range includes `end`.
318
+ */
319
+ toRange(): Range {
320
+ // Find the segments that span the start and end times of this anchor.
321
+ // This is inefficient since we re-find all segments for each annotation
322
+ // that is anchored. Changing this will involve revising the anchoring
323
+ // API however.
324
+ type Segment = { element: Element; start: number; end: number };
325
+ const segments = [...this.root.querySelectorAll('[data-time-start]')]
326
+ .map(element => {
327
+ const timeRange = getMediaTimeRange(element);
328
+ if (!timeRange) {
329
+ return null;
330
+ }
331
+ const [start, end] = timeRange;
332
+ return { element, start, end };
333
+ })
334
+ .filter(s => s !== null) as Segment[];
335
+ segments.sort((a, b) => a.start - b.start);
336
+
337
+ const startIdx = findLastIndex(
338
+ segments,
339
+ s => s.start <= this.start && s.end >= this.start
340
+ );
341
+ if (startIdx === -1) {
342
+ throw new Error('Start segment not found');
343
+ }
344
+ const endIdx =
345
+ startIdx +
346
+ segments
347
+ .slice(startIdx)
348
+ .findIndex(s => s.start <= this.end && s.end >= this.end);
349
+ if (endIdx === -1) {
350
+ throw new Error('End segment not found');
351
+ }
352
+
353
+ const range = new Range();
354
+ range.setStart(segments[startIdx].element, 0);
355
+
356
+ const endEl = segments[endIdx].element;
357
+ range.setEnd(endEl, endEl.childNodes.length);
358
+
359
+ return range;
360
+ }
361
+
362
+ static fromSelector(
363
+ root: Element,
364
+ selector: MediaTimeSelector
365
+ ): MediaTimeAnchor {
366
+ const { start, end } = selector;
367
+ return new MediaTimeAnchor(root, start, end);
368
+ }
369
+
370
+ toSelector(): MediaTimeSelector {
371
+ return {
372
+ type: 'MediaTimeSelector',
373
+ start: this.start,
374
+ end: this.end,
375
+ };
376
+ }
377
+ }