@samuelbradshaw/highlight-helper-js 0.8.0 → 0.9.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/.github/workflows/publish.yml +5 -5
- package/README.md +42 -25
- package/demo.html +17 -14
- package/highlight-helper.js +1339 -1234
- package/package.json +1 -1
package/highlight-helper.js
CHANGED
|
@@ -1,977 +1,1026 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* HighlightHelper.js
|
|
3
3
|
* https://github.com/samuelbradshaw/highlight-helper-js
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.annotatableContainer, this.relativeAncestorElement, this.annotatableParagraphs, this.stylesheets;
|
|
14
|
-
let generalStylesheet, appearanceStylesheet, highlightApiStylesheet, selectionStylesheet;
|
|
15
|
-
let annotatableParagraphIds, hyperlinkElements;
|
|
16
|
-
let svgBackground, svgActiveOverlay, selectionHandles;
|
|
17
|
-
let highlightsById, hyperlinksByPosition;
|
|
18
|
-
let controller;
|
|
19
|
-
|
|
20
|
-
const initializeHighlighter = (previousContainerSelector = null) => {
|
|
21
|
-
if (!options.paragraphSelector.includes(options.containerSelector)) {
|
|
22
|
-
const paragraphSelectorList = options.paragraphSelector.split(',').map(selector => `${options.containerSelector} ${selector}`);
|
|
23
|
-
options.paragraphSelector = paragraphSelectorList.join(',');
|
|
24
|
-
}
|
|
25
|
-
this.annotatableContainer = document.querySelector(options.containerSelector);
|
|
26
|
-
this.annotatableParagraphs = this.annotatableContainer.querySelectorAll(options.paragraphSelector);
|
|
27
|
-
annotatableParagraphIds = Array.from(this.annotatableParagraphs, paragraph => paragraph.id);
|
|
28
|
-
|
|
29
|
-
// Handle cases where a highlighter already exists for the container, or one of its children or ancestors
|
|
30
|
-
const previousContainer = document.querySelector(previousContainerSelector) ?? this.annotatableContainer;
|
|
31
|
-
if (previousContainer.highlighter) {
|
|
32
|
-
previousContainer.highlighter.removeHighlighter();
|
|
33
|
-
} else if (this.annotatableContainer.closest('[data-hh-container]') || this.annotatableContainer.querySelector('[data-hh-container]')) {
|
|
34
|
-
console.error(`Unable to create Highlighter with container selector “${options.containerSelector}” (annotatable container can’t be an child or ancestor of another annotatable container).`);
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Get the closest ancestor element with `position: relative`, or the root element (this is used to calculate the position of selection handles and SVG highlights)
|
|
39
|
-
let ancestorElement = this.annotatableContainer;
|
|
40
|
-
while (ancestorElement) {
|
|
41
|
-
if (ancestorElement === document.documentElement || window.getComputedStyle(ancestorElement).position === 'relative') {
|
|
42
|
-
this.relativeAncestorElement = ancestorElement;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
ancestorElement = ancestorElement.parentElement;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Abort controller can be used to cancel event listeners if the highlighter is removed
|
|
49
|
-
controller = new AbortController;
|
|
50
|
-
|
|
51
|
-
// Setting tabIndex -1 on <body> allows focus to be set programmatically (needed to initialize text selection in iOS Safari). It also prevents "tap to search" from interfering with text selection in Android Chrome.
|
|
52
|
-
document.body.tabIndex = -1;
|
|
53
|
-
|
|
54
|
-
// Set up stylesheets
|
|
55
|
-
this.stylesheets = {}
|
|
56
|
-
generalStylesheet = addStylesheet(this.stylesheets, 'general');
|
|
57
|
-
appearanceStylesheet = addStylesheet(this.stylesheets, 'appearance');
|
|
58
|
-
highlightApiStylesheet = addStylesheet(this.stylesheets, 'highlight-api');
|
|
59
|
-
selectionStylesheet = addStylesheet(this.stylesheets, 'selection');
|
|
60
|
-
generalStylesheet.replaceSync(`
|
|
61
|
-
${options.containerSelector} {
|
|
62
|
-
-webkit-tap-highlight-color: transparent;
|
|
63
|
-
}
|
|
64
|
-
.hh-wrapper-start, .hh-wrapper-end, .hh-selection-handle {
|
|
65
|
-
-webkit-user-select: none;
|
|
66
|
-
user-select: none;
|
|
67
|
-
}
|
|
68
|
-
.hh-selection-handle {
|
|
69
|
-
position: absolute;
|
|
70
|
-
width: 0px;
|
|
71
|
-
visibility: hidden;
|
|
72
|
-
}
|
|
73
|
-
[data-hh-pointer-down="true"] .hh-selection-handle {
|
|
74
|
-
visibility: hidden !important;
|
|
75
|
-
}
|
|
76
|
-
.hh-selection-handle-content {
|
|
77
|
-
position: absolute;
|
|
78
|
-
height: 100%;
|
|
79
|
-
}
|
|
80
|
-
.hh-selection-handle [draggable] {
|
|
81
|
-
position: absolute;
|
|
82
|
-
top: -0.3em;
|
|
83
|
-
width: 3.2em;
|
|
84
|
-
height: calc(100% + 1.3em);
|
|
85
|
-
background-color: transparent;
|
|
86
|
-
z-index: 1;
|
|
87
|
-
}
|
|
88
|
-
.hh-selection-handle[data-side="left"] [draggable] { right: -2em; }
|
|
89
|
-
.hh-selection-handle[data-side="right"] [draggable] { left: -2em; }
|
|
90
|
-
.hh-default-handle {
|
|
91
|
-
position: absolute;
|
|
92
|
-
width: 0.8em;
|
|
93
|
-
height: min(1.2em, 100%);
|
|
94
|
-
background-color: hsl(from var(--hh-color) h 80% 40% / 1);
|
|
95
|
-
outline: 0.1em solid hsla(0, 0%, 100%, 0.8);
|
|
96
|
-
outline-offset: -0.05em;
|
|
97
|
-
bottom: -0.2em;
|
|
98
|
-
}
|
|
99
|
-
.hh-selection-handle[data-side="left"] .hh-default-handle {
|
|
100
|
-
right: 0;
|
|
101
|
-
border-radius: 1em 0 0.6em 0.6em;
|
|
102
|
-
}
|
|
103
|
-
.hh-selection-handle[data-side="right"] .hh-default-handle {
|
|
104
|
-
left: 0;
|
|
105
|
-
border-radius: 0 1em 0.6em 0.6em;
|
|
106
|
-
}
|
|
107
|
-
.hh-svg-background {
|
|
108
|
-
position: absolute;
|
|
109
|
-
top: 0;
|
|
110
|
-
left: 0;
|
|
111
|
-
width: 100%;
|
|
112
|
-
overflow: visible;
|
|
113
|
-
z-index: -1;
|
|
114
|
-
}
|
|
115
|
-
.hh-svg-background g {
|
|
116
|
-
fill: transparent;
|
|
117
|
-
stroke: none;
|
|
118
|
-
}
|
|
119
|
-
mark[data-highlight-id] {
|
|
120
|
-
background-color: transparent;
|
|
121
|
-
color: inherit;
|
|
122
|
-
}
|
|
123
|
-
mark[data-highlight-id][data-style="fill"][data-start] {
|
|
124
|
-
border-top-left-radius: 0.25em;
|
|
125
|
-
border-bottom-left-radius: 0.25em;
|
|
126
|
-
margin-left: -0.13em; padding-left: 0.13em;
|
|
127
|
-
}
|
|
128
|
-
mark[data-highlight-id][data-style="fill"][data-end] {
|
|
129
|
-
border-top-right-radius: 0.25em;
|
|
130
|
-
border-bottom-right-radius: 0.25em;
|
|
131
|
-
margin-right: -0.13em; padding-right: 0.13em;
|
|
132
|
-
}
|
|
133
|
-
mark[data-highlight-id][data-style="fill"] + mark[data-highlight-id][data-style="fill"] {
|
|
134
|
-
margin-left: 0; padding-left: 0;
|
|
135
|
-
}
|
|
136
|
-
`);
|
|
137
|
-
|
|
138
|
-
// Set up SVG background and selection handles
|
|
139
|
-
svgBackground = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
140
|
-
svgActiveOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
141
|
-
svgActiveOverlay.dataset.activeOverlay = '';
|
|
142
|
-
svgBackground.appendChild(svgActiveOverlay);
|
|
143
|
-
svgBackground.classList.add('hh-svg-background');
|
|
144
|
-
this.annotatableContainer.appendChild(svgBackground);
|
|
145
|
-
this.annotatableContainer.insertAdjacentHTML('beforeend', `
|
|
146
|
-
<div class="hh-selection-handle" data-side="left" data-position="start"><div draggable="true"></div><div class="hh-selection-handle-content"></div></div>
|
|
147
|
-
<div class="hh-selection-handle" data-side="right" data-position="end"><div draggable="true"></div><div class="hh-selection-handle-content"></div></div>
|
|
148
|
-
`);
|
|
149
|
-
selectionHandles = this.annotatableContainer.getElementsByClassName('hh-selection-handle');
|
|
150
|
-
|
|
151
|
-
// Check for hyperlinks on the page
|
|
152
|
-
hyperlinkElements = this.annotatableContainer.getElementsByTagName('a');
|
|
153
|
-
hyperlinksByPosition = {}
|
|
154
|
-
for (let hyp = 0; hyp < hyperlinkElements.length; hyp++) {
|
|
155
|
-
hyperlinksByPosition[hyp] = {
|
|
156
|
-
'position': hyp,
|
|
157
|
-
'text': hyperlinkElements[hyp].innerHTML,
|
|
158
|
-
'url': hyperlinkElements[hyp].href,
|
|
159
|
-
'hyperlinkElement': hyperlinkElements[hyp],
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
highlightsById = {};
|
|
164
|
-
this.annotatableContainer.dataset.hhContainer = '';
|
|
165
|
-
this.annotatableContainer.highlighter = this;
|
|
166
|
-
hhHighlighters.push(this);
|
|
167
|
-
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const isInitialized = initializeHighlighter();
|
|
172
|
-
if (!isInitialized) return;
|
|
173
|
-
|
|
174
|
-
let activeHighlightId, previousSelectionRange, activeSelectionHandle, dragAnchorNode, dragAnchorOffset, pointerType, tapResult, doubleTapTimeoutId, longPressTimeoutId;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// -------- PUBLIC METHODS --------
|
|
178
|
-
|
|
179
|
-
// Load highlights
|
|
180
|
-
this.loadHighlights = (highlights) => {
|
|
181
|
-
// Don't load highlights until the document is ready (otherwise, highlights may be offset)
|
|
182
|
-
if (document.readyState !== 'complete') return setTimeout(this.loadHighlights, 10, highlights);
|
|
183
|
-
const startTimestamp = Date.now();
|
|
184
|
-
|
|
185
|
-
// Hide container (repeated DOM manipulations are faster if the container is hidden)
|
|
186
|
-
if (highlights.length > 1) (options.drawingMode === 'svg' ? svgBackground : this.annotatableContainer).style.display = 'none';
|
|
187
|
-
|
|
188
|
-
// Load read-only highlights first (read-only highlights change the DOM, affecting other highlights' ranges)
|
|
189
|
-
const sortedHighlights = highlights.sort((a,b) => a.readOnly === b.readOnly ? 0 : a.readOnly ? -1 : 1);
|
|
190
|
-
|
|
191
|
-
const knownHighlightIds = Object.keys(highlightsById);
|
|
192
|
-
let addedCount = 0, updatedCount = 0;
|
|
193
|
-
for (const highlight of sortedHighlights) {
|
|
194
|
-
const highlightInfo = diffHighlight(highlight, highlightsById[highlight.highlightId]);
|
|
195
|
-
highlightInfo.highlightId = highlight.highlightId;
|
|
196
|
-
const knownHighlightIndex = knownHighlightIds.indexOf(highlightInfo.highlightId);
|
|
197
|
-
if (knownHighlightIndex > -1) {
|
|
198
|
-
knownHighlightIds.splice(knownHighlightIndex, 1);
|
|
199
|
-
if (Object.keys(highlightInfo).length > 1) {
|
|
200
|
-
this.createOrUpdateHighlight(highlightInfo, false); updatedCount++;
|
|
201
|
-
}
|
|
202
|
-
} else {
|
|
203
|
-
this.createOrUpdateHighlight(highlightInfo, false); addedCount++;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
if (knownHighlightIds.length > 0) this.removeHighlights(knownHighlightIds);
|
|
207
|
-
|
|
208
|
-
(options.drawingMode === 'svg' ? svgBackground : this.annotatableContainer).style.display = '';
|
|
209
|
-
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightsload', { detail: {
|
|
210
|
-
addedCount: addedCount, removedCount: knownHighlightIds.length, updatedCount: updatedCount,
|
|
211
|
-
totalCount: Object.keys(highlightsById).length,
|
|
212
|
-
timeToLoad: Date.now() - startTimestamp,
|
|
213
|
-
} }));
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Draw (or redraw) specified highlights, or all highlights on the page
|
|
217
|
-
this.drawHighlights = (highlightIds = Object.keys(highlightsById)) => {
|
|
218
|
-
// Hide container (repeated DOM manipulations is faster if the container is hidden)
|
|
219
|
-
if (highlightIds.length > 1) (options.drawingMode === 'svg' ? svgBackground : this.annotatableContainer).style.display = 'none';
|
|
220
|
-
|
|
221
|
-
for (const highlightId of highlightIds) {
|
|
222
|
-
const highlightInfo = highlightsById[highlightId];
|
|
223
|
-
let range = getCorrectedRangeObj(highlightId);
|
|
224
|
-
const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
|
|
225
|
-
const wasDrawnAsReadOnly = this.annotatableContainer.querySelector(`[data-highlight-id="${highlightId}"][data-read-only]`);
|
|
226
|
-
|
|
227
|
-
// Remove old highlight elements and styles
|
|
228
|
-
if (!wasDrawnAsReadOnly || (wasDrawnAsReadOnly && !highlightInfo.readOnly)) undrawHighlight(highlightInfo);
|
|
229
|
-
|
|
230
|
-
if (highlightInfo.readOnly || options.drawingMode === 'mark-elements') {
|
|
231
|
-
// Don't redraw a read-only highlight
|
|
232
|
-
if (wasDrawnAsReadOnly) continue;
|
|
233
|
-
|
|
234
|
-
// Inject HTML <mark> elements
|
|
235
|
-
range.startContainer.splitText(range.startOffset);
|
|
236
|
-
range.endContainer.splitText(range.endOffset);
|
|
237
|
-
const textNodeIter = document.createNodeIterator(range.commonAncestorContainer, NodeFilter.SHOW_TEXT);
|
|
238
|
-
const relevantTextNodes = [];
|
|
239
|
-
let node;
|
|
240
|
-
while (node = textNodeIter.nextNode()) {
|
|
241
|
-
if (range.intersectsNode(node) && node !== range.startContainer && node.textContent !== '' && !node.parentElement.closest('rt') && node.parentElement.closest(options.paragraphSelector)) relevantTextNodes.push(node);
|
|
242
|
-
if (node === range.endContainer) break;
|
|
243
|
-
}
|
|
244
|
-
const overlappingHighlightIds = new Set();
|
|
245
|
-
for (let tn = 0; tn < relevantTextNodes.length; tn++) {
|
|
246
|
-
const textNode = relevantTextNodes[tn];
|
|
247
|
-
if (textNode.parentElement.dataset.highlightId) {
|
|
248
|
-
overlappingHighlightIds.add(textNode.parentElement.dataset.highlightId);
|
|
249
|
-
}
|
|
250
|
-
const styledMark = document.createElement('mark');
|
|
251
|
-
styledMark.dataset.highlightId = highlightId;
|
|
252
|
-
if (highlightInfo.readOnly) styledMark.dataset.readOnly = '';
|
|
253
|
-
styledMark.dataset.color = highlightInfo.color;
|
|
254
|
-
styledMark.dataset.style = highlightInfo.style;
|
|
255
|
-
if (tn === 0) styledMark.dataset.start = '';
|
|
256
|
-
if (tn === relevantTextNodes.length - 1) styledMark.dataset.end = '';
|
|
257
|
-
textNode.before(styledMark);
|
|
258
|
-
styledMark.appendChild(textNode);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Update highlight ranges that were invalidated by the DOM change
|
|
262
|
-
range = getCorrectedRangeObj(highlightId);
|
|
263
|
-
for (const overlappingHighlightId of overlappingHighlightIds) {
|
|
264
|
-
getCorrectedRangeObj(overlappingHighlightId);
|
|
265
|
-
}
|
|
266
|
-
} else {
|
|
267
|
-
// Draw highlights with Custom Highlight API
|
|
268
|
-
if (options.drawingMode === 'highlight-api' && supportsHighlightApi) {
|
|
269
|
-
let highlightObj;
|
|
270
|
-
if (CSS.highlights.has(highlightId)) {
|
|
271
|
-
highlightObj = CSS.highlights.get(highlightId);
|
|
272
|
-
highlightObj.clear();
|
|
273
|
-
} else {
|
|
274
|
-
highlightObj = new Highlight();
|
|
275
|
-
CSS.highlights.set(highlightId, highlightObj);
|
|
276
|
-
}
|
|
277
|
-
highlightObj.add(range);
|
|
278
|
-
const styleTemplate = getStyleTemplate(highlightInfo.style, 'css', null);
|
|
279
|
-
const colorString = options.colors[highlightInfo.color];
|
|
280
|
-
highlightApiStylesheet.insertRule(`${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { --hh-color: ${colorString}; ${styleTemplate} }`);
|
|
281
|
-
highlightApiStylesheet.insertRule(`${options.containerSelector} rt::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }`);
|
|
282
|
-
highlightApiStylesheet.insertRule(`${options.containerSelector} img::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }`);
|
|
283
|
-
|
|
284
|
-
// Draw highlights with SVG shapes
|
|
285
|
-
} else if (options.drawingMode === 'svg') {
|
|
286
|
-
const clientRects = getMergedClientRects(range, rangeParagraphs);
|
|
287
|
-
let group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
288
|
-
group.dataset.highlightId = highlightId;
|
|
289
|
-
group.dataset.color = highlightInfo.color;
|
|
290
|
-
group.dataset.style = highlightInfo.style;
|
|
291
|
-
let svgContent = '';
|
|
292
|
-
for (const clientRect of clientRects) {
|
|
293
|
-
svgContent += getStyleTemplate(highlightInfo.style, 'svg', clientRect);
|
|
294
|
-
}
|
|
295
|
-
group.innerHTML = svgContent;
|
|
296
|
-
svgBackground.appendChild(group);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Update wrapper (for read-only highlights only)
|
|
301
|
-
if (highlightInfo.readOnly && !wasDrawnAsReadOnly) {
|
|
302
|
-
if (highlightInfo.wrapper && (options.wrappers[highlightInfo.wrapper]?.start || options.wrappers[highlightInfo.wrapper]?.end)) {
|
|
303
|
-
const addWrapper = (edge, range, htmlString) => {
|
|
304
|
-
htmlString = `<span class="hh-wrapper-${edge}" data-highlight-id="${highlightId}" data-color="${highlightInfo.color}" data-style="${highlightInfo.style}">${htmlString}</span>`
|
|
305
|
-
for (const key of Object.keys(highlightInfo.wrapperVariables)) {
|
|
306
|
-
htmlString = htmlString.replaceAll(`{${key}}`, highlightInfo.wrapperVariables[key]);
|
|
307
|
-
}
|
|
308
|
-
const template = document.createElement('template');
|
|
309
|
-
template.innerHTML = htmlString;
|
|
310
|
-
let htmlElement = template.content.firstChild;
|
|
311
|
-
|
|
312
|
-
let node;
|
|
313
|
-
const textNodeIter = document.createNodeIterator(htmlElement, NodeFilter.SHOW_TEXT);
|
|
314
|
-
while (node = textNodeIter.nextNode()) node.parentNode.removeChild(node);
|
|
315
|
-
range.insertNode(htmlElement);
|
|
316
|
-
}
|
|
317
|
-
const startRange = highlightInfo.rangeObj;
|
|
318
|
-
const endRange = document.createRange(); endRange.setStart(highlightInfo.rangeObj.endContainer, highlightInfo.rangeObj.endOffset);
|
|
319
|
-
const wrapperInfo = options.wrappers[highlightInfo.wrapper];
|
|
320
|
-
addWrapper('start', startRange, wrapperInfo.start);
|
|
321
|
-
addWrapper('end', endRange, wrapperInfo.end);
|
|
322
|
-
rangeParagraphs.forEach(p => { p.normalize(); });
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Show container
|
|
328
|
-
(options.drawingMode === 'svg' ? svgBackground : this.annotatableContainer).style.display = '';
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Create a new highlight, or update an existing highlight when it changes
|
|
332
|
-
this.createOrUpdateHighlight = (attributes = {}, triggeredByUserAction = true) => {
|
|
333
|
-
let highlightId = attributes.highlightId ?? activeHighlightId ?? options.highlightIdFunction();
|
|
334
|
-
const appearanceChanges = [];
|
|
335
|
-
const boundsChanges = [];
|
|
336
|
-
|
|
337
|
-
let isNewHighlight, oldHighlightInfo;
|
|
338
|
-
if (highlightsById.hasOwnProperty(highlightId)) {
|
|
339
|
-
oldHighlightInfo = highlightsById[highlightId];
|
|
340
|
-
} else {
|
|
341
|
-
isNewHighlight = true;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// If a different highlight is active, deactivate it
|
|
345
|
-
if (activeHighlightId && highlightId !== activeHighlightId && triggeredByUserAction === true) {
|
|
346
|
-
this.deactivateHighlights();
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// If the highlight is currently activate, ignore bounds changes that weren't initiated by the user
|
|
350
|
-
if (highlightId === activeHighlightId && triggeredByUserAction === false) {
|
|
351
|
-
attributes.startParagraphId = null;
|
|
352
|
-
attributes.startParagraphOffset = null;
|
|
353
|
-
attributes.endParagraphId = null;
|
|
354
|
-
attributes.endParagraphOffset = null;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Warn if color, style, or wrapper attributes are invalid
|
|
358
|
-
if (attributes.color && !options.colors.hasOwnProperty(attributes.color)) {
|
|
359
|
-
console.warn(`Highlight color "${attributes.color}" is not defined in options (highlightId: ${highlightId}).`);
|
|
360
|
-
}
|
|
361
|
-
if (attributes.style && !options.styles.hasOwnProperty(attributes.style)) {
|
|
362
|
-
console.warn(`Highlight style "${attributes.style}" is not defined in options (highlightId: ${highlightId}).`);
|
|
363
|
-
}
|
|
364
|
-
if (attributes.wrapper && !options.wrappers.hasOwnProperty(attributes.wrapper)) {
|
|
365
|
-
console.warn(`Highlight wrapper "${attributes.wrapper}" is not defined in options (highlightId: ${highlightId}).`);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Update defaults
|
|
369
|
-
if (options.rememberStyle && triggeredByUserAction) {
|
|
370
|
-
if (attributes.color) options.defaultColor = attributes.color;
|
|
371
|
-
if (attributes.style) options.defaultStyle = attributes.style;
|
|
372
|
-
if (attributes.wrapper) options.defaultWrapper = attributes.wrapper;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Check which appearance properties changed
|
|
376
|
-
for (const key of ['color', 'style', 'wrapper', 'wrapperVariables', 'readOnly']) {
|
|
377
|
-
if (isNewHighlight || (attributes[key] != null && attributes[key] !== oldHighlightInfo[key])) appearanceChanges.push(key);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// If the highlight was and still is read-only, return
|
|
381
|
-
if (oldHighlightInfo?.readOnly && (attributes.readOnly == null || attributes.readOnly === true)) return this.deactivateHighlights();
|
|
382
|
-
|
|
383
|
-
// Calculate the bounds of the highlight range, if it's changed
|
|
384
|
-
let adjustedSelectionRange, highlightRange;
|
|
385
|
-
let rangeText, rangeHtml, rangeParagraphIds;
|
|
386
|
-
let startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset;
|
|
387
|
-
const selection = getRestoredSelectionOrCaret(window.getSelection());
|
|
388
|
-
if (selection.type === 'Range') adjustedSelectionRange = snapRangeToBoundaries(selection.getRangeAt(0));
|
|
389
|
-
if ((attributes.startParagraphId ?? attributes.startParagraphOffset ?? attributes.endParagraphId ?? attributes.endParagraphOffset != null) || (adjustedSelectionRange && !adjustedSelectionRange.collapsed)) {
|
|
390
|
-
let startNode, startOffset, endNode, endOffset;
|
|
391
|
-
if (attributes.startParagraphId ?? attributes.startParagraphOffset ?? attributes.endParagraphId ?? attributes.endParagraphOffset != null) {
|
|
392
|
-
startParagraphId = attributes.startParagraphId ?? oldHighlightInfo?.startParagraphId;
|
|
393
|
-
startParagraphOffset = parseInt(attributes.startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset);
|
|
394
|
-
endParagraphId = attributes.endParagraphId ?? oldHighlightInfo?.endParagraphId;
|
|
395
|
-
endParagraphOffset = parseInt(attributes.endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset);
|
|
396
|
-
([ startNode, startOffset ] = getTextNodeAndOffset(document.getElementById(startParagraphId), startParagraphOffset));
|
|
397
|
-
([ endNode, endOffset ] = getTextNodeAndOffset(document.getElementById(endParagraphId), endParagraphOffset));
|
|
398
|
-
} else if (adjustedSelectionRange) {
|
|
399
|
-
startNode = adjustedSelectionRange.startContainer;
|
|
400
|
-
startOffset = adjustedSelectionRange.startOffset;
|
|
401
|
-
endNode = adjustedSelectionRange.endContainer;
|
|
402
|
-
endOffset = adjustedSelectionRange.endOffset;
|
|
403
|
-
([ startParagraphId, startParagraphOffset ] = getParagraphOffset(startNode, startOffset));
|
|
404
|
-
([ endParagraphId, endParagraphOffset ] = getParagraphOffset(endNode, endOffset));
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Create a new highlight range
|
|
408
|
-
highlightRange = document.createRange();
|
|
409
|
-
highlightRange.setStart(startNode, startOffset);
|
|
410
|
-
highlightRange.setEnd(endNode, endOffset);
|
|
411
|
-
|
|
412
|
-
// Check which bounds properties changed
|
|
413
|
-
for (const key of ['startParagraphId', 'startParagraphOffset', 'endParagraphId', 'endParagraphOffset']) {
|
|
414
|
-
if (isNewHighlight || eval(key) !== oldHighlightInfo[key]) boundsChanges.push(key);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Set variables that depend on the range
|
|
418
|
-
const temporaryHtmlElement = document.createElement('div');
|
|
419
|
-
temporaryHtmlElement.appendChild(highlightRange.cloneContents());
|
|
420
|
-
for (const element of temporaryHtmlElement.querySelectorAll(`a, [data-highlight-id]:not([data-highlight-id="${highlightId}"])`)) element.outerHTML = element.innerHTML;
|
|
421
|
-
rangeText = highlightRange.toString();
|
|
422
|
-
rangeHtml = temporaryHtmlElement.innerHTML;
|
|
423
|
-
let startParagraphIndex = annotatableParagraphIds.indexOf(startParagraphId);
|
|
424
|
-
let endParagraphIndex = annotatableParagraphIds.indexOf(endParagraphId);
|
|
425
|
-
if (startParagraphIndex === -1) startParagraphIndex = 0;
|
|
426
|
-
if (endParagraphIndex === -1) endParagraphIndex = annotatableParagraphIds.length - 1;
|
|
427
|
-
rangeParagraphIds = annotatableParagraphIds.slice(startParagraphIndex, endParagraphIndex + 1);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// If there are no valid changes, return
|
|
431
|
-
if (!highlightRange || highlightRange.toString() === '' || appearanceChanges.length + boundsChanges.length === 0) return;
|
|
432
|
-
|
|
433
|
-
// Update saved highlight info
|
|
434
|
-
const newHighlightInfo = {
|
|
435
|
-
highlightId: highlightId,
|
|
436
|
-
color: attributes?.color ?? oldHighlightInfo?.color ?? options.defaultColor,
|
|
437
|
-
style: attributes?.style ?? oldHighlightInfo?.style ?? options.defaultStyle,
|
|
438
|
-
wrapper: attributes?.wrapper ?? oldHighlightInfo?.wrapper ?? options.defaultWrapper,
|
|
439
|
-
wrapperVariables: attributes?.wrapperVariables ?? oldHighlightInfo?.wrapperVariables ?? {},
|
|
440
|
-
readOnly: attributes?.readOnly ?? oldHighlightInfo?.readOnly ?? false,
|
|
441
|
-
startParagraphId: startParagraphId ?? oldHighlightInfo?.startParagraphId,
|
|
442
|
-
startParagraphOffset: startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset,
|
|
443
|
-
endParagraphId: endParagraphId ?? oldHighlightInfo?.endParagraphId,
|
|
444
|
-
endParagraphOffset: endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset,
|
|
445
|
-
// Read-only properties
|
|
446
|
-
escapedHighlightId: CSS.escape(highlightId),
|
|
447
|
-
rangeText: rangeText ?? oldHighlightInfo?.rangeText,
|
|
448
|
-
rangeHtml: rangeHtml ?? oldHighlightInfo?.rangeHtml,
|
|
449
|
-
rangeParagraphIds: rangeParagraphIds ?? oldHighlightInfo?.rangeParagraphIds,
|
|
450
|
-
rangeObj: highlightRange ?? oldHighlightInfo?.rangeObj,
|
|
451
|
-
};
|
|
452
|
-
highlightsById[highlightId] = newHighlightInfo;
|
|
453
|
-
|
|
454
|
-
const detail = {
|
|
455
|
-
highlight: newHighlightInfo,
|
|
456
|
-
changes: appearanceChanges.concat(boundsChanges),
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (triggeredByUserAction && !activeHighlightId) {
|
|
460
|
-
this.activateHighlight(highlightId);
|
|
461
|
-
} else if (activeHighlightId && highlightId === activeHighlightId && appearanceChanges.length > 0) {
|
|
462
|
-
updateSelectionUi('appearance');
|
|
463
|
-
} else if (highlightId !== activeHighlightId) {
|
|
464
|
-
this.drawHighlights([highlightId]);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
if (isNewHighlight) {
|
|
468
|
-
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightcreate', { detail: detail }));
|
|
469
|
-
} else {
|
|
470
|
-
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightupdate', { detail: detail }));
|
|
471
|
-
}
|
|
8
|
+
/********************** Highlighter Initialization **********************/
|
|
9
|
+
|
|
10
|
+
function Highlighter(options = _defaultOptions) {
|
|
11
|
+
for (const key of Object.keys(_defaultOptions)) {
|
|
12
|
+
options[key] = options[key] ?? _defaultOptions[key];
|
|
472
13
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
14
|
+
this._options = options;
|
|
15
|
+
|
|
16
|
+
// DOM references
|
|
17
|
+
this._annotatableContainer = null;
|
|
18
|
+
this._relativeAncestorElement = null;
|
|
19
|
+
this._annotatableParagraphs = null;
|
|
20
|
+
this._svgBackground = null;
|
|
21
|
+
this._svgActiveOverlay = null;
|
|
22
|
+
this._selectionHandles = null;
|
|
23
|
+
|
|
24
|
+
// Stylesheets
|
|
25
|
+
this._stylesheets = null;
|
|
26
|
+
this._generalStylesheet = null;
|
|
27
|
+
this._appearanceStylesheet = null;
|
|
28
|
+
this._highlightApiStylesheet = null;
|
|
29
|
+
this._selectionStylesheet = null;
|
|
30
|
+
|
|
31
|
+
// Data and state
|
|
32
|
+
this._annotatableParagraphIds = null;
|
|
33
|
+
this._hyperlinkElements = null;
|
|
34
|
+
this._hyperlinksByPosition = null;
|
|
35
|
+
this._highlightsById = null;
|
|
36
|
+
|
|
37
|
+
// Abort controller
|
|
38
|
+
this._controller = null;
|
|
39
|
+
|
|
40
|
+
const isInitialized = this._initializeHighlighter();
|
|
41
|
+
if (!isInitialized) return;
|
|
42
|
+
|
|
43
|
+
// Interaction state
|
|
44
|
+
this._activeHighlightId = undefined;
|
|
45
|
+
this._previousSelectionRange = undefined;
|
|
46
|
+
this._activeSelectionHandle = undefined;
|
|
47
|
+
this._dragAnchorNode = undefined;
|
|
48
|
+
this._dragAnchorOffset = undefined;
|
|
49
|
+
this._pointerType = undefined;
|
|
50
|
+
this._tapResult = undefined;
|
|
51
|
+
this._doubleTapTimeoutId = undefined;
|
|
52
|
+
this._longPressTimeoutId = undefined;
|
|
53
|
+
this._allowHyperlinkClick = false;
|
|
54
|
+
|
|
55
|
+
this._loadStyles();
|
|
56
|
+
this._loadEventListeners();
|
|
57
|
+
|
|
58
|
+
this._updateAppearanceStylesheet();
|
|
59
|
+
this._updateSelectionUi('appearance');
|
|
60
|
+
this.setOption('selectionHandles', this._options.selectionHandles);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Highlighter.prototype._initializeHighlighter = function (previousContainerSelector = null) {
|
|
64
|
+
const options = this._options;
|
|
65
|
+
if (!options.paragraphSelector.includes(options.containerSelector)) {
|
|
66
|
+
const paragraphSelectorList = options.paragraphSelector.split(',').map(selector => `${options.containerSelector} ${selector}`);
|
|
67
|
+
options.paragraphSelector = paragraphSelectorList.join(',');
|
|
502
68
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
this.deactivateHighlights = (removeSelectionRanges = true) => {
|
|
515
|
-
const deactivatedHighlight = highlightsById[activeHighlightId];
|
|
516
|
-
activeHighlightId = null;
|
|
517
|
-
previousSelectionRange = null;
|
|
518
|
-
const selection = window.getSelection();
|
|
519
|
-
if (removeSelectionRanges && selection.anchorNode && this.annotatableContainer.contains(selection.anchorNode)) {
|
|
520
|
-
selection.collapseToStart();
|
|
521
|
-
}
|
|
522
|
-
updateSelectionUi('appearance');
|
|
523
|
-
if (deactivatedHighlight) {
|
|
524
|
-
this.drawHighlights([deactivatedHighlight.highlightId]);
|
|
525
|
-
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: {
|
|
526
|
-
highlight: deactivatedHighlight,
|
|
527
|
-
}}));
|
|
528
|
-
}
|
|
69
|
+
this._annotatableContainer = document.querySelector(options.containerSelector);
|
|
70
|
+
this._annotatableParagraphs = this._annotatableContainer.querySelectorAll(options.paragraphSelector);
|
|
71
|
+
this._annotatableParagraphIds = Array.from(this._annotatableParagraphs, paragraph => paragraph.id);
|
|
72
|
+
|
|
73
|
+
// Handle cases where a highlighter already exists for the container, or one of its children or ancestors
|
|
74
|
+
const previousContainer = document.querySelector(previousContainerSelector) ?? this._annotatableContainer;
|
|
75
|
+
if (previousContainer.highlighter) {
|
|
76
|
+
previousContainer.highlighter.removeHighlighter();
|
|
77
|
+
} else if (this._annotatableContainer.closest('[data-hh-container]') || this._annotatableContainer.querySelector('[data-hh-container]')) {
|
|
78
|
+
console.error(`Unable to create Highlighter with container selector "${options.containerSelector}" (annotatable container can't be an child or ancestor of another annotatable container).`);
|
|
79
|
+
return false;
|
|
529
80
|
}
|
|
530
|
-
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
highlightId: highlightId,
|
|
540
|
-
}}));
|
|
541
|
-
undrawHighlight(highlightInfo);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
81
|
+
|
|
82
|
+
// Get the closest ancestor element with `position: relative`, or the root element (this is used to calculate the position of selection handles and SVG highlights)
|
|
83
|
+
let ancestorElement = this._annotatableContainer;
|
|
84
|
+
while (ancestorElement) {
|
|
85
|
+
if (ancestorElement === document.documentElement || globalThis.getComputedStyle(ancestorElement).position === 'relative') {
|
|
86
|
+
this._relativeAncestorElement = ancestorElement;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
ancestorElement = ancestorElement.parentElement;
|
|
544
90
|
}
|
|
545
|
-
|
|
546
|
-
//
|
|
547
|
-
this.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
updateAppearanceStylesheet();
|
|
575
|
-
if (supportsHighlightApi) CSS.highlights.clear();
|
|
576
|
-
this.drawHighlights();
|
|
577
|
-
} else if (key === 'colors') {
|
|
578
|
-
updateAppearanceStylesheet();
|
|
579
|
-
} else if (key === 'containerSelector') {
|
|
580
|
-
this.removeHighlighter();
|
|
581
|
-
initializeHighlighter();
|
|
582
|
-
} else if (key === 'paragraphSelector') {
|
|
583
|
-
initializeHighlighter();
|
|
584
|
-
} else if (key === 'selectionHandles') {
|
|
585
|
-
for (const selectionHandle of selectionHandles) {
|
|
586
|
-
selectionHandle.children[1].innerHTML = options.selectionHandles[selectionHandle.dataset.side] ?? '';
|
|
587
|
-
}
|
|
91
|
+
|
|
92
|
+
// Abort controller can be used to cancel event listeners if the highlighter is removed
|
|
93
|
+
this._controller = new AbortController;
|
|
94
|
+
|
|
95
|
+
// Setting tabIndex -1 on <body> allows focus to be set programmatically (needed to initialize text selection in iOS Safari). It also prevents "tap to search" from interfering with text selection in Android Chrome.
|
|
96
|
+
document.body.tabIndex = -1;
|
|
97
|
+
|
|
98
|
+
// Set up SVG background and selection handles
|
|
99
|
+
this._svgBackground = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
100
|
+
this._svgActiveOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
101
|
+
this._svgActiveOverlay.dataset.activeOverlay = '';
|
|
102
|
+
this._svgBackground.appendChild(this._svgActiveOverlay);
|
|
103
|
+
this._svgBackground.classList.add('hh-svg-background');
|
|
104
|
+
this._annotatableContainer.appendChild(this._svgBackground);
|
|
105
|
+
this._annotatableContainer.insertAdjacentHTML('beforeend', `
|
|
106
|
+
<div class="hh-selection-handle" data-side="left" data-position="start" data-hh-ignore=""><div draggable="true"></div><div class="hh-selection-handle-content"></div></div>
|
|
107
|
+
<div class="hh-selection-handle" data-side="right" data-position="end" data-hh-ignore=""><div draggable="true"></div><div class="hh-selection-handle-content"></div></div>
|
|
108
|
+
`);
|
|
109
|
+
this._selectionHandles = this._annotatableContainer.getElementsByClassName('hh-selection-handle');
|
|
110
|
+
|
|
111
|
+
// Check for hyperlinks on the page
|
|
112
|
+
this._hyperlinkElements = this._annotatableContainer.getElementsByTagName('a');
|
|
113
|
+
this._hyperlinksByPosition = {}
|
|
114
|
+
for (let hyp = 0; hyp < this._hyperlinkElements.length; hyp++) {
|
|
115
|
+
this._hyperlinksByPosition[hyp] = {
|
|
116
|
+
'position': hyp,
|
|
117
|
+
'text': this._hyperlinkElements[hyp].innerHTML,
|
|
118
|
+
'url': this._hyperlinkElements[hyp].href,
|
|
119
|
+
'hyperlinkElement': this._hyperlinkElements[hyp],
|
|
588
120
|
}
|
|
589
121
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
this.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
122
|
+
|
|
123
|
+
this._highlightsById = {};
|
|
124
|
+
this._annotatableContainer.dataset.hhContainer = '';
|
|
125
|
+
this._annotatableContainer.highlighter = this;
|
|
126
|
+
_highlighters.push(this);
|
|
127
|
+
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
Highlighter.prototype._loadStyles = function () {
|
|
132
|
+
const options = this._options;
|
|
133
|
+
|
|
134
|
+
// Set up stylesheets
|
|
135
|
+
this._stylesheets = {}
|
|
136
|
+
this._generalStylesheet = addStylesheet(this._stylesheets, 'general');
|
|
137
|
+
this._appearanceStylesheet = addStylesheet(this._stylesheets, 'appearance');
|
|
138
|
+
this._highlightApiStylesheet = addStylesheet(this._stylesheets, 'highlight-api');
|
|
139
|
+
this._selectionStylesheet = addStylesheet(this._stylesheets, 'selection');
|
|
140
|
+
this._generalStylesheet.replaceSync(`
|
|
141
|
+
${options.containerSelector} {
|
|
142
|
+
-webkit-tap-highlight-color: transparent;
|
|
143
|
+
}
|
|
144
|
+
.hh-wrapper-start, .hh-wrapper-end, .hh-selection-handle {
|
|
145
|
+
-webkit-user-select: none;
|
|
146
|
+
user-select: none;
|
|
147
|
+
}
|
|
148
|
+
.hh-selection-handle {
|
|
149
|
+
position: absolute;
|
|
150
|
+
width: 0px;
|
|
151
|
+
visibility: hidden;
|
|
152
|
+
}
|
|
153
|
+
[data-hh-pointer-down="true"] .hh-selection-handle {
|
|
154
|
+
visibility: hidden !important;
|
|
155
|
+
}
|
|
156
|
+
.hh-selection-handle-content {
|
|
157
|
+
position: absolute;
|
|
158
|
+
height: 100%;
|
|
159
|
+
}
|
|
160
|
+
.hh-selection-handle [draggable] {
|
|
161
|
+
position: absolute;
|
|
162
|
+
top: -0.3em;
|
|
163
|
+
width: 3.2em;
|
|
164
|
+
height: calc(100% + 1.3em);
|
|
165
|
+
background-color: transparent;
|
|
166
|
+
z-index: 1;
|
|
167
|
+
}
|
|
168
|
+
.hh-selection-handle[data-side="left"] [draggable] { right: -2em; }
|
|
169
|
+
.hh-selection-handle[data-side="right"] [draggable] { left: -2em; }
|
|
170
|
+
.hh-default-handle {
|
|
171
|
+
position: absolute;
|
|
172
|
+
width: 0.8em;
|
|
173
|
+
height: min(1.2em, 100%);
|
|
174
|
+
background-color: hsl(from var(--hh-color) h 80% 40% / 1);
|
|
175
|
+
outline: 0.1em solid hsla(0, 0%, 100%, 0.8);
|
|
176
|
+
outline-offset: -0.05em;
|
|
177
|
+
bottom: -0.2em;
|
|
178
|
+
}
|
|
179
|
+
.hh-selection-handle[data-side="left"] .hh-default-handle {
|
|
180
|
+
right: 0;
|
|
181
|
+
border-radius: 1em 0 0.6em 0.6em;
|
|
182
|
+
}
|
|
183
|
+
.hh-selection-handle[data-side="right"] .hh-default-handle {
|
|
184
|
+
left: 0;
|
|
185
|
+
border-radius: 0 1em 0.6em 0.6em;
|
|
186
|
+
}
|
|
187
|
+
.hh-svg-background {
|
|
188
|
+
position: absolute;
|
|
189
|
+
top: 0;
|
|
190
|
+
left: 0;
|
|
191
|
+
width: 100%;
|
|
192
|
+
overflow: visible;
|
|
193
|
+
z-index: -1;
|
|
194
|
+
}
|
|
195
|
+
.hh-svg-background g {
|
|
196
|
+
fill: transparent;
|
|
197
|
+
stroke: none;
|
|
198
|
+
}
|
|
199
|
+
mark[data-highlight-id] {
|
|
200
|
+
background-color: transparent;
|
|
201
|
+
color: inherit;
|
|
202
|
+
}
|
|
203
|
+
`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
Highlighter.prototype._loadEventListeners = function () {
|
|
207
|
+
const options = this._options;
|
|
208
|
+
|
|
611
209
|
// Selection change in document (new selection, change in selection range, or selection collapsing to a caret)
|
|
612
|
-
document.addEventListener('selectionchange', (event) => respondToSelectionChange(event), { signal: controller.signal });
|
|
613
210
|
const respondToSelectionChange = (event) => {
|
|
614
|
-
const selection =
|
|
211
|
+
const selection = this._getRestoredSelectionOrCaret(globalThis.getSelection());
|
|
615
212
|
const selectionRange = selection.type === 'None' ? null : selection.getRangeAt(0);
|
|
616
|
-
|
|
213
|
+
|
|
617
214
|
// In "Mac (Designed for iPad)" apps (iPad app running on macOS – most recently tested with macOS Sequoia 15.3.1), in-app webviews have several quirks related to text selection. One of these is text selection collapsing to a caret more often than expected. This code attempts to restore the previous selection range if it unexpectedly collapses to a caret in these scenarios:
|
|
618
215
|
// 1. While dragging custom selection handles (happens randomly). TODO: Dragging custom selection handles in this environment is still sometimes a little jumpy.
|
|
619
216
|
// 2. Just after clicking to activate a highlight (happens if it's the first click after the page loads).
|
|
620
|
-
if (isWKWebView && !isTouchDevice && selection.type !== 'Range' &&
|
|
621
|
-
selection.setBaseAndExtent(
|
|
217
|
+
if (isWKWebView && !isTouchDevice && selection.type !== 'Range' && this._previousSelectionRange && (this._activeSelectionHandle || (this._previousSelectionRange.compareBoundaryPoints(Range.END_TO_START, selectionRange) <= 0 && this._previousSelectionRange.compareBoundaryPoints(Range.END_TO_END, selectionRange) >= 0))) {
|
|
218
|
+
selection.setBaseAndExtent(this._previousSelectionRange.startContainer, this._previousSelectionRange.startOffset, this._previousSelectionRange.endContainer, this._previousSelectionRange.endOffset);
|
|
622
219
|
}
|
|
623
|
-
|
|
220
|
+
|
|
624
221
|
// Deactivate highlights when tapping or creating a selection outside of the previous selection range
|
|
625
|
-
if (!
|
|
222
|
+
if (!this._activeSelectionHandle && this._previousSelectionRange && (selection.type !== 'Range' || this._previousSelectionRange.comparePoint(selectionRange.startContainer, selectionRange.startOffset) === 1 || this._previousSelectionRange.comparePoint(selectionRange.endContainer, selectionRange.endOffset) === -1)) {
|
|
626
223
|
this.deactivateHighlights(false);
|
|
627
224
|
}
|
|
628
|
-
|
|
225
|
+
|
|
629
226
|
if (selection.type === 'Range') {
|
|
630
227
|
// Clear tap result (prevents hh:tap event from being sent when long-pressing or dragging to select text)
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (this.
|
|
634
|
-
if (
|
|
635
|
-
this.createOrUpdateHighlight({ highlightId:
|
|
228
|
+
this._tapResult = null;
|
|
229
|
+
|
|
230
|
+
if (this._annotatableContainer.contains(selection.anchorNode)) {
|
|
231
|
+
if (this._activeHighlightId || (options.pointerMode === 'live' || (options.pointerMode === 'auto' && this._pointerType === 'pen'))) {
|
|
232
|
+
this.createOrUpdateHighlight({ highlightId: this._activeHighlightId, });
|
|
636
233
|
}
|
|
637
|
-
|
|
234
|
+
this._previousSelectionRange = selectionRange.cloneRange();
|
|
638
235
|
}
|
|
639
236
|
}
|
|
640
|
-
|
|
641
|
-
|
|
237
|
+
|
|
238
|
+
this._updateSelectionUi('bounds');
|
|
642
239
|
}
|
|
643
|
-
|
|
240
|
+
document.addEventListener('selectionchange', (event) => respondToSelectionChange(event), { signal: this._controller.signal });
|
|
241
|
+
|
|
644
242
|
// Pointer down in annotatable container
|
|
645
|
-
this.annotatableContainer.addEventListener('pointerdown', (event) => respondToPointerDown(event), { signal: controller.signal });
|
|
646
243
|
const respondToPointerDown = (event) => {
|
|
647
244
|
const isSecondaryClick = (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey);
|
|
648
|
-
if (!isSecondaryClick) this.
|
|
649
|
-
|
|
650
|
-
|
|
245
|
+
if (!isSecondaryClick) this._annotatableContainer.dataset.hhPointerDown = 'true';
|
|
246
|
+
this._pointerType = event.pointerType;
|
|
247
|
+
|
|
651
248
|
// Pointer down on a selection handle
|
|
652
249
|
if (event.target?.closest('.hh-selection-handle')) {
|
|
653
250
|
if (!isSecondaryClick) {
|
|
654
|
-
|
|
655
|
-
const selectionHandleClientRect =
|
|
251
|
+
this._activeSelectionHandle = event.target.parentElement.closest('.hh-selection-handle');
|
|
252
|
+
const selectionHandleClientRect = this._activeSelectionHandle.getBoundingClientRect();
|
|
656
253
|
const lineHeight = selectionHandleClientRect.bottom - selectionHandleClientRect.top;
|
|
657
|
-
|
|
658
|
-
const selectionRange =
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
this.
|
|
662
|
-
|
|
254
|
+
this._activeSelectionHandle.dataset.dragYOffset = Math.max(0, event.clientY - selectionHandleClientRect.bottom + (lineHeight / 6));
|
|
255
|
+
const selectionRange = globalThis.getSelection().getRangeAt(0);
|
|
256
|
+
this._dragAnchorNode = this._activeSelectionHandle.dataset.position === 'start' ? selectionRange.endContainer : selectionRange.startContainer;
|
|
257
|
+
this._dragAnchorOffset = this._activeSelectionHandle.dataset.position === 'start' ? selectionRange.endOffset : selectionRange.startOffset;
|
|
258
|
+
this._annotatableContainer.addEventListener('pointermove', respondToSelectionHandleDrag, { signal: this._controller.signal });
|
|
259
|
+
this._updateSelectionUi('bounds');
|
|
663
260
|
}
|
|
664
261
|
// Prevent default drag interaction (which would show a thumbnail and drag selected text)
|
|
665
262
|
return event.preventDefault();
|
|
666
263
|
}
|
|
667
|
-
|
|
264
|
+
|
|
668
265
|
// Deactivate highlights and return on double-tap. This fixes a bug where double-tapping and holding a word in a highlight in iOS Safari caused the highlight to activate then shrink to the selected word.
|
|
669
|
-
if (
|
|
266
|
+
if (this._doubleTapTimeoutId) {
|
|
670
267
|
return this.deactivateHighlights();
|
|
671
268
|
} else {
|
|
672
|
-
|
|
269
|
+
this._doubleTapTimeoutId = setTimeout(() => this._doubleTapTimeoutId = clearTimeout(this._doubleTapTimeoutId), 500);
|
|
673
270
|
}
|
|
674
|
-
|
|
271
|
+
|
|
675
272
|
// Return if it's not a regular click, or if the user is tapping away from an existing selection
|
|
676
|
-
if (
|
|
677
|
-
|
|
273
|
+
if (this._previousSelectionRange || isSecondaryClick) return;
|
|
274
|
+
|
|
678
275
|
// Trigger a long-press event if the user doesn't lift their finger within the specified time
|
|
679
|
-
if (options.longPressTimeout)
|
|
680
|
-
|
|
681
|
-
|
|
276
|
+
if (options.longPressTimeout) this._longPressTimeoutId = setTimeout(() => respondToLongPress(event), options.longPressTimeout);
|
|
277
|
+
|
|
278
|
+
this._tapResult = this._checkForTapTargets(event);
|
|
682
279
|
}
|
|
683
|
-
|
|
280
|
+
this._annotatableContainer.addEventListener('pointerdown', (event) => respondToPointerDown(event), { signal: this._controller.signal });
|
|
281
|
+
|
|
684
282
|
// Selection handle drag (this function is added as an event listener on pointerdown, and removed on pointerup)
|
|
685
283
|
const respondToSelectionHandleDrag = (event) => {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
const selection =
|
|
284
|
+
this._activeSelectionHandle.dataset.pointerXPosition = event.clientX;
|
|
285
|
+
this._activeSelectionHandle.dataset.pointerYPosition = event.clientY;
|
|
286
|
+
const selection = globalThis.getSelection();
|
|
689
287
|
const selectionRange = selection.getRangeAt(0);
|
|
690
|
-
const dragCaret =
|
|
691
|
-
|
|
288
|
+
const dragCaret = this._getCaretFromCoordinates(event.clientX, event.clientY - this._activeSelectionHandle.dataset.dragYOffset, true, false, true);
|
|
289
|
+
|
|
692
290
|
// Return if there's no drag caret, if the drag caret is invalid, or if the drag caret and anchor caret have the same position
|
|
693
|
-
if (!dragCaret || dragCaret.startContainer.nodeType !== Node.TEXT_NODE || dragCaret.endContainer.nodeType !== Node.TEXT_NODE || (
|
|
694
|
-
|
|
291
|
+
if (!dragCaret || dragCaret.startContainer.nodeType !== Node.TEXT_NODE || dragCaret.endContainer.nodeType !== Node.TEXT_NODE || (this._dragAnchorNode === dragCaret.endContainer && this._dragAnchorOffset === dragCaret.endOffset)) return;
|
|
292
|
+
|
|
695
293
|
// Check if start and end selection handles switched positions
|
|
696
294
|
const dragPositionRelativeToSelectionStart = dragCaret.compareBoundaryPoints(Range.START_TO_END, selectionRange);
|
|
697
295
|
const dragPositionRelativeToSelectionEnd = dragCaret.compareBoundaryPoints(Range.END_TO_END, selectionRange);
|
|
698
|
-
if (
|
|
699
|
-
for (const selectionHandle of
|
|
296
|
+
if (this._activeSelectionHandle.dataset.position === 'start' && dragPositionRelativeToSelectionEnd === 1 || this._activeSelectionHandle.dataset.position === 'end' && dragPositionRelativeToSelectionStart === -1) {
|
|
297
|
+
for (const selectionHandle of this._selectionHandles) {
|
|
700
298
|
selectionHandle.dataset.position = selectionHandle.dataset.position === 'start' ? 'end' : 'start';
|
|
701
299
|
}
|
|
702
300
|
}
|
|
703
|
-
|
|
301
|
+
|
|
704
302
|
// Update selection
|
|
705
|
-
selection.setBaseAndExtent(
|
|
303
|
+
selection.setBaseAndExtent(this._dragAnchorNode, this._dragAnchorOffset, dragCaret.endContainer, dragCaret.endOffset);
|
|
706
304
|
}
|
|
707
|
-
|
|
305
|
+
|
|
708
306
|
// Long press in annotatable container (triggered by setTimeout() in pointerdown event)
|
|
709
307
|
const respondToLongPress = (event) => {
|
|
710
308
|
respondToPointerUp(event, true);
|
|
711
|
-
|
|
309
|
+
this._tapResult = null;
|
|
712
310
|
}
|
|
713
|
-
|
|
311
|
+
|
|
714
312
|
// Pointer up in annotatable container
|
|
715
|
-
this.annotatableContainer.addEventListener('pointerup', (event) => respondToPointerUp(event), { signal: controller.signal });
|
|
716
313
|
const respondToPointerUp = (event, isLongPress = false) => {
|
|
717
|
-
if (
|
|
718
|
-
|
|
719
|
-
this.
|
|
720
|
-
if (options.autoTapToActivate &&
|
|
721
|
-
if (
|
|
722
|
-
return this.activateHighlight(
|
|
723
|
-
} else if (
|
|
724
|
-
return this.activateHyperlink(
|
|
725
|
-
} else if (
|
|
726
|
-
return this.
|
|
314
|
+
if (this._tapResult) {
|
|
315
|
+
this._tapResult.isLongPress = isLongPress;
|
|
316
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:tap', { detail: this._tapResult, }));
|
|
317
|
+
if (options.autoTapToActivate && this._tapResult?.targetFound && !isLongPress) {
|
|
318
|
+
if (this._tapResult.highlights.length === 1 && this._tapResult.hyperlinks.length === 0) {
|
|
319
|
+
return this.activateHighlight(this._tapResult.highlights[0].highlightId);
|
|
320
|
+
} else if (this._tapResult.highlights.length === 0 && this._tapResult.hyperlinks.length === 1) {
|
|
321
|
+
return this.activateHyperlink(this._tapResult.hyperlinks[0].position);
|
|
322
|
+
} else if (this._tapResult.highlights.length + this._tapResult.hyperlinks.length > 1) {
|
|
323
|
+
return this._annotatableContainer.dispatchEvent(new CustomEvent('hh:ambiguousaction', { detail: this._tapResult, }));
|
|
727
324
|
}
|
|
728
325
|
}
|
|
729
326
|
}
|
|
730
327
|
}
|
|
731
|
-
|
|
328
|
+
this._annotatableContainer.addEventListener('pointerup', (event) => respondToPointerUp(event), { signal: this._controller.signal });
|
|
329
|
+
|
|
732
330
|
// Pointer up or cancel in window
|
|
733
|
-
window.addEventListener('pointerup', (event) => respondToWindowPointerUp(event), { signal: controller.signal });
|
|
734
|
-
window.addEventListener('pointercancel', (event) => respondToWindowPointerUp(event), { signal: controller.signal });
|
|
735
331
|
const respondToWindowPointerUp = (event) => {
|
|
736
|
-
const selection =
|
|
737
|
-
if (selection.type === 'Range' &&
|
|
738
|
-
const adjustedSelectionRange =
|
|
739
|
-
|
|
332
|
+
const selection = globalThis.getSelection();
|
|
333
|
+
if (selection.type === 'Range' && this._activeHighlightId && this._annotatableContainer.contains(selection.anchorNode)) {
|
|
334
|
+
const adjustedSelectionRange = this._snapRangeToBoundaries(selection.getRangeAt(0), selection.anchorNode);
|
|
335
|
+
|
|
740
336
|
// Update the selection range if needed
|
|
741
337
|
// In Android Chrome, sometimes selection handles don't show when a selection is updated programmatically, so it's best to only update the selection if needed.
|
|
742
338
|
const selectionRange = selection.getRangeAt(0);
|
|
743
339
|
if (!(
|
|
744
|
-
adjustedSelectionRange.startContainer === selectionRange.startContainer &&
|
|
745
|
-
adjustedSelectionRange.startOffset === selectionRange.startOffset &&
|
|
746
|
-
adjustedSelectionRange.endContainer === selectionRange.endContainer &&
|
|
340
|
+
adjustedSelectionRange.startContainer === selectionRange.startContainer &&
|
|
341
|
+
adjustedSelectionRange.startOffset === selectionRange.startOffset &&
|
|
342
|
+
adjustedSelectionRange.endContainer === selectionRange.endContainer &&
|
|
747
343
|
adjustedSelectionRange.endOffset === selectionRange.endOffset)
|
|
748
344
|
) {
|
|
749
345
|
selection.setBaseAndExtent(adjustedSelectionRange.startContainer, adjustedSelectionRange.startOffset, adjustedSelectionRange.endContainer, adjustedSelectionRange.endOffset);
|
|
750
346
|
}
|
|
751
347
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
this.
|
|
755
|
-
if (
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
this.
|
|
348
|
+
this._tapResult = null;
|
|
349
|
+
this._longPressTimeoutId = clearTimeout(this._longPressTimeoutId);
|
|
350
|
+
this._annotatableContainer.dataset.hhPointerDown = 'false';
|
|
351
|
+
if (this._activeSelectionHandle) {
|
|
352
|
+
this._activeSelectionHandle = null;
|
|
353
|
+
this._updateSelectionUi('bounds');
|
|
354
|
+
this._annotatableContainer.removeEventListener('pointermove', respondToSelectionHandleDrag);
|
|
759
355
|
}
|
|
760
356
|
}
|
|
761
|
-
|
|
357
|
+
globalThis.addEventListener('pointerup', (event) => respondToWindowPointerUp(event), { signal: this._controller.signal });
|
|
358
|
+
globalThis.addEventListener('pointercancel', (event) => respondToWindowPointerUp(event), { signal: this._controller.signal });
|
|
359
|
+
|
|
762
360
|
// Hyperlink click (for each hyperlink in annotatable container)
|
|
763
|
-
for (const hyperlinkElement of
|
|
361
|
+
for (const hyperlinkElement of this._hyperlinkElements) {
|
|
764
362
|
hyperlinkElement.addEventListener('click', (event) => {
|
|
765
363
|
this.deactivateHighlights();
|
|
766
|
-
if (!
|
|
767
|
-
}, { signal:
|
|
364
|
+
if (!this._allowHyperlinkClick) event.preventDefault();
|
|
365
|
+
}, { signal: this._controller.signal });
|
|
768
366
|
}
|
|
769
|
-
|
|
367
|
+
|
|
770
368
|
// Annotatable container resize (debounced)
|
|
771
|
-
let computedStyle =
|
|
772
|
-
let previousWidth = Math.round(this.
|
|
773
|
-
|
|
369
|
+
let computedStyle = globalThis.getComputedStyle(this._annotatableContainer);
|
|
370
|
+
let previousWidth = Math.round(this._annotatableContainer.clientWidth - Number.parseInt(computedStyle.getPropertyValue('padding-left')) - Number.parseInt(computedStyle.getPropertyValue('padding-right')));
|
|
371
|
+
this._resizeObserver = new ResizeObserver(debounce((entries) => {
|
|
774
372
|
for (const entry of entries) {
|
|
775
373
|
const width = Math.round(entry.contentBoxSize[0].inlineSize);
|
|
776
374
|
// Only respond if the annotatable content width changed
|
|
777
375
|
if (width !== previousWidth) {
|
|
778
376
|
if (options.drawingMode === 'svg') this.drawHighlights();
|
|
779
|
-
if (
|
|
377
|
+
if (this._previousSelectionRange) this._updateSelectionUi('bounds');
|
|
780
378
|
previousWidth = width;
|
|
781
379
|
}
|
|
782
380
|
}
|
|
783
|
-
}, Math.floor(Object.keys(
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
381
|
+
}, Math.floor(Object.keys(this._highlightsById).length / 20)));
|
|
382
|
+
this._resizeObserver.observe(this._annotatableContainer);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
/********************** Public Methods **********************/
|
|
387
|
+
|
|
388
|
+
// Load highlights
|
|
389
|
+
Highlighter.prototype.loadHighlights = function (highlights) {
|
|
390
|
+
// Don't load highlights until the document is ready (otherwise, highlights may be offset)
|
|
391
|
+
if (document.readyState !== 'complete') return setTimeout(this.loadHighlights.bind(this), 10, highlights);
|
|
392
|
+
const startTimestamp = Date.now();
|
|
393
|
+
|
|
394
|
+
// Hide container (repeated DOM manipulations are faster if the container is hidden)
|
|
395
|
+
if (highlights.length > 1) {
|
|
396
|
+
let containerToHide = this._options.drawingMode === 'svg' ? this._svgBackground : this._annotatableContainer;
|
|
397
|
+
containerToHide.style.display = 'none';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Load read-only highlights first (read-only highlights change the DOM, affecting other highlights' ranges)
|
|
401
|
+
const sortedHighlights = highlights.sort((a,b) => a.readOnly === b.readOnly ? 0 : a.readOnly ? -1 : 1);
|
|
402
|
+
|
|
403
|
+
const knownHighlightIds = Object.keys(this._highlightsById);
|
|
404
|
+
let addedCount = 0, updatedCount = 0;
|
|
405
|
+
for (const highlight of sortedHighlights) {
|
|
406
|
+
const highlightInfo = this._diffHighlight(highlight, this._highlightsById[highlight.highlightId]);
|
|
407
|
+
highlightInfo.highlightId = highlight.highlightId;
|
|
408
|
+
const knownHighlightIndex = knownHighlightIds.indexOf(highlightInfo.highlightId);
|
|
409
|
+
if (knownHighlightIndex > -1) {
|
|
410
|
+
knownHighlightIds.splice(knownHighlightIndex, 1);
|
|
411
|
+
if (Object.keys(highlightInfo).length > 1) {
|
|
412
|
+
this.createOrUpdateHighlight(highlightInfo, false); updatedCount++;
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
this.createOrUpdateHighlight(highlightInfo, false); addedCount++;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (knownHighlightIds.length > 0) this.removeHighlights(knownHighlightIds);
|
|
419
|
+
|
|
420
|
+
(this._options.drawingMode === 'svg' ? this._svgBackground : this._annotatableContainer).style.display = '';
|
|
421
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightsload', { detail: {
|
|
422
|
+
addedCount: addedCount, removedCount: knownHighlightIds.length, updatedCount: updatedCount,
|
|
423
|
+
totalCount: Object.keys(this._highlightsById).length,
|
|
424
|
+
timeToLoad: Date.now() - startTimestamp,
|
|
425
|
+
} }));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Draw (or redraw) specified highlights, or all highlights on the page
|
|
429
|
+
Highlighter.prototype.drawHighlights = function (highlightIds = Object.keys(this._highlightsById)) {
|
|
430
|
+
const options = this._options;
|
|
431
|
+
|
|
432
|
+
// Hide container (repeated DOM manipulations is faster if the container is hidden)
|
|
433
|
+
if (highlightIds.length > 1) {
|
|
434
|
+
let containerToHide = options.drawingMode === 'svg' ? this._svgBackground : this._annotatableContainer;
|
|
435
|
+
containerToHide.style.display = 'none';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const highlightId of highlightIds) {
|
|
439
|
+
const highlightInfo = this._highlightsById[highlightId];
|
|
440
|
+
let range = this._getCorrectedRangeObj(highlightId);
|
|
441
|
+
const rangeParagraphs = this._annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
|
|
442
|
+
const wasDrawnAsReadOnly = this._annotatableContainer.querySelector(`[data-highlight-id="${highlightId}"][data-read-only]`);
|
|
443
|
+
|
|
444
|
+
// Remove old highlight elements and styles
|
|
445
|
+
if (!wasDrawnAsReadOnly || !highlightInfo.readOnly) this._undrawHighlight(highlightInfo);
|
|
446
|
+
// Don't redraw a read-only highlight that's already drawn
|
|
447
|
+
if (wasDrawnAsReadOnly) continue;
|
|
448
|
+
|
|
449
|
+
// Draw highlights with mark elements
|
|
450
|
+
if (highlightInfo.readOnly || options.drawingMode === 'mark-elements') {
|
|
451
|
+
// Inject HTML <mark> elements
|
|
452
|
+
if (range.startOffset < range.startContainer.length) range.startContainer.splitText(range.startOffset);
|
|
453
|
+
if (range.endOffset > 0 && range.endOffset < range.endContainer.length) range.endContainer.splitText(range.endOffset);
|
|
454
|
+
const walker = this._getTextNodeWalker(range.commonAncestorContainer);
|
|
455
|
+
walker.currentNode = range.startContainer;
|
|
456
|
+
const relevantTextNodes = [];
|
|
457
|
+
let node;
|
|
458
|
+
while (node = walker.nextNode()) {
|
|
459
|
+
if (!node.parentElement.closest('rt, rp')) relevantTextNodes.push(node);
|
|
460
|
+
if (node === range.endContainer) break;
|
|
461
|
+
}
|
|
462
|
+
const overlappingHighlightIds = new Set();
|
|
463
|
+
const createStyledMark = () => {
|
|
464
|
+
const mark = document.createElement('mark');
|
|
465
|
+
mark.dataset.highlightId = highlightId;
|
|
466
|
+
if (highlightInfo.readOnly) mark.dataset.readOnly = '';
|
|
467
|
+
mark.dataset.color = highlightInfo.color;
|
|
468
|
+
mark.dataset.style = highlightInfo.style;
|
|
469
|
+
return mark;
|
|
470
|
+
};
|
|
471
|
+
for (let tn = 0; tn < relevantTextNodes.length; tn++) {
|
|
472
|
+
const textNode = relevantTextNodes[tn];
|
|
473
|
+
if (textNode.parentElement.dataset.highlightId) overlappingHighlightIds.add(textNode.parentElement.dataset.highlightId);
|
|
474
|
+
const styledMark = createStyledMark();
|
|
475
|
+
if (tn === 0) styledMark.dataset.start = '';
|
|
476
|
+
if (tn === relevantTextNodes.length - 1) styledMark.dataset.end = '';
|
|
477
|
+
textNode.before(styledMark);
|
|
478
|
+
styledMark.appendChild(textNode);
|
|
479
|
+
// Absorb adjacent elements without text into the mark element. This prevents unstyled gaps around CSS-rendered pseudoelement content (such as superscript footnote markers).
|
|
480
|
+
let prev = styledMark.previousSibling;
|
|
481
|
+
while (prev && prev.nodeType === Node.ELEMENT_NODE && prev.textContent === '') {
|
|
482
|
+
const toAbsorb = prev; prev = prev.previousSibling;
|
|
483
|
+
styledMark.prepend(toAbsorb);
|
|
484
|
+
}
|
|
485
|
+
if (tn < relevantTextNodes.length - 1) {
|
|
486
|
+
let next = styledMark.nextSibling;
|
|
487
|
+
while (next && next.nodeType === Node.ELEMENT_NODE && next.textContent === '') {
|
|
488
|
+
const toAbsorb = next; next = next.nextSibling;
|
|
489
|
+
styledMark.appendChild(toAbsorb);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Update highlight ranges that were invalidated by the DOM change
|
|
495
|
+
this._getCorrectedRangeObj(highlightId);
|
|
496
|
+
for (const overlappingHighlightId of overlappingHighlightIds) {
|
|
497
|
+
this._getCorrectedRangeObj(overlappingHighlightId);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Draw highlights with Custom Highlight API
|
|
501
|
+
} else if (options.drawingMode === 'highlight-api' && supportsHighlightApi) {
|
|
502
|
+
let highlightObj;
|
|
503
|
+
if (CSS.highlights.has(highlightId)) {
|
|
504
|
+
highlightObj = CSS.highlights.get(highlightId);
|
|
505
|
+
highlightObj.clear();
|
|
506
|
+
} else {
|
|
507
|
+
highlightObj = new Highlight();
|
|
508
|
+
CSS.highlights.set(highlightId, highlightObj);
|
|
509
|
+
}
|
|
510
|
+
highlightObj.add(range);
|
|
511
|
+
const styleTemplate = this._getStyleTemplate(highlightInfo.style, 'css', null);
|
|
512
|
+
const colorString = options.colors[highlightInfo.color];
|
|
513
|
+
this._highlightApiStylesheet.insertRule(`${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { --hh-color: ${colorString}; ${styleTemplate} }`);
|
|
514
|
+
this._highlightApiStylesheet.insertRule(`${options.containerSelector} rt::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }`);
|
|
515
|
+
this._highlightApiStylesheet.insertRule(`${options.containerSelector} img::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }`);
|
|
516
|
+
|
|
517
|
+
// Draw highlights with SVG shapes
|
|
518
|
+
} else if (options.drawingMode === 'svg') {
|
|
519
|
+
const clientRects = this._getMergedClientRects(range, rangeParagraphs);
|
|
520
|
+
let group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
521
|
+
group.dataset.highlightId = highlightId;
|
|
522
|
+
group.dataset.color = highlightInfo.color;
|
|
523
|
+
group.dataset.style = highlightInfo.style;
|
|
524
|
+
let svgContent = '';
|
|
525
|
+
for (const clientRect of clientRects) {
|
|
526
|
+
svgContent += this._getStyleTemplate(highlightInfo.style, 'svg', clientRect);
|
|
527
|
+
}
|
|
528
|
+
group.innerHTML = svgContent;
|
|
529
|
+
this._svgBackground.appendChild(group);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Update wrapper (for read-only highlights only)
|
|
533
|
+
if (highlightInfo.readOnly) {
|
|
534
|
+
if (highlightInfo.wrapper && (options.wrappers[highlightInfo.wrapper]?.start || options.wrappers[highlightInfo.wrapper]?.end)) {
|
|
535
|
+
const addWrapper = (edge, range, htmlString) => {
|
|
536
|
+
htmlString = `<span class="hh-wrapper-${edge}" data-highlight-id="${highlightId}" data-color="${highlightInfo.color}" data-style="${highlightInfo.style}" data-hh-ignore="">${htmlString}</span>`
|
|
537
|
+
for (const key of Object.keys(highlightInfo.wrapperVariables)) {
|
|
538
|
+
htmlString = htmlString.replaceAll(`{${key}}`, highlightInfo.wrapperVariables[key]);
|
|
539
|
+
}
|
|
540
|
+
const template = document.createElement('template');
|
|
541
|
+
template.innerHTML = htmlString;
|
|
542
|
+
let htmlElement = template.content.firstChild;
|
|
543
|
+
range.insertNode(htmlElement);
|
|
544
|
+
}
|
|
545
|
+
const startRange = highlightInfo.rangeObj;
|
|
546
|
+
const endRange = document.createRange(); endRange.setStart(highlightInfo.rangeObj.endContainer, highlightInfo.rangeObj.endOffset);
|
|
547
|
+
const wrapperInfo = options.wrappers[highlightInfo.wrapper];
|
|
548
|
+
addWrapper('start', startRange, wrapperInfo.start);
|
|
549
|
+
addWrapper('end', endRange, wrapperInfo.end);
|
|
550
|
+
rangeParagraphs.forEach(p => { p.normalize(); });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Show container
|
|
556
|
+
(options.drawingMode === 'svg' ? this._svgBackground : this._annotatableContainer).style.display = '';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Create a new highlight, or update an existing highlight when it changes
|
|
560
|
+
Highlighter.prototype.createOrUpdateHighlight = function (attributes = {}, triggeredByUserAction = true) {
|
|
561
|
+
const options = this._options;
|
|
562
|
+
let highlightId = attributes.highlightId ?? this._activeHighlightId ?? options.highlightIdFunction();
|
|
563
|
+
const appearanceChanges = [];
|
|
564
|
+
const boundsChanges = [];
|
|
565
|
+
|
|
566
|
+
const oldHighlightInfo = this._highlightsById[highlightId];
|
|
567
|
+
const isNewHighlight = !oldHighlightInfo;
|
|
568
|
+
|
|
569
|
+
// If a different highlight is active, deactivate it
|
|
570
|
+
if (this._activeHighlightId && highlightId !== this._activeHighlightId && triggeredByUserAction) {
|
|
571
|
+
this.deactivateHighlights();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// If the highlight is currently activate, ignore bounds changes that weren't initiated by the user
|
|
575
|
+
if (highlightId === this._activeHighlightId && !triggeredByUserAction) {
|
|
576
|
+
attributes.startParagraphId = null;
|
|
577
|
+
attributes.startParagraphOffset = null;
|
|
578
|
+
attributes.endParagraphId = null;
|
|
579
|
+
attributes.endParagraphOffset = null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Warn if color, style, or wrapper attributes are invalid
|
|
583
|
+
if (attributes.color && !Object.hasOwn(options.colors, attributes.color)) {
|
|
584
|
+
console.warn(`Highlight color "${attributes.color}" is not defined in options (highlightId: ${highlightId}).`);
|
|
585
|
+
}
|
|
586
|
+
if (attributes.style && !Object.hasOwn(options.styles, attributes.style)) {
|
|
587
|
+
console.warn(`Highlight style "${attributes.style}" is not defined in options (highlightId: ${highlightId}).`);
|
|
588
|
+
}
|
|
589
|
+
if (attributes.wrapper && !Object.hasOwn(options.wrappers, attributes.wrapper)) {
|
|
590
|
+
console.warn(`Highlight wrapper "${attributes.wrapper}" is not defined in options (highlightId: ${highlightId}).`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Update defaults
|
|
594
|
+
if (options.rememberStyle && triggeredByUserAction) {
|
|
595
|
+
if (attributes.color) options.defaultColor = attributes.color;
|
|
596
|
+
if (attributes.style) options.defaultStyle = attributes.style;
|
|
597
|
+
if (attributes.wrapper) options.defaultWrapper = attributes.wrapper;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Check which appearance properties changed
|
|
601
|
+
for (const key of ['color', 'style', 'wrapper', 'wrapperVariables', 'readOnly']) {
|
|
602
|
+
if (isNewHighlight || (attributes[key] != null && attributes[key] !== oldHighlightInfo[key])) appearanceChanges.push(key);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// If the highlight was and still is read-only, return
|
|
606
|
+
if (oldHighlightInfo?.readOnly && (attributes.readOnly == null || attributes.readOnly === true)) return this.deactivateHighlights();
|
|
607
|
+
|
|
608
|
+
// Calculate the bounds of the highlight range, if it's changed
|
|
609
|
+
let adjustedSelectionRange, highlightRange;
|
|
610
|
+
let rangeText, rangeHtml, rangeParagraphIds;
|
|
611
|
+
let startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset;
|
|
612
|
+
const selection = this._getRestoredSelectionOrCaret(globalThis.getSelection());
|
|
613
|
+
if (selection.type === 'Range') adjustedSelectionRange = this._snapRangeToBoundaries(selection.getRangeAt(0));
|
|
614
|
+
if ((attributes.startParagraphId ?? attributes.startParagraphOffset ?? attributes.endParagraphId ?? attributes.endParagraphOffset != null) || (adjustedSelectionRange && !adjustedSelectionRange.collapsed)) {
|
|
615
|
+
let startNode, startOffset, endNode, endOffset;
|
|
616
|
+
if (attributes.startParagraphId ?? attributes.startParagraphOffset ?? attributes.endParagraphId ?? attributes.endParagraphOffset != null) {
|
|
617
|
+
startParagraphId = attributes.startParagraphId ?? oldHighlightInfo?.startParagraphId;
|
|
618
|
+
startParagraphOffset = Number.parseInt(attributes.startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset);
|
|
619
|
+
endParagraphId = attributes.endParagraphId ?? oldHighlightInfo?.endParagraphId;
|
|
620
|
+
endParagraphOffset = Number.parseInt(attributes.endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset);
|
|
621
|
+
([ startNode, startOffset ] = this._getTextNodeAndOffset(document.getElementById(startParagraphId), startParagraphOffset));
|
|
622
|
+
([ endNode, endOffset ] = this._getTextNodeAndOffset(document.getElementById(endParagraphId), endParagraphOffset));
|
|
623
|
+
} else if (adjustedSelectionRange) {
|
|
624
|
+
startNode = adjustedSelectionRange.startContainer;
|
|
625
|
+
startOffset = adjustedSelectionRange.startOffset;
|
|
626
|
+
endNode = adjustedSelectionRange.endContainer;
|
|
627
|
+
endOffset = adjustedSelectionRange.endOffset;
|
|
628
|
+
([ startParagraphId, startParagraphOffset ] = this._getParagraphOffset(startNode, startOffset));
|
|
629
|
+
([ endParagraphId, endParagraphOffset ] = this._getParagraphOffset(endNode, endOffset));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Create a new highlight range
|
|
633
|
+
highlightRange = document.createRange();
|
|
634
|
+
highlightRange.setStart(startNode, startOffset);
|
|
635
|
+
highlightRange.setEnd(endNode, endOffset);
|
|
636
|
+
|
|
637
|
+
// Check which bounds properties changed
|
|
638
|
+
const boundsValues = { startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset };
|
|
639
|
+
for (const key of Object.keys(boundsValues)) {
|
|
640
|
+
if (isNewHighlight || boundsValues[key] !== oldHighlightInfo[key]) boundsChanges.push(key);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Set variables that depend on the range
|
|
644
|
+
const temporaryHtmlElement = document.createElement('div');
|
|
645
|
+
temporaryHtmlElement.appendChild(highlightRange.cloneContents());
|
|
646
|
+
for (const element of temporaryHtmlElement.querySelectorAll(`a, [data-highlight-id]:not([data-highlight-id="${highlightId}"])`)) element.outerHTML = element.innerHTML;
|
|
647
|
+
for (const element of temporaryHtmlElement.querySelectorAll(`[data-hh-ignore]`)) element.remove();
|
|
648
|
+
rangeText = temporaryHtmlElement.textContent;
|
|
649
|
+
rangeHtml = temporaryHtmlElement.innerHTML;
|
|
650
|
+
let startParagraphIndex = this._annotatableParagraphIds.indexOf(startParagraphId);
|
|
651
|
+
let endParagraphIndex = this._annotatableParagraphIds.indexOf(endParagraphId);
|
|
652
|
+
if (startParagraphIndex === -1) startParagraphIndex = 0;
|
|
653
|
+
if (endParagraphIndex === -1) endParagraphIndex = this._annotatableParagraphIds.length - 1;
|
|
654
|
+
rangeParagraphIds = this._annotatableParagraphIds.slice(startParagraphIndex, endParagraphIndex + 1);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// If there are no valid changes, return
|
|
658
|
+
if (!highlightRange || highlightRange.toString() === '' || appearanceChanges.length + boundsChanges.length === 0) return;
|
|
659
|
+
|
|
660
|
+
// Update saved highlight info
|
|
661
|
+
const newHighlightInfo = {
|
|
662
|
+
highlightId: highlightId,
|
|
663
|
+
color: attributes.color ?? oldHighlightInfo?.color ?? options.defaultColor,
|
|
664
|
+
style: attributes.style ?? oldHighlightInfo?.style ?? options.defaultStyle,
|
|
665
|
+
wrapper: attributes.wrapper ?? oldHighlightInfo?.wrapper ?? options.defaultWrapper,
|
|
666
|
+
wrapperVariables: attributes.wrapperVariables ?? oldHighlightInfo?.wrapperVariables ?? {},
|
|
667
|
+
readOnly: attributes.readOnly ?? oldHighlightInfo?.readOnly ?? false,
|
|
668
|
+
startParagraphId: startParagraphId ?? oldHighlightInfo?.startParagraphId,
|
|
669
|
+
startParagraphOffset: startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset,
|
|
670
|
+
endParagraphId: endParagraphId ?? oldHighlightInfo?.endParagraphId,
|
|
671
|
+
endParagraphOffset: endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset,
|
|
672
|
+
// Read-only properties
|
|
673
|
+
escapedHighlightId: CSS.escape(highlightId),
|
|
674
|
+
rangeText: rangeText ?? oldHighlightInfo?.rangeText,
|
|
675
|
+
rangeHtml: rangeHtml ?? oldHighlightInfo?.rangeHtml,
|
|
676
|
+
rangeParagraphIds: rangeParagraphIds ?? oldHighlightInfo?.rangeParagraphIds,
|
|
677
|
+
rangeObj: highlightRange ?? oldHighlightInfo?.rangeObj,
|
|
678
|
+
};
|
|
679
|
+
this._highlightsById[highlightId] = newHighlightInfo;
|
|
680
|
+
|
|
681
|
+
const detail = {
|
|
682
|
+
highlight: newHighlightInfo,
|
|
683
|
+
changes: appearanceChanges.concat(boundsChanges),
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (triggeredByUserAction && !this._activeHighlightId) {
|
|
687
|
+
this.activateHighlight(highlightId);
|
|
688
|
+
} else if (this._activeHighlightId && highlightId === this._activeHighlightId && appearanceChanges.length > 0) {
|
|
689
|
+
this._updateSelectionUi('appearance');
|
|
690
|
+
} else if (highlightId !== this._activeHighlightId) {
|
|
691
|
+
this.drawHighlights([highlightId]);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (isNewHighlight) {
|
|
695
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightcreate', { detail: detail }));
|
|
696
|
+
} else {
|
|
697
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightupdate', { detail: detail }));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Activate a highlight by ID
|
|
702
|
+
Highlighter.prototype.activateHighlight = function (highlightId) {
|
|
703
|
+
const highlightToActivate = this._highlightsById[highlightId];
|
|
704
|
+
if (highlightToActivate.readOnly) {
|
|
705
|
+
// If the highlight is read-only, return events, but don't actually activate it
|
|
706
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
|
|
707
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: { highlight: highlightToActivate } }));
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const selection = globalThis.getSelection();
|
|
711
|
+
const highlightRange = highlightToActivate.rangeObj.cloneRange();
|
|
712
|
+
this._activeHighlightId = highlightId;
|
|
713
|
+
this._updateSelectionUi('appearance');
|
|
714
|
+
this._updateSelectionUi('bounds');
|
|
715
|
+
|
|
716
|
+
// Update the selection range if needed
|
|
717
|
+
// In Android Chrome, sometimes selection handles don't show when a selection is updated programmatically, so it's best to only update the selection if needed.
|
|
718
|
+
let selectionRange = selection.type === 'Range' ? selection.getRangeAt(0) : null;
|
|
719
|
+
if (!selectionRange || !(
|
|
720
|
+
selectionRange.startContainer === highlightRange.startContainer &&
|
|
721
|
+
selectionRange.startOffset === highlightRange.startOffset &&
|
|
722
|
+
selectionRange.endContainer === highlightRange.endContainer &&
|
|
723
|
+
selectionRange.endOffset === highlightRange.endOffset)
|
|
724
|
+
) {
|
|
725
|
+
selection.setBaseAndExtent(highlightRange.startContainer, highlightRange.startOffset, highlightRange.endContainer, highlightRange.endOffset);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Activate a link by position
|
|
732
|
+
Highlighter.prototype.activateHyperlink = function (position) {
|
|
733
|
+
this.deactivateHighlights();
|
|
734
|
+
this._allowHyperlinkClick = true;
|
|
735
|
+
this._hyperlinksByPosition[position].hyperlinkElement.click();
|
|
736
|
+
this._allowHyperlinkClick = false;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Deactivate any highlights that are currently active/selected
|
|
740
|
+
Highlighter.prototype.deactivateHighlights = function (removeSelectionRanges = true) {
|
|
741
|
+
const deactivatedHighlight = this._highlightsById[this._activeHighlightId];
|
|
742
|
+
this._activeHighlightId = null;
|
|
743
|
+
this._previousSelectionRange = null;
|
|
744
|
+
const selection = globalThis.getSelection();
|
|
745
|
+
if (removeSelectionRanges && selection.anchorNode && this._annotatableContainer.contains(selection.anchorNode)) {
|
|
746
|
+
selection.collapseToStart();
|
|
747
|
+
}
|
|
748
|
+
this._updateSelectionUi('appearance');
|
|
749
|
+
if (deactivatedHighlight) {
|
|
750
|
+
this.drawHighlights([deactivatedHighlight.highlightId]);
|
|
751
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: {
|
|
752
|
+
highlight: deactivatedHighlight,
|
|
753
|
+
}}));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Remove the specified highlights, or all highlights on the page
|
|
758
|
+
Highlighter.prototype.removeHighlights = function (highlightIds = Object.keys(this._highlightsById)) {
|
|
759
|
+
this.deactivateHighlights();
|
|
760
|
+
for (const highlightId of highlightIds) {
|
|
761
|
+
const highlightInfo = this._highlightsById[highlightId];
|
|
762
|
+
if (highlightInfo) {
|
|
763
|
+
delete this._highlightsById[highlightId];
|
|
764
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightremove', { detail: {
|
|
765
|
+
highlightId: highlightId,
|
|
766
|
+
}}));
|
|
767
|
+
this._undrawHighlight(highlightInfo);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Get the active highlight ID (if there is one)
|
|
773
|
+
Highlighter.prototype.getActiveHighlightId = function () {
|
|
774
|
+
return this._activeHighlightId;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Get info for specified highlights, or all highlights on the page
|
|
778
|
+
Highlighter.prototype.getHighlightInfo = function (highlightIds = Object.keys(this._highlightsById), paragraphId = null) {
|
|
779
|
+
let filteredHighlights = []
|
|
780
|
+
for (const highlightId of highlightIds) {
|
|
781
|
+
const highlightInfo = this._highlightsById[highlightId];
|
|
782
|
+
if (!paragraphId || paragraphId === highlightInfo.startParagraphId) {
|
|
783
|
+
filteredHighlights.push(highlightInfo);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// Sort highlights based on their order on the page
|
|
787
|
+
if (filteredHighlights.length > 0) {
|
|
788
|
+
filteredHighlights.sort((a, b) => {
|
|
789
|
+
return (this._annotatableParagraphIds.indexOf(a.startParagraphId) - this._annotatableParagraphIds.indexOf(b.startParagraphId)) || (a.startParagraphOffset - b.startParagraphOffset);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
return filteredHighlights;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Update one of the initialized options
|
|
796
|
+
Highlighter.prototype.setOption = function (key, value) {
|
|
797
|
+
const options = this._options;
|
|
798
|
+
options[key] = value ?? options[key];
|
|
799
|
+
if (key === 'drawingMode' || key === 'styles') {
|
|
800
|
+
this._updateAppearanceStylesheet();
|
|
801
|
+
if (supportsHighlightApi) CSS.highlights.clear();
|
|
802
|
+
this.drawHighlights();
|
|
803
|
+
} else if (key === 'colors') {
|
|
804
|
+
this._updateAppearanceStylesheet();
|
|
805
|
+
} else if (key === 'containerSelector') {
|
|
806
|
+
this.removeHighlighter();
|
|
807
|
+
this._initializeHighlighter();
|
|
808
|
+
this._loadStyles();
|
|
809
|
+
this._loadEventListeners();
|
|
810
|
+
this._updateAppearanceStylesheet();
|
|
811
|
+
this._updateSelectionUi('appearance');
|
|
812
|
+
this.setOption('selectionHandles', options.selectionHandles);
|
|
813
|
+
} else if (key === 'paragraphSelector') {
|
|
814
|
+
this._initializeHighlighter();
|
|
815
|
+
} else if (key === 'selectionHandles') {
|
|
816
|
+
for (const selectionHandle of this._selectionHandles) {
|
|
817
|
+
selectionHandle.children[1].innerHTML = options.selectionHandles[selectionHandle.dataset.side] ?? '';
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Get all of the initialized options
|
|
823
|
+
Highlighter.prototype.getOptions = function () {
|
|
824
|
+
return this._options;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Remove this Highlighter instance and its highlights
|
|
828
|
+
Highlighter.prototype.removeHighlighter = function () {
|
|
829
|
+
this.loadHighlights([]);
|
|
830
|
+
this._annotatableContainer.querySelectorAll('.hh-svg-background, .hh-selection-handle').forEach(el => el.remove())
|
|
831
|
+
removeStylesheets(this._stylesheets);
|
|
832
|
+
this._resizeObserver.disconnect();
|
|
833
|
+
this._controller.abort();
|
|
834
|
+
|
|
835
|
+
this._annotatableContainer.highlighter = undefined;
|
|
836
|
+
_highlighters = _highlighters.filter(h => h._annotatableContainer !== this._annotatableContainer);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
/********************** Private Methods **********************/
|
|
841
|
+
|
|
842
|
+
// Check if the tap is in the range of an existing highlight or link
|
|
843
|
+
Highlighter.prototype._checkForTapTargets = function (pointerEvent) {
|
|
844
|
+
if (!pointerEvent) return;
|
|
845
|
+
|
|
846
|
+
// Check for tapped highlights and hyperlinks
|
|
847
|
+
const tappedHighlights = [];
|
|
848
|
+
const tappedHyperlinks = [];
|
|
849
|
+
if (!pointerEvent.target.closest('[data-hh-ignore]')) {
|
|
850
|
+
for (const highlightId of Object.keys(this._highlightsById)) {
|
|
851
|
+
const highlightInfo = this._highlightsById[highlightId];
|
|
797
852
|
const highlightRange = highlightInfo.rangeObj;
|
|
798
853
|
for (const rangeRect of highlightRange.getClientRects()) {
|
|
799
|
-
if (
|
|
854
|
+
if (this._isPointInRect(pointerEvent.clientX, pointerEvent.clientY, rangeRect, 5)) {
|
|
800
855
|
tappedHighlights.push(highlightInfo);
|
|
801
856
|
break;
|
|
802
857
|
}
|
|
803
858
|
}
|
|
804
859
|
}
|
|
805
|
-
const
|
|
806
|
-
for (const hyperlinkInfo of Object.values(hyperlinksByPosition)) {
|
|
860
|
+
for (const hyperlinkInfo of Object.values(this._hyperlinksByPosition)) {
|
|
807
861
|
if (pointerEvent.target.closest('a') === hyperlinkInfo.hyperlinkElement) {
|
|
808
862
|
tappedHyperlinks.push(hyperlinkInfo);
|
|
809
863
|
}
|
|
810
864
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Sort highlights (hyperlinks should already be sorted)
|
|
868
|
+
const tappedHighlightIds = [];
|
|
869
|
+
for (const highlightInfo of tappedHighlights) tappedHighlightIds.push(highlightInfo.highlightId);
|
|
870
|
+
const sortedTappedHighlights = this.getHighlightInfo(tappedHighlightIds);
|
|
871
|
+
|
|
872
|
+
return {
|
|
873
|
+
'targetFound': sortedTappedHighlights.length > 0 || tappedHyperlinks.length > 0,
|
|
874
|
+
'tapRange': this._getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY),
|
|
875
|
+
'pointerEvent': pointerEvent,
|
|
876
|
+
'highlights': sortedTappedHighlights,
|
|
877
|
+
'hyperlinks': tappedHyperlinks,
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Compare new highlight information to old highlight information, returning an object with the properties that changed
|
|
882
|
+
Highlighter.prototype._diffHighlight = function (newHighlightInfo, oldHighlightInfo) {
|
|
883
|
+
if (!oldHighlightInfo) return newHighlightInfo;
|
|
884
|
+
const changedHighlightInfo = {}
|
|
885
|
+
for (const key of Object.keys(newHighlightInfo)) {
|
|
886
|
+
if (Object.hasOwn(oldHighlightInfo, key) && oldHighlightInfo[key] !== newHighlightInfo[key]) {
|
|
887
|
+
changedHighlightInfo[key] = newHighlightInfo[key];
|
|
823
888
|
}
|
|
824
889
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
890
|
+
return changedHighlightInfo;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Undraw the specified highlight
|
|
894
|
+
Highlighter.prototype._undrawHighlight = function (highlightInfo) {
|
|
895
|
+
const highlightId = highlightInfo.highlightId;
|
|
896
|
+
|
|
897
|
+
// Remove <mark> highlights and HTML wrappers
|
|
898
|
+
const overlappingHighlightIds = new Set();
|
|
899
|
+
this._annotatableContainer.querySelectorAll(`[data-highlight-id="${highlightId}"]:not(g)`).forEach(element => {
|
|
900
|
+
if (element.parentElement.dataset.highlightId) {
|
|
901
|
+
overlappingHighlightIds.add(element.parentElement.dataset.highlightId);
|
|
834
902
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
// Undraw the specified highlight
|
|
839
|
-
const undrawHighlight = (highlightInfo) => {
|
|
840
|
-
const highlightId = highlightInfo.highlightId;
|
|
841
|
-
|
|
842
|
-
// Remove <mark> highlights and HTML wrappers
|
|
843
|
-
if (this.annotatableContainer.querySelector(`[data-highlight-id="${highlightId}"]:not(g)`)) {
|
|
844
|
-
const overlappingHighlightIds = new Set();
|
|
845
|
-
this.annotatableContainer.querySelectorAll(`[data-highlight-id="${highlightId}"]:not(g)`).forEach(element => {
|
|
846
|
-
if (element.parentElement.dataset.highlightId) {
|
|
847
|
-
overlappingHighlightIds.add(element.parentElement.dataset.highlightId);
|
|
848
|
-
}
|
|
849
|
-
for (const childHighlight of element.querySelectorAll('[data-highlight-id]')) {
|
|
850
|
-
overlappingHighlightIds.add(childHighlight.dataset.highlightId);
|
|
851
|
-
}
|
|
852
|
-
if (element.tagName.toLowerCase() === 'mark') {
|
|
853
|
-
element.outerHTML = element.innerHTML;
|
|
854
|
-
} else {
|
|
855
|
-
element.remove();
|
|
856
|
-
}
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
// Redraw overlapping highlights
|
|
860
|
-
overlappingHighlightIds.delete(highlightId);
|
|
861
|
-
if (overlappingHighlightIds.size > 0) {
|
|
862
|
-
for (const overlappingHighlightId of overlappingHighlightIds) {
|
|
863
|
-
undrawHighlight(highlightsById[overlappingHighlightId]);
|
|
864
|
-
}
|
|
865
|
-
this.drawHighlights(overlappingHighlightIds);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// Normalize text nodes
|
|
869
|
-
const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.startParagraphId}, #${highlightInfo.endParagraphId}`);
|
|
870
|
-
rangeParagraphs.forEach(p => { p.normalize(); });
|
|
871
|
-
getCorrectedRangeObj(highlightId);
|
|
903
|
+
for (const childHighlight of element.querySelectorAll('[data-highlight-id]')) {
|
|
904
|
+
overlappingHighlightIds.add(childHighlight.dataset.highlightId);
|
|
872
905
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
svgBackground.querySelectorAll(`g[data-highlight-id="${highlightId}"]`).forEach(element => {
|
|
878
|
-
element.remove();
|
|
879
|
-
});
|
|
906
|
+
if (element.matches('mark')) {
|
|
907
|
+
element.outerHTML = element.innerHTML;
|
|
908
|
+
} else {
|
|
909
|
+
element.remove();
|
|
880
910
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
for (const index of ruleIndexesToDelete.reverse()) highlightApiStylesheet.deleteRule(index);
|
|
889
|
-
CSS.highlights.delete(highlightId);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// Redraw overlapping highlights
|
|
914
|
+
overlappingHighlightIds.delete(highlightId);
|
|
915
|
+
if (overlappingHighlightIds.size > 0) {
|
|
916
|
+
for (const overlappingHighlightId of overlappingHighlightIds) {
|
|
917
|
+
this._undrawHighlight(this._highlightsById[overlappingHighlightId]);
|
|
890
918
|
}
|
|
919
|
+
this.drawHighlights(overlappingHighlightIds);
|
|
891
920
|
}
|
|
892
|
-
|
|
893
|
-
//
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
921
|
+
|
|
922
|
+
// Normalize text nodes
|
|
923
|
+
const rangeParagraphs = this._annotatableContainer.querySelectorAll(`#${highlightInfo.startParagraphId}, #${highlightInfo.endParagraphId}`);
|
|
924
|
+
rangeParagraphs.forEach(p => p.normalize());
|
|
925
|
+
this._getCorrectedRangeObj(highlightId);
|
|
926
|
+
|
|
927
|
+
// Remove SVG highlights
|
|
928
|
+
this._svgBackground.querySelectorAll(`g[data-highlight-id="${highlightId}"]`).forEach(element => {
|
|
929
|
+
element.remove();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// Remove Highlight API highlights
|
|
933
|
+
if (supportsHighlightApi && CSS.highlights.has(highlightId)) {
|
|
934
|
+
const ruleIndexesToDelete = [];
|
|
935
|
+
for (let r = 0; r < this._highlightApiStylesheet.cssRules.length; r++) {
|
|
936
|
+
if (this._highlightApiStylesheet.cssRules[r].selectorText.includes(`::highlight(${highlightInfo.escapedHighlightId})`)) ruleIndexesToDelete.push(r);
|
|
937
|
+
}
|
|
938
|
+
for (const index of ruleIndexesToDelete.toReversed()) this._highlightApiStylesheet.deleteRule(index);
|
|
939
|
+
CSS.highlights.delete(highlightId);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Update selection background and handles
|
|
944
|
+
Highlighter.prototype._updateSelectionUi = function (changeType = 'appearance') {
|
|
945
|
+
const options = this._options;
|
|
946
|
+
const selection = globalThis.getSelection();
|
|
947
|
+
const selectionRange = selection.type === 'None' ? null : selection.getRangeAt(0);
|
|
948
|
+
|
|
949
|
+
// If the selection starts in another annotatable container, let the other container handle it
|
|
950
|
+
if (selection.anchorNode?.parentElement?.closest('[data-hh-container]') && !this._annotatableContainer.contains(selection.anchorNode)) return;
|
|
951
|
+
|
|
952
|
+
const highlightInfo = this._highlightsById[this._activeHighlightId] ?? null;
|
|
953
|
+
const color = highlightInfo?.color ?? null;
|
|
954
|
+
const style = highlightInfo?.style ?? null;
|
|
955
|
+
const colorString = options.colors[color] ?? (CSS.supports('color', 'AccentColor') ? 'AccentColor' : 'dodgerblue');
|
|
956
|
+
|
|
957
|
+
// Draw SVG selection rects (SVG drawing mode only)
|
|
958
|
+
this._svgActiveOverlay.innerHTML = '';
|
|
959
|
+
if (this._activeHighlightId && options.drawingMode === 'svg') {
|
|
960
|
+
let range = this._getCorrectedRangeObj(this._activeHighlightId);
|
|
961
|
+
const rangeParagraphs = this._annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
|
|
962
|
+
const clientRects = this._getMergedClientRects(range, rangeParagraphs);
|
|
963
|
+
this._svgActiveOverlay.dataset.color = color;
|
|
964
|
+
this._svgActiveOverlay.dataset.style = style;
|
|
965
|
+
let svgContent = '';
|
|
966
|
+
for (const clientRect of clientRects) {
|
|
967
|
+
svgContent += this._getStyleTemplate(highlightInfo.style, 'svg', clientRect, true);
|
|
968
|
+
}
|
|
969
|
+
this._svgActiveOverlay.innerHTML = svgContent;
|
|
970
|
+
|
|
971
|
+
// Bring active highlight to the front
|
|
972
|
+
const svgHighlight = this._svgBackground.querySelector(`g[data-highlight-id="${this._activeHighlightId}"]`);
|
|
973
|
+
if (svgHighlight) this._svgBackground.appendChild(svgHighlight);
|
|
974
|
+
this._svgBackground.appendChild(this._svgActiveOverlay);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (changeType === 'appearance') {
|
|
978
|
+
this._annotatableContainer.style.setProperty('--hh-color', colorString);
|
|
979
|
+
|
|
980
|
+
// Hide the active highlight (and wrappers), and set a selection style that mimics the highlight. This avoids the need to redraw the highlight while actively editing it (especially important for <mark> highlights, because DOM manipulation around the selection can make the selection UI unstable).
|
|
981
|
+
if (this._activeHighlightId && options.drawingMode === 'svg') {
|
|
982
|
+
this._selectionStylesheet.replaceSync(`
|
|
983
|
+
${options.containerSelector} g[data-highlight-id="${this._activeHighlightId}"][data-style] { display: none; }
|
|
984
|
+
${options.containerSelector} .hh-wrapper-start[data-highlight-id="${this._activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${this._activeHighlightId}"] { display: none; }
|
|
985
|
+
${options.containerSelector} ::selection { background-color: transparent; }
|
|
986
|
+
`);
|
|
987
|
+
} else if (this._activeHighlightId) {
|
|
988
|
+
const styleTemplate = this._getStyleTemplate(style, 'css', null, true);
|
|
989
|
+
this._selectionStylesheet.replaceSync(`
|
|
990
|
+
${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
|
|
991
|
+
${options.containerSelector} mark[data-highlight-id="${this._activeHighlightId}"][data-style] { all: unset; }
|
|
992
|
+
${options.containerSelector} .hh-wrapper-start[data-highlight-id="${this._activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${this._activeHighlightId}"] { display: none; }
|
|
993
|
+
${options.containerSelector} ::selection { --hh-color: ${colorString}; ${styleTemplate} }
|
|
994
|
+
${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
|
|
995
|
+
`);
|
|
996
|
+
|
|
997
|
+
// No active highlight (show the regular selection UI)
|
|
998
|
+
} else {
|
|
999
|
+
this._selectionStylesheet.replaceSync(`
|
|
1000
|
+
${options.containerSelector} ::selection { background-color: Highlight; color: inherit; }
|
|
1001
|
+
${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
|
|
1002
|
+
`);
|
|
924
1003
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
// No active highlight (show the regular selection UI)
|
|
947
|
-
} else {
|
|
948
|
-
selectionStylesheet.replaceSync(`
|
|
949
|
-
${options.containerSelector} ::selection { background-color: Highlight; color: inherit; }
|
|
950
|
-
${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
|
|
951
|
-
`);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// Send event
|
|
955
|
-
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:selectionupdate', { detail: { color: color, style: style, }}));
|
|
956
|
-
|
|
957
|
-
} else if (changeType === 'bounds') {
|
|
958
|
-
// Update selection handle location and visibility
|
|
959
|
-
if (selection.type === 'Range') {
|
|
960
|
-
const selectionRangeRects = selectionRange.getClientRects();
|
|
961
|
-
const startRect = selectionRangeRects[0];
|
|
962
|
-
const endRect = selectionRangeRects[selectionRangeRects.length-1];
|
|
963
|
-
const relativeAncestorClientRect = this.relativeAncestorElement.getBoundingClientRect();
|
|
964
|
-
const startNodeIsRtl = window.getComputedStyle(selectionRange.startContainer.parentElement).direction === 'rtl';
|
|
965
|
-
const endNodeIsRtl = window.getComputedStyle(selectionRange.endContainer.parentElement).direction === 'rtl';
|
|
966
|
-
|
|
967
|
-
if (
|
|
968
|
-
(options.showCustomSelectionHandlesOnTouch || pointerType === 'mouse')
|
|
969
|
-
&& ((options.showCustomSelectionHandlesForActiveHighlights && activeHighlightId)
|
|
970
|
-
|| (options.showCustomSelectionHandlesForTextSelection && !activeHighlightId))
|
|
971
|
-
) {
|
|
972
|
-
setCustomSelectionHandleVisibility(true);
|
|
973
|
-
}
|
|
974
|
-
for (const selectionHandle of selectionHandles) {
|
|
1004
|
+
|
|
1005
|
+
// Send event
|
|
1006
|
+
this._annotatableContainer.dispatchEvent(new CustomEvent('hh:selectionupdate', { detail: { color: color, style: style, }}));
|
|
1007
|
+
|
|
1008
|
+
} else if (changeType === 'bounds') {
|
|
1009
|
+
// Update selection handle location and visibility
|
|
1010
|
+
if (selection.type === 'Range') {
|
|
1011
|
+
const selectionRangeRects = selectionRange.getClientRects();
|
|
1012
|
+
const startRect = selectionRangeRects[0];
|
|
1013
|
+
const endRect = selectionRangeRects[selectionRangeRects.length - 1];
|
|
1014
|
+
const relativeAncestorClientRect = this._relativeAncestorElement.getBoundingClientRect();
|
|
1015
|
+
const startNodeIsRtl = globalThis.getComputedStyle(selectionRange.startContainer.parentElement).direction === 'rtl';
|
|
1016
|
+
const endNodeIsRtl = globalThis.getComputedStyle(selectionRange.endContainer.parentElement).direction === 'rtl';
|
|
1017
|
+
|
|
1018
|
+
if (
|
|
1019
|
+
(options.showCustomSelectionHandlesOnTouch || this._pointerType === 'mouse')
|
|
1020
|
+
&& ((options.showCustomSelectionHandlesForActiveHighlights && this._activeHighlightId)
|
|
1021
|
+
|| (options.showCustomSelectionHandlesForTextSelection && !this._activeHighlightId))
|
|
1022
|
+
) {
|
|
1023
|
+
for (const selectionHandle of this._selectionHandles) {
|
|
975
1024
|
let side;
|
|
976
1025
|
if (selectionHandle.dataset.position === 'start') {
|
|
977
1026
|
side = startNodeIsRtl ? 'right' : 'left';
|
|
@@ -989,420 +1038,452 @@ function Highlighter(options = hhDefaultOptions) {
|
|
|
989
1038
|
this.setOption('selectionHandles', options.selectionHandles);
|
|
990
1039
|
}
|
|
991
1040
|
}
|
|
992
|
-
|
|
993
|
-
setCustomSelectionHandleVisibility(false);
|
|
1041
|
+
this._setCustomSelectionHandleVisibility(true);
|
|
994
1042
|
}
|
|
1043
|
+
} else {
|
|
1044
|
+
this._setCustomSelectionHandleVisibility(false);
|
|
995
1045
|
}
|
|
996
1046
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
Highlighter.prototype._setCustomSelectionHandleVisibility = function (visible = this._selectionHandles[0].style.visibility === 'hidden') {
|
|
1050
|
+
for (const selectionHandle of this._selectionHandles) {
|
|
1051
|
+
selectionHandle.style.visibility = visible ? 'visible' : 'hidden';
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Update the selection or highlight range to stay within the annotatable container
|
|
1056
|
+
Highlighter.prototype._snapRangeToBoundaries = function (range, anchorNode = null) {
|
|
1057
|
+
const options = this._options;
|
|
1058
|
+
let startNode = range.startContainer;
|
|
1059
|
+
let endNode = range.endContainer;
|
|
1060
|
+
let startOffset = range.startOffset;
|
|
1061
|
+
let endOffset = range.endOffset;
|
|
1062
|
+
|
|
1063
|
+
// Prevent the range from going outside of the annotatable container
|
|
1064
|
+
if (!this._annotatableContainer.contains(range.commonAncestorContainer)) {
|
|
1065
|
+
if (anchorNode && !this._annotatableContainer.contains(anchorNode)) {
|
|
1066
|
+
// Range is from a selection, and the selection anchor is outside of the container
|
|
1067
|
+
return range.cloneRange().collapse(true);
|
|
1068
|
+
} else if (anchorNode === startNode || this._annotatableContainer.contains(startNode)) {
|
|
1069
|
+
// Range starts in the container but ends outside
|
|
1070
|
+
endNode = this._getLastTextNode(this._annotatableParagraphs[this._annotatableParagraphs.length - 1]);
|
|
1071
|
+
endOffset = endNode.length;
|
|
1072
|
+
} else if (anchorNode === endNode || this._annotatableContainer.contains(endNode)) {
|
|
1073
|
+
// Range starts outside of the container but ends inside
|
|
1074
|
+
startNode = this._getFirstTextNode(this._annotatableParagraphs[0]);
|
|
1075
|
+
startOffset = 0;
|
|
1001
1076
|
}
|
|
1002
1077
|
}
|
|
1003
|
-
|
|
1004
|
-
//
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
let endOffset = range.endOffset;
|
|
1010
|
-
|
|
1011
|
-
// Prevent the range from going outside of the annotatable container
|
|
1012
|
-
if (!this.annotatableContainer.contains(range.commonAncestorContainer)) {
|
|
1013
|
-
if (anchorNode && !this.annotatableContainer.contains(anchorNode)) {
|
|
1014
|
-
// Range is from a selection, and the selection anchor is outside of the container
|
|
1015
|
-
return range.cloneRange().collapse(true);
|
|
1016
|
-
} else if (anchorNode === startNode || this.annotatableContainer.contains(startNode)) {
|
|
1017
|
-
// Range starts in the container but ends outside
|
|
1018
|
-
endNode = getLastTextNode(this.annotatableParagraphs[this.annotatableParagraphs.length - 1]);
|
|
1019
|
-
endOffset = endNode.length;
|
|
1020
|
-
} else if (anchorNode === endNode || this.annotatableContainer.contains(endNode)) {
|
|
1021
|
-
// Range starts outside of the container but ends inside
|
|
1022
|
-
startNode = getFirstTextNode(this.annotatableParagraphs[0]);
|
|
1023
|
-
startOffset = 0;
|
|
1024
|
-
}
|
|
1078
|
+
|
|
1079
|
+
// Prevent the range from starting or ending in an invalid text node
|
|
1080
|
+
if (this._annotatableContainer.contains(range.commonAncestorContainer) && (this._shouldSkipTextNode(startNode) || this._shouldSkipTextNode(endNode))) {
|
|
1081
|
+
if (this._shouldSkipTextNode(startNode)) {
|
|
1082
|
+
startNode = this._getNextValidTextNode(startNode);
|
|
1083
|
+
startOffset = 0;
|
|
1025
1084
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const annotatableParagraphsInRange = Array.from(this.annotatableParagraphs).filter((paragraph) => {
|
|
1030
|
-
const relativeStartPosition = startNode.compareDocumentPosition(paragraph);
|
|
1031
|
-
const relativeEndPosition = endNode.compareDocumentPosition(paragraph);
|
|
1032
|
-
return (relativeStartPosition & Node.DOCUMENT_POSITION_FOLLOWING || relativeStartPosition & Node.DOCUMENT_POSITION_CONTAINS) && (relativeEndPosition & Node.DOCUMENT_POSITION_PRECEDING || relativeEndPosition & Node.DOCUMENT_POSITION_CONTAINS);
|
|
1033
|
-
});
|
|
1034
|
-
if (annotatableParagraphsInRange.length === 0) {
|
|
1035
|
-
return range.cloneRange().collapse(true);
|
|
1036
|
-
}
|
|
1037
|
-
if (!startNode.parentElement.closest(options.paragraphSelector)) {
|
|
1038
|
-
startNode = getFirstTextNode(annotatableParagraphsInRange[0]);
|
|
1039
|
-
startOffset = 0;
|
|
1040
|
-
}
|
|
1041
|
-
if (!endNode.parentElement.closest(options.paragraphSelector)) {
|
|
1042
|
-
endNode = getLastTextNode(annotatableParagraphsInRange[annotatableParagraphsInRange.length - 1]);
|
|
1043
|
-
endOffset = endNode.length;
|
|
1044
|
-
}
|
|
1085
|
+
if (this._shouldSkipTextNode(endNode)) {
|
|
1086
|
+
endNode = this._getPreviousValidTextNode(endNode);
|
|
1087
|
+
endOffset = endNode.length;
|
|
1045
1088
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
if (
|
|
1049
|
-
|
|
1050
|
-
if (startOffset === startNode.textContent.length) {
|
|
1051
|
-
let parentElement = range.commonAncestorContainer;
|
|
1052
|
-
let walker = document.createTreeWalker(parentElement, NodeFilter.SHOW_TEXT, (node) => node.parentNode.closest(options.paragraphSelector) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP);
|
|
1053
|
-
let nextTextNode = walker.nextNode();
|
|
1054
|
-
while (nextTextNode && nextTextNode !== startNode) nextTextNode = walker.nextNode();
|
|
1055
|
-
nextTextNode = walker.nextNode();
|
|
1056
|
-
if (nextTextNode) {
|
|
1057
|
-
startNode = nextTextNode;
|
|
1058
|
-
startOffset = 0;
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// Trim whitespace and dashes at range start and end
|
|
1063
|
-
while (/\s|\p{Pd}/u.test(startOffset < startNode.textContent.length && startNode.textContent[startOffset])) startOffset += 1;
|
|
1064
|
-
while (endOffset - 1 >= 0 && /\s|\p{Pd}/u.test(endNode.textContent[endOffset - 1])) endOffset -= 1;
|
|
1065
|
-
|
|
1066
|
-
// Expand range to word boundaries
|
|
1067
|
-
while (startOffset > 0 && /[^\s|\p{Pd}]/u.test(startNode.textContent[startOffset - 1])) startOffset -= 1;
|
|
1068
|
-
while (endOffset + 1 <= endNode.textContent.length && /[^\s|\p{Pd}]/u.test(endNode.textContent[endOffset])) endOffset += 1;
|
|
1089
|
+
if (!startNode || !endNode || (startNode === endNode && startOffset === endOffset)) {
|
|
1090
|
+
return range.cloneRange().collapse(true);
|
|
1091
|
+
} else if (startNode === endNode && endOffset < startOffset) {
|
|
1092
|
+
[startOffset, endOffset] = [endOffset, startOffset];
|
|
1069
1093
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
referenceRange.selectNodeContents(paragraph);
|
|
1083
|
-
referenceRange.setEnd(referenceTextNode, referenceTextNodeOffset);
|
|
1084
|
-
const paragraphOffset = referenceRange.toString().length;
|
|
1085
|
-
return [ paragraph.id, paragraphOffset ];
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Get the character offset relative to the deepest relevant text node
|
|
1089
|
-
const getTextNodeAndOffset = (parentElement, targetOffset) => {
|
|
1090
|
-
let textNode, firstTextNode, currentOffset = 0;
|
|
1091
|
-
const walker = document.createTreeWalker(parentElement, NodeFilter.SHOW_TEXT);
|
|
1092
|
-
while (textNode = walker.nextNode()) {
|
|
1093
|
-
if (!firstTextNode) firstTextNode = walker.currentNode;
|
|
1094
|
-
currentOffset += textNode.textContent.length;
|
|
1095
|
-
if (currentOffset >= targetOffset) {
|
|
1096
|
-
const relativeOffset = textNode.textContent.length - currentOffset + targetOffset
|
|
1097
|
-
return [ textNode, relativeOffset ];
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Snap to the nearest word
|
|
1097
|
+
if (options.snapToWord) {
|
|
1098
|
+
// If the range starts at the end of a text node, move it to start at the beginning of the following text node. This prevents the range from jumping across the text node boundary and selecting an extra word.
|
|
1099
|
+
if (startOffset === startNode.textContent.length) {
|
|
1100
|
+
const walker = this._getTextNodeWalker(range.commonAncestorContainer);
|
|
1101
|
+
walker.currentNode = startNode;
|
|
1102
|
+
const nextTextNode = walker.nextNode();
|
|
1103
|
+
if (nextTextNode) {
|
|
1104
|
+
startNode = nextTextNode;
|
|
1105
|
+
startOffset = 0;
|
|
1098
1106
|
}
|
|
1099
1107
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
+
|
|
1109
|
+
// Trim whitespace and dashes at range start and end
|
|
1110
|
+
while (_reWhitespaceDash.test(startOffset < startNode.textContent.length && startNode.textContent[startOffset])) startOffset += 1;
|
|
1111
|
+
while (endOffset > 0 && _reWhitespaceDash.test(endNode.textContent[endOffset - 1])) endOffset -= 1;
|
|
1112
|
+
|
|
1113
|
+
// Expand range to word boundaries
|
|
1114
|
+
while (startOffset > 0 && _reNonWhitespaceDash.test(startNode.textContent[startOffset - 1])) startOffset -= 1;
|
|
1115
|
+
while (endOffset < endNode.textContent.length && _reNonWhitespaceDash.test(endNode.textContent[endOffset])) endOffset += 1;
|
|
1108
1116
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
appearanceStylesheet.insertRule(`[data-color="${color}"] { --hh-color: ${options.colors[color]}; }`);
|
|
1129
|
-
}
|
|
1130
|
-
for (const style of Object.keys(options.styles)) {
|
|
1131
|
-
const styleTemplate = getStyleTemplate(style, 'css', null);
|
|
1132
|
-
appearanceStylesheet.insertRule(`mark[data-highlight-id][data-style="${style}"] { ${styleTemplate} }`);
|
|
1133
|
-
}
|
|
1117
|
+
|
|
1118
|
+
const newRange = document.createRange();
|
|
1119
|
+
newRange.setStart(startNode, startOffset);
|
|
1120
|
+
newRange.setEnd(endNode, endOffset);
|
|
1121
|
+
return newRange;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Get the character offset relative to the annotatable paragraph
|
|
1125
|
+
Highlighter.prototype._getParagraphOffset = function (referenceTextNode, referenceTextNodeOffset) {
|
|
1126
|
+
const paragraph = referenceTextNode.parentElement.closest(this._options.paragraphSelector);
|
|
1127
|
+
const walker = this._getTextNodeWalker(paragraph);
|
|
1128
|
+
let currentOffset = 0;
|
|
1129
|
+
let textNode;
|
|
1130
|
+
while (textNode = walker.nextNode()) {
|
|
1131
|
+
if (textNode === referenceTextNode) {
|
|
1132
|
+
currentOffset += referenceTextNodeOffset;
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
currentOffset += textNode.textContent.length;
|
|
1134
1136
|
}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
if (!
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
const relativeAncestorClientRect = this.relativeAncestorElement.getBoundingClientRect();
|
|
1149
|
-
styleTemplate = styleTemplate
|
|
1150
|
-
.replaceAll('{x}', clientRect.x - relativeAncestorClientRect.x)
|
|
1151
|
-
.replaceAll('{y}', clientRect.y - relativeAncestorClientRect.y)
|
|
1152
|
-
.replaceAll('{width}', clientRect.width)
|
|
1153
|
-
.replaceAll('{height}', clientRect.height)
|
|
1154
|
-
.replaceAll('{top}', clientRect.top - relativeAncestorClientRect.top)
|
|
1155
|
-
.replaceAll('{right}', clientRect.right - relativeAncestorClientRect.right)
|
|
1156
|
-
.replaceAll('{bottom}', clientRect.bottom - relativeAncestorClientRect.bottom)
|
|
1157
|
-
.replaceAll('{left}', clientRect.left - relativeAncestorClientRect.left);
|
|
1137
|
+
return [ paragraph.id, currentOffset ];
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Get the character offset relative to the deepest relevant text node
|
|
1141
|
+
Highlighter.prototype._getTextNodeAndOffset = function (parentElement, targetOffset) {
|
|
1142
|
+
let textNode, firstTextNode, currentOffset = 0;
|
|
1143
|
+
const walker = this._getTextNodeWalker(parentElement);
|
|
1144
|
+
while (textNode = walker.nextNode()) {
|
|
1145
|
+
if (!firstTextNode) firstTextNode = walker.currentNode;
|
|
1146
|
+
currentOffset += textNode.textContent.length;
|
|
1147
|
+
if (currentOffset >= targetOffset) {
|
|
1148
|
+
const relativeOffset = textNode.textContent.length - currentOffset + targetOffset
|
|
1149
|
+
return [ textNode, relativeOffset ];
|
|
1158
1150
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1151
|
+
}
|
|
1152
|
+
// TODO: Direction isn't always accurate (maybe it resets when selection is cleared and set to a new range programmatically?)
|
|
1153
|
+
const direction = globalThis.getSelection().direction;
|
|
1154
|
+
if (direction == 'backward') {
|
|
1155
|
+
return [ firstTextNode, 0 ];
|
|
1156
|
+
} else {
|
|
1157
|
+
const lastTextNode = walker.currentNode;
|
|
1158
|
+
return [ lastTextNode, lastTextNode.textContent.length ];
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Get the first valid text node in an element
|
|
1163
|
+
Highlighter.prototype._getFirstTextNode = function (element) {
|
|
1164
|
+
const walker = this._getTextNodeWalker(element);
|
|
1165
|
+
return walker.nextNode();
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Get the last valid text node in an element
|
|
1169
|
+
Highlighter.prototype._getLastTextNode = function (element) {
|
|
1170
|
+
const walker = this._getTextNodeWalker(element);
|
|
1171
|
+
let lastNode = null, node;
|
|
1172
|
+
while (node = walker.nextNode()) lastNode = node;
|
|
1173
|
+
return lastNode;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Get the previous valid text node in the annotatable container
|
|
1177
|
+
Highlighter.prototype._getNextValidTextNode = function (currentNode) {
|
|
1178
|
+
const walker = this._getTextNodeWalker(this._annotatableContainer);
|
|
1179
|
+
walker.currentNode = currentNode;
|
|
1180
|
+
return walker.nextNode();
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Get the next valid text node in the annotatable container
|
|
1184
|
+
Highlighter.prototype._getPreviousValidTextNode = function (currentNode) {
|
|
1185
|
+
const walker = this._getTextNodeWalker(this._annotatableContainer);
|
|
1186
|
+
walker.currentNode = currentNode;
|
|
1187
|
+
return walker.previousNode();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Determine if text node should be skipped when snapping to word or calculating character offsets
|
|
1191
|
+
Highlighter.prototype._shouldSkipTextNode = function (textNode) {
|
|
1192
|
+
const parentParagraph = textNode.parentNode.closest(this._options.paragraphSelector);
|
|
1193
|
+
const hhIgnore = textNode.parentNode.closest('[data-hh-ignore]');
|
|
1194
|
+
if (!parentParagraph || hhIgnore || textNode.textContent === '') return true;
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Get text node walker
|
|
1199
|
+
Highlighter.prototype._getTextNodeWalker = function (root) {
|
|
1200
|
+
return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, (node) => this._shouldSkipTextNode(node) ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Update appearance stylesheet (user-defined colors and styles)
|
|
1204
|
+
Highlighter.prototype._updateAppearanceStylesheet = function () {
|
|
1205
|
+
const options = this._options;
|
|
1206
|
+
this._appearanceStylesheet.replaceSync('');
|
|
1207
|
+
for (const color of Object.keys(options.colors)) {
|
|
1208
|
+
this._appearanceStylesheet.insertRule(`[data-color="${color}"] { --hh-color: ${options.colors[color]}; }`);
|
|
1209
|
+
}
|
|
1210
|
+
for (const style of Object.keys(options.styles)) {
|
|
1211
|
+
const styleTemplate = this._getStyleTemplate(style, 'css', null);
|
|
1212
|
+
this._appearanceStylesheet.insertRule(`mark[data-highlight-id][data-style="${style}"] { ${styleTemplate} }`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Get style template for a given highlight style
|
|
1217
|
+
Highlighter.prototype._getStyleTemplate = function (style, type, clientRect = null, active = false) {
|
|
1218
|
+
const options = this._options;
|
|
1219
|
+
style = Object.hasOwn(options.styles, style) ? style : options.defaultStyle;
|
|
1220
|
+
let styleTemplate = options.styles[style]?.[type] ?? '';
|
|
1221
|
+
if (active) {
|
|
1222
|
+
styleTemplate = options.styles[style]?.[`${type}-active`] ?? styleTemplate;
|
|
1223
|
+
}
|
|
1224
|
+
if (!styleTemplate) {
|
|
1225
|
+
console.warn(`Highlight style "${style}" in options does not have a defined "${type}" value.`);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (type === 'svg' && clientRect) {
|
|
1229
|
+
const relativeAncestorClientRect = this._relativeAncestorElement.getBoundingClientRect();
|
|
1230
|
+
styleTemplate = styleTemplate
|
|
1231
|
+
.replaceAll('{x}', clientRect.x - relativeAncestorClientRect.x)
|
|
1232
|
+
.replaceAll('{y}', clientRect.y - relativeAncestorClientRect.y)
|
|
1233
|
+
.replaceAll('{width}', clientRect.width)
|
|
1234
|
+
.replaceAll('{height}', clientRect.height)
|
|
1235
|
+
.replaceAll('{top}', clientRect.top - relativeAncestorClientRect.top)
|
|
1236
|
+
.replaceAll('{right}', clientRect.right - relativeAncestorClientRect.right)
|
|
1237
|
+
.replaceAll('{bottom}', clientRect.bottom - relativeAncestorClientRect.bottom)
|
|
1238
|
+
.replaceAll('{left}', clientRect.left - relativeAncestorClientRect.left);
|
|
1239
|
+
}
|
|
1240
|
+
return styleTemplate;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Restore the previous selection range in case the browser clears the selection
|
|
1244
|
+
Highlighter.prototype._getRestoredSelectionOrCaret = function (selection, pointerEvent = null) {
|
|
1245
|
+
if (selection.type === 'None') {
|
|
1246
|
+
if (this._previousSelectionRange) {
|
|
1247
|
+
// iOS Safari deselects text when a button is tapped. This restores the selection.
|
|
1248
|
+
selection.addRange(this._previousSelectionRange);
|
|
1249
|
+
} else if (pointerEvent) {
|
|
1250
|
+
// In most browsers, tapping or clicking somewhere on the page creates a selection of 0 character length (selection.type === "Caret"). iOS Safari instead clears the selection (selection.type === "None"). This restores a Caret selection if the selection type is None.
|
|
1251
|
+
let range = this._getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY);
|
|
1252
|
+
selection.addRange(range);
|
|
1183
1253
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1254
|
+
}
|
|
1255
|
+
return selection;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Fix highlight range if the DOM changed and made the previous highlight range invalid
|
|
1259
|
+
Highlighter.prototype._getCorrectedRangeObj = function (highlightId) {
|
|
1260
|
+
const highlightInfo = this._highlightsById[highlightId];
|
|
1261
|
+
const highlightRange = highlightInfo?.rangeObj;
|
|
1262
|
+
if (highlightRange && (
|
|
1263
|
+
highlightRange.startContainer.nodeType !== Node.TEXT_NODE || highlightRange.endContainer.nodeType !== Node.TEXT_NODE ||
|
|
1264
|
+
highlightRange.startOffset > highlightRange.startContainer.textContent.length - 1 ||
|
|
1265
|
+
highlightRange.endOffset > highlightRange.endContainer.textContent.length
|
|
1266
|
+
)) {
|
|
1267
|
+
const [ startNode, startOffset ] = this._getTextNodeAndOffset(document.getElementById(highlightInfo.startParagraphId), highlightInfo.startParagraphOffset);
|
|
1268
|
+
const [ endNode, endOffset ] = this._getTextNodeAndOffset(document.getElementById(highlightInfo.endParagraphId), highlightInfo.endParagraphOffset);
|
|
1269
|
+
highlightRange.setStart(startNode, startOffset);
|
|
1270
|
+
highlightRange.setEnd(endNode, endOffset);
|
|
1271
|
+
}
|
|
1272
|
+
return highlightRange;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Convert tap or click to a selection range
|
|
1276
|
+
// Adapted from https://stackoverflow.com/a/12924488/1349044
|
|
1277
|
+
Highlighter.prototype._getCaretFromCoordinates = function (clientX, clientY, checkAnnotatable = false, checkXDistance = false, checkYDistance = false) {
|
|
1278
|
+
let range;
|
|
1279
|
+
if (supportsCaretPositionFromPoint) {
|
|
1280
|
+
let caretPosition = document.caretPositionFromPoint(clientX, clientY);
|
|
1281
|
+
range = document.createRange();
|
|
1282
|
+
range.setStart(caretPosition.offsetNode, caretPosition.offset);
|
|
1283
|
+
range.collapse(true);
|
|
1284
|
+
} else if (supportsCaretRangeFromPoint) {
|
|
1285
|
+
range = document.caretRangeFromPoint(clientX, clientY);
|
|
1286
|
+
}
|
|
1287
|
+
if (!range) return;
|
|
1288
|
+
if (checkXDistance || checkYDistance) {
|
|
1289
|
+
const maxDistance = 30;
|
|
1290
|
+
const caretClientRect = range.getBoundingClientRect();
|
|
1291
|
+
if (checkXDistance && Math.abs(clientX - caretClientRect.x) > maxDistance) return;
|
|
1292
|
+
if (checkYDistance && Math.abs(clientY - caretClientRect.top) > maxDistance && Math.abs(clientY - caretClientRect.bottom) > maxDistance) return;
|
|
1293
|
+
}
|
|
1294
|
+
if (checkAnnotatable && !range.startContainer.parentElement.closest(this._options.paragraphSelector)) return;
|
|
1295
|
+
return range;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Check if a point is in a DOMRect
|
|
1299
|
+
Highlighter.prototype._isPointInRect = function (x, y, rect, padding = 0) {
|
|
1300
|
+
return (
|
|
1301
|
+
x >= rect.x - padding && x <= rect.x + rect.width + padding &&
|
|
1302
|
+
y >= rect.y - padding && y <= rect.y + rect.height + padding
|
|
1303
|
+
)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Get merged client rects from the highlight range
|
|
1307
|
+
Highlighter.prototype._getMergedClientRects = function (range, paragraphs) {
|
|
1308
|
+
const unmergedRects = Array.from(range.getClientRects());
|
|
1309
|
+
const mergedRects = [];
|
|
1310
|
+
|
|
1311
|
+
// Remove element rects (only text node rects are needed)
|
|
1312
|
+
const ancestorElementsInRange = new Set();
|
|
1313
|
+
for (const paragraph of paragraphs) {
|
|
1314
|
+
let element = paragraph;
|
|
1315
|
+
while (range.commonAncestorContainer.contains(element)) {
|
|
1316
|
+
ancestorElementsInRange.add(element);
|
|
1317
|
+
element = element.parentElement;
|
|
1196
1318
|
}
|
|
1197
1319
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
if (
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
} else if (pointerEvent) {
|
|
1206
|
-
// In most browsers, tapping or clicking somewhere on the page creates a selection of 0 character length (selection.type === "Caret"). iOS Safari instead clears the selection (selection.type === "None"). This restores a Caret selection if the selection type is None.
|
|
1207
|
-
let range = getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY);
|
|
1208
|
-
selection.addRange(range);
|
|
1320
|
+
for (let ur = unmergedRects.length - 1; ur >= 0; ur--) {
|
|
1321
|
+
const rect = unmergedRects[ur];
|
|
1322
|
+
for (const element of ancestorElementsInRange) {
|
|
1323
|
+
const elementRect = element.getBoundingClientRect();
|
|
1324
|
+
if (Math.round(elementRect.width) === Math.round(rect.width) && Math.round(elementRect.height) === Math.round(rect.height) && Math.round(elementRect.top) === Math.round(rect.top) && Math.round(elementRect.left) === Math.round(rect.left)) {
|
|
1325
|
+
unmergedRects.splice(ur, 1);
|
|
1326
|
+
break;
|
|
1209
1327
|
}
|
|
1210
1328
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
const
|
|
1217
|
-
const
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
range = document.caretRangeFromPoint(clientX, clientY);
|
|
1242
|
-
}
|
|
1243
|
-
if (!range) return;
|
|
1244
|
-
if (checkXDistance || checkYDistance) {
|
|
1245
|
-
const maxDistance = 30;
|
|
1246
|
-
const caretClientRect = range.getBoundingClientRect();
|
|
1247
|
-
if (checkXDistance && Math.abs(clientX - caretClientRect.x) > maxDistance) return;
|
|
1248
|
-
if (checkYDistance && Math.abs(clientY - caretClientRect.top) > maxDistance && Math.abs(clientY - caretClientRect.bottom) > maxDistance) return;
|
|
1249
|
-
}
|
|
1250
|
-
if (checkAnnotatable && !range.startContainer.parentElement.closest(options.paragraphSelector)) return;
|
|
1251
|
-
return range;
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// Check if a point is in a DOMRect
|
|
1255
|
-
const isPointInRect = (x, y, rect, padding = 0) => {
|
|
1256
|
-
return (
|
|
1257
|
-
x >= rect.x - padding && x <= rect.x + rect.width + padding &&
|
|
1258
|
-
y >= rect.y - padding && y <= rect.y + rect.height + padding
|
|
1259
|
-
)
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
// Get merged client rects from the highlight range
|
|
1263
|
-
const getMergedClientRects = (range, paragraphs) => {
|
|
1264
|
-
const unmergedRects = Array.from(range.getClientRects());
|
|
1265
|
-
const mergedRects = [];
|
|
1266
|
-
|
|
1267
|
-
// Remove element rects (only text node rects are needed)
|
|
1268
|
-
const ancestorElementsInRange = [];
|
|
1269
|
-
for (const paragraph of paragraphs) {
|
|
1270
|
-
let element = paragraph;
|
|
1271
|
-
while (range.commonAncestorContainer.contains(element)) {
|
|
1272
|
-
if (!ancestorElementsInRange.includes(element)) ancestorElementsInRange.push(element);
|
|
1273
|
-
element = element.parentElement;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Loop through the highlight's paragraphs
|
|
1332
|
+
for (const paragraph of paragraphs) {
|
|
1333
|
+
const paragraphRect = paragraph.getBoundingClientRect();
|
|
1334
|
+
const paragraphStyle = getComputedStyle(paragraph);
|
|
1335
|
+
const snapTolerance = Math.max(Number.parseInt(paragraphStyle.fontSize) / 2, 4);
|
|
1336
|
+
const topPadding = Number.parseInt(paragraphStyle.paddingTop);
|
|
1337
|
+
const textIndent = Number.parseInt(paragraphStyle.textIndent);
|
|
1338
|
+
|
|
1339
|
+
// Build line positions from paragraph text nodes
|
|
1340
|
+
const linePositions = {};
|
|
1341
|
+
const lineBottomKeys = [];
|
|
1342
|
+
const walker = this._getTextNodeWalker(paragraph);
|
|
1343
|
+
const textNodeRange = document.createRange();
|
|
1344
|
+
let textNode;
|
|
1345
|
+
while (textNode = walker.nextNode()) {
|
|
1346
|
+
// Skip text nodes in elements that are higher or lower than surrounding text
|
|
1347
|
+
if (textNode.parentElement.closest('sup, sub, rt')) continue;
|
|
1348
|
+
textNodeRange.selectNode(textNode);
|
|
1349
|
+
for (const rect of textNodeRange.getClientRects()) {
|
|
1350
|
+
// Round to a nearby line position if close
|
|
1351
|
+
const lineBottom = lineBottomKeys.find(b => Math.abs(b - rect.bottom) < snapTolerance) ?? rect.bottom;
|
|
1352
|
+
if (!linePositions[lineBottom]) lineBottomKeys.push(lineBottom);
|
|
1353
|
+
const pos = linePositions[lineBottom] ??= {
|
|
1354
|
+
minLeft: rect.left, maxRight: rect.right,
|
|
1355
|
+
highlightLeft: null, highlightRight: null,
|
|
1356
|
+
};
|
|
1357
|
+
pos.minLeft = Math.min(pos.minLeft, rect.left);
|
|
1358
|
+
pos.maxRight = Math.max(pos.maxRight, rect.right);
|
|
1274
1359
|
}
|
|
1275
1360
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1361
|
+
|
|
1362
|
+
const lineBottoms = lineBottomKeys.sort((a, b) => a - b);
|
|
1363
|
+
|
|
1364
|
+
// Assign highlight range rects to matching line positions
|
|
1365
|
+
for (const rect of unmergedRects) {
|
|
1366
|
+
const centerY = rect.y + rect.height / 2;
|
|
1367
|
+
for (let i = 0; i < lineBottoms.length; i++) {
|
|
1368
|
+
const pos = linePositions[lineBottoms[i]];
|
|
1369
|
+
// Skip rects that extend outside the line of text (such as absolutely-positioned elements)
|
|
1370
|
+
if (rect.left >= pos.minLeft && rect.right <= pos.maxRight
|
|
1371
|
+
&& centerY > (lineBottoms[i - 1] ?? (paragraphRect.top + topPadding)) && centerY <= lineBottoms[i]) {
|
|
1372
|
+
pos.highlightLeft = Math.min(pos.highlightLeft ?? rect.left, rect.left);
|
|
1373
|
+
pos.highlightRight = Math.max(pos.highlightRight ?? rect.right, rect.right);
|
|
1282
1374
|
break;
|
|
1283
1375
|
}
|
|
1284
1376
|
}
|
|
1285
1377
|
}
|
|
1286
|
-
|
|
1287
|
-
//
|
|
1288
|
-
for (
|
|
1289
|
-
const
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
let
|
|
1296
|
-
let
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1378
|
+
|
|
1379
|
+
// Create a merged rect for each relevant line
|
|
1380
|
+
for (let i = 0; i < lineBottoms.length; i++) {
|
|
1381
|
+
const bottom = lineBottoms[i];
|
|
1382
|
+
const pos = linePositions[bottom];
|
|
1383
|
+
if (pos.highlightLeft == null) continue;
|
|
1384
|
+
const nearbyBottom = lineBottoms[i - 1] ?? lineBottoms[i + 1] ?? (paragraphRect.top + topPadding);
|
|
1385
|
+
const height = Math.abs(bottom - nearbyBottom);
|
|
1386
|
+
const top = bottom - height;
|
|
1387
|
+
let left = Math.max(pos.minLeft, pos.highlightLeft);
|
|
1388
|
+
let right = Math.min(pos.maxRight, pos.highlightRight);
|
|
1389
|
+
|
|
1390
|
+
// Final adjustment to left and right bounds
|
|
1391
|
+
if (paragraphStyle.direction === 'ltr' && paragraphStyle.textAlign === 'start') {
|
|
1392
|
+
if (left - paragraphRect.left < snapTolerance) left = paragraphRect.left;
|
|
1393
|
+
if (textIndent < 0) left += textIndent;
|
|
1394
|
+
} else if (paragraphStyle.direction === 'rtl' && paragraphStyle.textAlign === 'start') {
|
|
1395
|
+
if (paragraphRect.right - right < snapTolerance) right = paragraphRect.right;
|
|
1396
|
+
if (textIndent < 0) right += textIndent;
|
|
1303
1397
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1398
|
+
|
|
1399
|
+
const width = right - left;
|
|
1400
|
+
mergedRects.push(new DOMRect(left, top, width, height));
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
return mergedRects;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
/********************** Utilities **********************/
|
|
1409
|
+
|
|
1410
|
+
// Add a CSS stylesheet to the document
|
|
1411
|
+
function addStylesheet(stylesheets, stylesheetKey) {
|
|
1412
|
+
let stylesheet = stylesheets[stylesheetKey];
|
|
1413
|
+
if (!stylesheet) {
|
|
1414
|
+
if (supportsCssStylesheetApi) {
|
|
1415
|
+
stylesheet = new CSSStyleSheet();
|
|
1416
|
+
document.adoptedStyleSheets.push(stylesheet);
|
|
1417
|
+
} else {
|
|
1418
|
+
// For browsers that don't fully support the CSSStyleSheet API, such as Safari < 16.4.
|
|
1419
|
+
// See https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#browser_compatibility
|
|
1420
|
+
stylesheet = document.createElement('style');
|
|
1421
|
+
stylesheet.appendChild(document.createTextNode(''));
|
|
1422
|
+
stylesheet.replaceSync = (newContent) => {
|
|
1423
|
+
stylesheet.textContent = newContent;
|
|
1424
|
+
}
|
|
1425
|
+
stylesheet.insertRule = (newContent) => {
|
|
1426
|
+
stylesheet.textContent += newContent;
|
|
1333
1427
|
}
|
|
1428
|
+
document.head.appendChild(stylesheet);
|
|
1334
1429
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1430
|
+
stylesheets[stylesheetKey] = stylesheet;
|
|
1431
|
+
}
|
|
1432
|
+
return stylesheet;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Remove CSS stylesheets (only those from the current Highlighter instance)
|
|
1436
|
+
function removeStylesheets(stylesheets) {
|
|
1437
|
+
for (const stylesheet of Object.values(stylesheets)) {
|
|
1438
|
+
if (supportsCssStylesheetApi) {
|
|
1439
|
+
const adoptedStylesheetIndex = document.adoptedStyleSheets.indexOf(stylesheet);
|
|
1440
|
+
document.adoptedStyleSheets.splice(adoptedStylesheetIndex, 1);
|
|
1441
|
+
} else {
|
|
1442
|
+
stylesheet.remove();
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Debounce a function to prevent it from being executed too frequently
|
|
1448
|
+
// Adapted from https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086
|
|
1449
|
+
function debounce(func, wait) {
|
|
1450
|
+
let timeout;
|
|
1451
|
+
return function executedFunction(...args) {
|
|
1452
|
+
const later = () => {
|
|
1348
1453
|
clearTimeout(timeout);
|
|
1349
|
-
|
|
1454
|
+
func(...args);
|
|
1350
1455
|
};
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1456
|
+
clearTimeout(timeout);
|
|
1457
|
+
timeout = setTimeout(later, wait);
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Default function for generating a highlight ID
|
|
1462
|
+
const _getNewHighlightId = () => {
|
|
1463
|
+
return 'hh-' + Date.now().toString();
|
|
1356
1464
|
}
|
|
1357
1465
|
|
|
1358
1466
|
|
|
1359
|
-
|
|
1467
|
+
/********************** Constants **********************/
|
|
1360
1468
|
|
|
1361
1469
|
// Keep track of all Highlighter instances
|
|
1362
|
-
let
|
|
1470
|
+
let _highlighters = [];
|
|
1363
1471
|
|
|
1364
1472
|
// Check browser type
|
|
1365
1473
|
const isTouchDevice = navigator.maxTouchPoints > 0;
|
|
1366
1474
|
const isWebKit = /^((?!Chrome|Firefox|Android|Samsung).)*AppleWebKit/i.test(navigator.userAgent);
|
|
1367
|
-
const isWKWebView = isWebKit &&
|
|
1475
|
+
const isWKWebView = isWebKit && globalThis.webkit?.messageHandlers;
|
|
1368
1476
|
const supportsCaretPositionFromPoint = document.caretPositionFromPoint;
|
|
1369
1477
|
const supportsCaretRangeFromPoint = document.caretRangeFromPoint;
|
|
1370
1478
|
const supportsCssStylesheetApi = CSSStyleSheet?.prototype?.replaceSync;
|
|
1371
1479
|
const supportsHighlightApi = CSS.highlights;
|
|
1372
1480
|
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
const tempInput = document.createElement('input');
|
|
1377
|
-
tempInput.style.position = 'fixed';
|
|
1378
|
-
tempInput.style.top = 0;
|
|
1379
|
-
tempInput.style.opacity = 0;
|
|
1380
|
-
tempInput.style.height = 0;
|
|
1381
|
-
tempInput.style.fontSize = '16px'; // Prevent page zoom on input focus
|
|
1382
|
-
tempInput.inputMode = 'none'; // Don't show keyboard
|
|
1383
|
-
tempInput.tabIndex = -1; // Prevent user from tabbing to input
|
|
1384
|
-
tempInput.ariaHidden = 'true'; // Hide from screen readers
|
|
1385
|
-
const initializeSelection = (event) => {
|
|
1386
|
-
if (document.readyState !== 'complete') return setTimeout(initializeSelection, 20);
|
|
1387
|
-
if (!tempInput.parentElement) document.body.append(tempInput);
|
|
1388
|
-
tempInput.focus();
|
|
1389
|
-
setTimeout(() => {
|
|
1390
|
-
tempInput.blur();
|
|
1391
|
-
}, 100);
|
|
1392
|
-
}
|
|
1393
|
-
initializeSelection();
|
|
1394
|
-
document.addEventListener('visibilitychange', (event) => {
|
|
1395
|
-
if (document.visibilityState === 'visible') initializeSelection();
|
|
1396
|
-
});
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
// Default function for generating a highlight ID
|
|
1400
|
-
const hhGetNewHighlightId = () => {
|
|
1401
|
-
return 'hh-' + Date.now().toString();
|
|
1402
|
-
}
|
|
1481
|
+
// Regex patterns for word boundary detection
|
|
1482
|
+
const _reWhitespaceDash = /\s|\p{Pd}/u;
|
|
1483
|
+
const _reNonWhitespaceDash = /[^\s|\p{Pd}]/u;
|
|
1403
1484
|
|
|
1404
1485
|
// Default options
|
|
1405
|
-
|
|
1486
|
+
const _defaultOptions = {
|
|
1406
1487
|
containerSelector: 'body',
|
|
1407
1488
|
paragraphSelector: 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id], p[id], ol[id], ul[id], dl[id], tr[id]',
|
|
1408
1489
|
colors: {
|
|
@@ -1463,13 +1544,37 @@ let hhDefaultOptions = {
|
|
|
1463
1544
|
defaultColor: 'yellow',
|
|
1464
1545
|
defaultStyle: 'fill',
|
|
1465
1546
|
defaultWrapper: 'none',
|
|
1466
|
-
highlightIdFunction:
|
|
1547
|
+
highlightIdFunction: _getNewHighlightId,
|
|
1467
1548
|
showCustomSelectionHandlesForActiveHighlights: true,
|
|
1468
1549
|
showCustomSelectionHandlesForTextSelection: false,
|
|
1469
1550
|
showCustomSelectionHandlesOnTouch: false,
|
|
1470
1551
|
}
|
|
1471
1552
|
|
|
1472
|
-
|
|
1553
|
+
// Workaround to allow programmatic text selection on tap in iOS Safari
|
|
1554
|
+
// See https://stackoverflow.com/a/79261423/1349044
|
|
1555
|
+
if (isTouchDevice && isWebKit) {
|
|
1556
|
+
const tempInput = document.createElement('input');
|
|
1557
|
+
tempInput.style.position = 'fixed';
|
|
1558
|
+
tempInput.style.top = 0;
|
|
1559
|
+
tempInput.style.opacity = 0;
|
|
1560
|
+
tempInput.style.height = 0;
|
|
1561
|
+
tempInput.style.fontSize = '16px'; // Prevent page zoom on input focus
|
|
1562
|
+
tempInput.inputMode = 'none'; // Don't show keyboard
|
|
1563
|
+
tempInput.tabIndex = -1; // Prevent user from tabbing to input
|
|
1564
|
+
tempInput.ariaHidden = 'true'; // Hide from screen readers
|
|
1565
|
+
const initializeSelection = (event) => {
|
|
1566
|
+
if (document.readyState !== 'complete') return setTimeout(initializeSelection, 20);
|
|
1567
|
+
if (!tempInput.parentElement) document.body.append(tempInput);
|
|
1568
|
+
tempInput.focus();
|
|
1569
|
+
setTimeout(() => {
|
|
1570
|
+
tempInput.blur();
|
|
1571
|
+
}, 100);
|
|
1572
|
+
}
|
|
1573
|
+
initializeSelection();
|
|
1574
|
+
document.addEventListener('visibilitychange', (event) => {
|
|
1575
|
+
if (document.visibilityState === 'visible') initializeSelection();
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1473
1578
|
|
|
1474
|
-
// Make Highlighter available to
|
|
1475
|
-
|
|
1579
|
+
// Make Highlighter available to JavaScript module (highlight-helper.mjs)
|
|
1580
|
+
globalThis.Highlighter = Highlighter;
|