@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,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
|
+
}
|