@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.
- package/README.MD +13 -0
- package/dist/index.js +3144 -0
- package/dist/index.umd.cjs +69 -0
- package/package.json +58 -0
- package/src/Loader.ts +85 -0
- package/src/comms/comms.ts +168 -0
- package/src/comms/index.ts +3 -0
- package/src/comms/keys.ts +42 -0
- package/src/comms/mid.ts +5 -0
- package/src/helpers/animation.ts +5 -0
- package/src/helpers/css.ts +16 -0
- package/src/helpers/document.ts +43 -0
- package/src/helpers/dom.ts +126 -0
- package/src/helpers/locator.ts +67 -0
- package/src/helpers/rect.ts +301 -0
- package/src/helpers/scrollSnapperHelper.ts +43 -0
- package/src/index.ts +3 -0
- package/src/modules/Decorator.ts +462 -0
- package/src/modules/Module.ts +23 -0
- package/src/modules/ModuleLibrary.ts +44 -0
- package/src/modules/Peripherals.ts +148 -0
- package/src/modules/index.ts +3 -0
- package/src/modules/setup/FixedSetup.ts +73 -0
- package/src/modules/setup/ReflowableSetup.ts +65 -0
- package/src/modules/setup/Setup.ts +131 -0
- package/src/modules/snapper/ColumnSnapper.ts +468 -0
- package/src/modules/snapper/ScrollSnapper.ts +168 -0
- package/src/modules/snapper/Snapper.ts +49 -0
- package/src/vendor/approx-string-match/LICENSE +21 -0
- package/src/vendor/approx-string-match/README.MD +1 -0
- package/src/vendor/approx-string-match/index.ts +362 -0
- package/src/vendor/hypothesis/README.MD +1 -0
- package/src/vendor/hypothesis/anchoring/api-types.ts +309 -0
- package/src/vendor/hypothesis/anchoring/html.ts +134 -0
- package/src/vendor/hypothesis/anchoring/match-quote.ts +163 -0
- package/src/vendor/hypothesis/anchoring/placeholder.ts +59 -0
- package/src/vendor/hypothesis/anchoring/text-range.ts +327 -0
- package/src/vendor/hypothesis/anchoring/trim-range.ts +220 -0
- package/src/vendor/hypothesis/anchoring/types.ts +377 -0
- package/src/vendor/hypothesis/anchoring/xpath.ts +164 -0
- package/src/vendor/hypothesis/tsconfig.json +33 -0
- package/src/vendor/hypothesis/types/shared.ts +40 -0
- package/types/src/Loader.d.ts +33 -0
- package/types/src/comms/comms.d.ts +40 -0
- package/types/src/comms/index.d.ts +3 -0
- package/types/src/comms/keys.d.ts +2 -0
- package/types/src/comms/mid.d.ts +1 -0
- package/types/src/helpers/animation.d.ts +1 -0
- package/types/src/helpers/css.d.ts +4 -0
- package/types/src/helpers/document.d.ts +8 -0
- package/types/src/helpers/dom.d.ts +15 -0
- package/types/src/helpers/locator.d.ts +2 -0
- package/types/src/helpers/rect.d.ts +10 -0
- package/types/src/helpers/scrollSnapperHelper.d.ts +5 -0
- package/types/src/index.d.ts +3 -0
- package/types/src/modules/Decorator.d.ts +43 -0
- package/types/src/modules/Module.d.ts +11 -0
- package/types/src/modules/ModuleLibrary.d.ts +5 -0
- package/types/src/modules/Peripherals.d.ts +39 -0
- package/types/src/modules/ReflowablePeripherals.d.ts +37 -0
- package/types/src/modules/index.d.ts +3 -0
- package/types/src/modules/setup/FixedSetup.d.ts +9 -0
- package/types/src/modules/setup/ReflowableSetup.d.ts +9 -0
- package/types/src/modules/setup/Setup.d.ts +17 -0
- package/types/src/modules/snapper/ColumnSnapper.d.ts +41 -0
- package/types/src/modules/snapper/ScrollSnapper.d.ts +16 -0
- package/types/src/modules/snapper/Snapper.d.ts +11 -0
- package/types/src/vendor/approx-string-match/index.d.ts +54 -0
- package/types/src/vendor/hypothesis/anchoring/api-types.d.ts +266 -0
- package/types/src/vendor/hypothesis/anchoring/html.d.ts +17 -0
- package/types/src/vendor/hypothesis/anchoring/match-quote.d.ts +30 -0
- package/types/src/vendor/hypothesis/anchoring/placeholder.d.ts +32 -0
- package/types/src/vendor/hypothesis/anchoring/text-range.d.ts +103 -0
- package/types/src/vendor/hypothesis/anchoring/trim-range.d.ts +17 -0
- package/types/src/vendor/hypothesis/anchoring/types.d.ts +102 -0
- package/types/src/vendor/hypothesis/anchoring/xpath.d.ts +15 -0
- 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
|
+
}
|