@samuelbradshaw/highlight-helper-js 0.8.1 → 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.
@@ -1,1408 +1,1489 @@
1
1
  /**
2
- * Highlight Helper
2
+ * HighlightHelper.js
3
3
  * https://github.com/samuelbradshaw/highlight-helper-js
4
4
  */
5
5
 
6
6
  'use strict';
7
7
 
8
- function Highlighter(options = hhDefaultOptions) {
9
- for (const key of Object.keys(hhDefaultOptions)) {
10
- options[key] = options[key] ?? hhDefaultOptions[key];
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
- // Activate a highlight by ID
475
- this.activateHighlight = (highlightId) => {
476
- const highlightToActivate = highlightsById[highlightId];
477
- if (highlightToActivate.readOnly) {
478
- // If the highlight is read-only, return events, but don't actually activate it
479
- this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
480
- this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: { highlight: highlightToActivate } }));
481
- return;
482
- }
483
- const selection = window.getSelection();
484
- const highlightRange = highlightToActivate.rangeObj.cloneRange();
485
- activeHighlightId = highlightId;
486
- updateSelectionUi('appearance');
487
- updateSelectionUi('bounds');
488
-
489
- // Update the selection range if needed
490
- // 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.
491
- let selectionRange = selection.type === 'Range' ? selection.getRangeAt(0) : null;
492
- if (!selectionRange || !(
493
- selectionRange.startContainer === highlightRange.startContainer &&
494
- selectionRange.startOffset === highlightRange.startOffset &&
495
- selectionRange.endContainer === highlightRange.endContainer &&
496
- selectionRange.endOffset === highlightRange.endOffset)
497
- ) {
498
- selection.setBaseAndExtent(highlightRange.startContainer, highlightRange.startOffset, highlightRange.endContainer, highlightRange.endOffset);
499
- }
500
-
501
- this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
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
- // Activate a link by position
505
- let allowHyperlinkClick = false;
506
- this.activateHyperlink = (position) => {
507
- this.deactivateHighlights();
508
- allowHyperlinkClick = true;
509
- hyperlinksByPosition[position].hyperlinkElement.click();
510
- allowHyperlinkClick = false;
511
- }
512
-
513
- // Deactivate any highlights that are currently active/selected
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
- // Remove the specified highlights, or all highlights on the page
532
- this.removeHighlights = (highlightIds = Object.keys(highlightsById)) => {
533
- this.deactivateHighlights();
534
- for (const highlightId of highlightIds) {
535
- const highlightInfo = highlightsById[highlightId];
536
- if (highlightInfo) {
537
- delete highlightsById[highlightId];
538
- this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightremove', { detail: {
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
- // Get the active highlight ID (if there is one)
547
- this.getActiveHighlightId = () => {
548
- return activeHighlightId;
549
- }
550
-
551
- // Get info for specified highlights, or all highlights on the page
552
- this.getHighlightInfo = (highlightIds = Object.keys(highlightsById), paragraphId = null) => {
553
- let filteredHighlights = []
554
- for (const highlightId of highlightIds) {
555
- const highlightInfo = highlightsById[highlightId];
556
- if (!paragraphId || paragraphId === highlightInfo.startParagraphId) {
557
- filteredHighlights.push(highlightInfo);
558
- }
559
- }
560
- // Sort highlights based on their order on the page
561
- if (filteredHighlights.length > 0) {
562
- filteredHighlights.sort((a, b) => {
563
- return (annotatableParagraphIds.indexOf(a.startParagraphId) - annotatableParagraphIds.indexOf(b.startParagraphId)) || (a.startParagraphOffset - b.startParagraphOffset);
564
- });
565
- }
566
- return filteredHighlights;
567
- }
568
-
569
- // Update one of the initialized options
570
- this.setOption = (key, value) => {
571
- const containerSelector = options.containerSelector;
572
- options[key] = value ?? options[key];
573
- if (key === 'drawingMode' || key === 'styles') {
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
- // Get all of the initialized options
592
- this.getOptions = () => {
593
- return options;
594
- }
595
-
596
- // Remove this Highlighter instance and its highlights
597
- this.removeHighlighter = () => {
598
- this.loadHighlights([]);
599
- this.annotatableContainer.querySelectorAll('.hh-svg-background, .hh-selection-handle').forEach(el => el.remove())
600
- removeStylesheets(this.stylesheets);
601
- resizeObserver.disconnect();
602
- controller.abort();
603
-
604
- this.annotatableContainer.highlighter = undefined;
605
- hhHighlighters = hhHighlighters.filter(hhHighlighter => hhHighlighter.annotatableContainer !== this.annotatableContainer);
606
- }
607
-
608
-
609
- // -------- EVENT LISTENERS --------
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 = getRestoredSelectionOrCaret(window.getSelection());
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' && previousSelectionRange && (activeSelectionHandle || (previousSelectionRange.compareBoundaryPoints(Range.END_TO_START, selectionRange) <= 0 && previousSelectionRange.compareBoundaryPoints(Range.END_TO_END, selectionRange) >= 0))) {
621
- selection.setBaseAndExtent(previousSelectionRange.startContainer, previousSelectionRange.startOffset, previousSelectionRange.endContainer, previousSelectionRange.endOffset);
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 (!activeSelectionHandle && previousSelectionRange && (selection.type !== 'Range' || previousSelectionRange.comparePoint(selectionRange.startContainer, selectionRange.startOffset) === 1 || previousSelectionRange.comparePoint(selectionRange.endContainer, selectionRange.endOffset) === -1)) {
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
- tapResult = null;
632
-
633
- if (this.annotatableContainer.contains(selection.anchorNode)) {
634
- if (activeHighlightId || (options.pointerMode === 'live' || (options.pointerMode === 'auto' && pointerType === 'pen'))) {
635
- this.createOrUpdateHighlight({ highlightId: activeHighlightId, });
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
- previousSelectionRange = selectionRange.cloneRange();
234
+ this._previousSelectionRange = selectionRange.cloneRange();
638
235
  }
639
236
  }
640
-
641
- updateSelectionUi('bounds');
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.annotatableContainer.dataset.hhPointerDown = 'true';
649
- pointerType = event.pointerType;
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
- activeSelectionHandle = event.target.parentElement.closest('.hh-selection-handle');
655
- const selectionHandleClientRect = activeSelectionHandle.getBoundingClientRect();
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
- activeSelectionHandle.dataset.dragYOffset = Math.max(0, event.clientY - selectionHandleClientRect.bottom + (lineHeight / 6));
658
- const selectionRange = window.getSelection().getRangeAt(0);
659
- dragAnchorNode = activeSelectionHandle.dataset.position === 'start' ? selectionRange.endContainer : selectionRange.startContainer;
660
- dragAnchorOffset = activeSelectionHandle.dataset.position === 'start' ? selectionRange.endOffset : selectionRange.startOffset;
661
- this.annotatableContainer.addEventListener('pointermove', respondToSelectionHandleDrag, { signal: controller.signal });
662
- updateSelectionUi('bounds');
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 (doubleTapTimeoutId) {
266
+ if (this._doubleTapTimeoutId) {
670
267
  return this.deactivateHighlights();
671
268
  } else {
672
- doubleTapTimeoutId = setTimeout(() => doubleTapTimeoutId = clearTimeout(doubleTapTimeoutId), 500);
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 (previousSelectionRange || isSecondaryClick) return;
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) longPressTimeoutId = setTimeout(() => respondToLongPress(event), options.longPressTimeout);
680
-
681
- tapResult = checkForTapTargets(event);
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
- activeSelectionHandle.dataset.pointerXPosition = event.clientX;
687
- activeSelectionHandle.dataset.pointerYPosition = event.clientY;
688
- const selection = window.getSelection();
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 = getCaretFromCoordinates(event.clientX, event.clientY - activeSelectionHandle.dataset.dragYOffset, true, false, true);
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 || (dragAnchorNode === dragCaret.endContainer && dragAnchorOffset === dragCaret.endOffset)) return;
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 (activeSelectionHandle.dataset.position === 'start' && dragPositionRelativeToSelectionEnd === 1 || activeSelectionHandle.dataset.position === 'end' && dragPositionRelativeToSelectionStart === -1) {
699
- for (const selectionHandle of selectionHandles) {
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(dragAnchorNode, dragAnchorOffset, dragCaret.endContainer, dragCaret.endOffset);
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
- tapResult = null;
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 (tapResult) {
718
- tapResult.isLongPress = isLongPress;
719
- this.annotatableContainer.dispatchEvent(new CustomEvent('hh:tap', { detail: tapResult, }));
720
- if (options.autoTapToActivate && tapResult?.targetFound && !isLongPress) {
721
- if (tapResult.highlights.length === 1 && tapResult.hyperlinks.length === 0) {
722
- return this.activateHighlight(tapResult.highlights[0].highlightId);
723
- } else if (tapResult.highlights.length === 0 && tapResult.hyperlinks.length === 1) {
724
- return this.activateHyperlink(tapResult.hyperlinks[0].position);
725
- } else if (tapResult.highlights.length + tapResult.hyperlinks.length > 1) {
726
- return this.annotatableContainer.dispatchEvent(new CustomEvent('hh:ambiguousaction', { detail: tapResult, }));
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 = window.getSelection();
737
- if (selection.type === 'Range' && activeHighlightId && this.annotatableContainer.contains(selection.anchorNode)) {
738
- const adjustedSelectionRange = snapRangeToBoundaries(selection.getRangeAt(0), selection.anchorNode);
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
- tapResult = null;
753
- longPressTimeoutId = clearTimeout(longPressTimeoutId);
754
- this.annotatableContainer.dataset.hhPointerDown = 'false';
755
- if (activeSelectionHandle) {
756
- activeSelectionHandle = null;
757
- updateSelectionUi('bounds');
758
- this.annotatableContainer.removeEventListener('pointermove', respondToSelectionHandleDrag);
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 hyperlinkElements) {
361
+ for (const hyperlinkElement of this._hyperlinkElements) {
764
362
  hyperlinkElement.addEventListener('click', (event) => {
765
363
  this.deactivateHighlights();
766
- if (!allowHyperlinkClick) event.preventDefault();
767
- }, { signal: controller.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 = window.getComputedStyle(this.annotatableContainer);
772
- let previousWidth = Math.round(this.annotatableContainer.clientWidth - parseInt(computedStyle.getPropertyValue('padding-left')) - parseInt(computedStyle.getPropertyValue('padding-right')));
773
- const resizeObserver = new ResizeObserver(debounce((entries) => {
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 (previousSelectionRange) updateSelectionUi('bounds');
377
+ if (this._previousSelectionRange) this._updateSelectionUi('bounds');
780
378
  previousWidth = width;
781
379
  }
782
380
  }
783
- }, Math.floor(Object.keys(highlightsById).length / 20)));
784
- resizeObserver.observe(this.annotatableContainer);
785
-
786
-
787
- // -------- UTILITY FUNCTIONS --------
788
-
789
- // Check if the tap is in the range of an existing highlight or link
790
- const checkForTapTargets = (pointerEvent) => {
791
- if (!pointerEvent) return;
792
-
793
- // Check for tapped highlights and hyperlinks
794
- const tappedHighlights = [];
795
- for (const highlightId of Object.keys(highlightsById)) {
796
- const highlightInfo = highlightsById[highlightId];
797
- const highlightRange = highlightInfo.rangeObj;
798
- for (const rangeRect of highlightRange.getClientRects()) {
799
- if (isPointInRect(pointerEvent.clientX, pointerEvent.clientY, rangeRect, 5)) {
800
- tappedHighlights.push(highlightInfo);
801
- break;
802
- }
803
- }
804
- }
805
- const tappedHyperlinks = [];
806
- for (const hyperlinkInfo of Object.values(hyperlinksByPosition)) {
807
- if (pointerEvent.target.closest('a') === hyperlinkInfo.hyperlinkElement) {
808
- tappedHyperlinks.push(hyperlinkInfo);
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++;
809
413
  }
414
+ } else {
415
+ this.createOrUpdateHighlight(highlightInfo, false); addedCount++;
810
416
  }
811
-
812
- // Sort highlights (hyperlinks should already be sorted)
813
- const tappedHighlightIds = [];
814
- for (const highlightInfo of tappedHighlights) tappedHighlightIds.push(highlightInfo.highlightId);
815
- const sortedTappedHighlights = this.getHighlightInfo(tappedHighlightIds);
816
-
817
- return {
818
- 'targetFound': sortedTappedHighlights.length > 0 || tappedHyperlinks.length > 0,
819
- 'tapRange': getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY),
820
- 'pointerEvent': pointerEvent,
821
- 'highlights': sortedTappedHighlights,
822
- 'hyperlinks': tappedHyperlinks,
823
- }
824
417
  }
825
-
826
- // Compare new highlight information to old highlight information, returning an object with the properties that changed
827
- const diffHighlight = (newHighlightInfo, oldHighlightInfo) => {
828
- if (!oldHighlightInfo) return newHighlightInfo;
829
- const changedHighlightInfo = {}
830
- for (const key of Object.keys(newHighlightInfo)) {
831
- if (oldHighlightInfo.hasOwnProperty(key) && oldHighlightInfo[key] !== newHighlightInfo[key]) {
832
- changedHighlightInfo[key] = newHighlightInfo[key];
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;
833
461
  }
834
- }
835
- return changedHighlightInfo;
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
462
  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();
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);
856
484
  }
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]);
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
+ }
864
491
  }
865
- this.drawHighlights(overlappingHighlightIds);
866
492
  }
867
-
868
- // Normalize text nodes
869
- const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.startParagraphId}, #${highlightInfo.endParagraphId}`);
870
- rangeParagraphs.forEach(p => { p.normalize(); });
871
- getCorrectedRangeObj(highlightId);
872
- }
873
-
874
- // Remove SVG highlights
875
- if (svgBackground.querySelector(`g[data-highlight-id="${highlightId}"]`)) {
876
- const overlappingHighlightIds = new Set();
877
- svgBackground.querySelectorAll(`g[data-highlight-id="${highlightId}"]`).forEach(element => {
878
- element.remove();
879
- });
880
- }
881
-
882
- // Remove Highlight API highlights
883
- if (supportsHighlightApi && CSS.highlights.has(highlightId)) {
884
- const ruleIndexesToDelete = [];
885
- for (let r = 0; r < highlightApiStylesheet.cssRules.length; r++) {
886
- if (highlightApiStylesheet.cssRules[r].selectorText.includes(`::highlight(${highlightInfo.escapedHighlightId})`)) ruleIndexesToDelete.push(r);
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);
887
498
  }
888
- for (const index of ruleIndexesToDelete.reverse()) highlightApiStylesheet.deleteRule(index);
889
- CSS.highlights.delete(highlightId);
890
- }
891
- }
892
-
893
- // Update selection background and handles
894
- const updateSelectionUi = (changeType = 'appearance') => {
895
- const selection = window.getSelection();
896
- const selectionRange = selection.type === 'None' ? null : selection.getRangeAt(0);
897
-
898
- // If the selection starts in another annotatable container, let the other container handle it
899
- if (selection.anchorNode && selection.anchorNode.parentElement.closest('[data-hh-container]') && !this.annotatableContainer.contains(selection.anchorNode)) return;
900
-
901
- const highlightInfo = highlightsById[activeHighlightId] ?? null;
902
- const color = highlightInfo?.color ?? null;
903
- const style = highlightInfo?.style ?? null;
904
- const colorString = options.colors[color] ?? (CSS.supports('color', 'AccentColor') ? 'AccentColor' : 'dodgerblue');
905
-
906
- // Draw SVG selection rects (SVG drawing mode only)
907
- svgActiveOverlay.innerHTML = '';
908
- if (activeHighlightId && options.drawingMode === 'svg') {
909
- let range = getCorrectedRangeObj(activeHighlightId);
910
- const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
911
- const clientRects = getMergedClientRects(range, rangeParagraphs);
912
- svgActiveOverlay.dataset.color = color;
913
- svgActiveOverlay.dataset.style = style;
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;
914
524
  let svgContent = '';
915
525
  for (const clientRect of clientRects) {
916
- svgContent += getStyleTemplate(highlightInfo.style, 'svg', clientRect, true);
526
+ svgContent += this._getStyleTemplate(highlightInfo.style, 'svg', clientRect);
917
527
  }
918
- svgActiveOverlay.innerHTML = svgContent;
919
-
920
- // Bring active highlight to the front
921
- const svgHighlight = svgBackground.querySelector(`g[data-highlight-id="${activeHighlightId}"]`);
922
- if (svgHighlight) svgBackground.appendChild(svgHighlight);
923
- svgBackground.appendChild(svgActiveOverlay);
528
+ group.innerHTML = svgContent;
529
+ this._svgBackground.appendChild(group);
924
530
  }
925
-
926
- if (changeType === 'appearance') {
927
- this.annotatableContainer.style.setProperty('--hh-color', `${colorString}`);
928
-
929
- // 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).
930
- if (activeHighlightId && options.drawingMode === 'svg') {
931
- selectionStylesheet.replaceSync(`
932
- ${options.containerSelector} g[data-highlight-id="${activeHighlightId}"][data-style] { display: none; }
933
- ${options.containerSelector} .hh-wrapper-start[data-highlight-id="${activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${activeHighlightId}"] { display: none; }
934
- ${options.containerSelector} ::selection { background-color: transparent; }
935
- `);
936
- } else if (activeHighlightId) {
937
- const styleTemplate = getStyleTemplate(style, 'css', null, true);
938
- selectionStylesheet.replaceSync(`
939
- ${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
940
- ${options.containerSelector} mark[data-highlight-id="${activeHighlightId}"][data-style] { all: unset; }
941
- ${options.containerSelector} .hh-wrapper-start[data-highlight-id="${activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${activeHighlightId}"] { display: none; }
942
- ${options.containerSelector} ::selection { --hh-color: ${colorString}; ${styleTemplate} }
943
- ${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
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
- for (const selectionHandle of selectionHandles) {
973
- let side;
974
- if (selectionHandle.dataset.position === 'start') {
975
- side = startNodeIsRtl ? 'right' : 'left';
976
- selectionHandle.style.left = startRect[side] - relativeAncestorClientRect.left + 'px';
977
- selectionHandle.style.height = startRect.height + 'px';
978
- selectionHandle.style.top = startRect.top - relativeAncestorClientRect.top + 'px';
979
- } else {
980
- side = endNodeIsRtl ? 'left' : 'right';
981
- selectionHandle.style.left = endRect[side] - relativeAncestorClientRect.left + 'px';
982
- selectionHandle.style.height = endRect.height + 'px';
983
- selectionHandle.style.top = endRect.top - relativeAncestorClientRect.top + 'px';
984
- }
985
- if (selectionHandle.dataset.side !== side) {
986
- selectionHandle.dataset.side = side;
987
- this.setOption('selectionHandles', options.selectionHandles);
988
- }
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]);
989
539
  }
990
- setCustomSelectionHandleVisibility(true);
540
+ const template = document.createElement('template');
541
+ template.innerHTML = htmlString;
542
+ let htmlElement = template.content.firstChild;
543
+ range.insertNode(htmlElement);
991
544
  }
992
- } else {
993
- setCustomSelectionHandleVisibility(false);
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(); });
994
551
  }
995
552
  }
996
553
  }
997
-
998
- const setCustomSelectionHandleVisibility = (visible = selectionHandles[0].style.visibility === 'hidden') => {
999
- for (const selectionHandle of selectionHandles) {
1000
- selectionHandle.style.visibility = visible ? 'visible' : 'hidden';
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);
1001
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);
1002
655
  }
1003
-
1004
- // Update the selection or highlight range to stay within the annotatable container
1005
- const snapRangeToBoundaries = (range, anchorNode = null) => {
1006
- let startNode = range.startContainer;
1007
- let endNode = range.endContainer;
1008
- let startOffset = range.startOffset;
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
- }
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);
1025
768
  }
1026
-
1027
- // Prevent the range from starting or ending in an element that doesn't match the paragraph selector
1028
- if (this.annotatableContainer.contains(range.commonAncestorContainer) && (!startNode.parentElement.closest(options.paragraphSelector) || !endNode.parentElement.closest(options.paragraphSelector))) {
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
- }
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);
1045
784
  }
1046
-
1047
- // Snap to the nearest word
1048
- if (options.snapToWord) {
1049
- // 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.
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;
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];
852
+ const highlightRange = highlightInfo.rangeObj;
853
+ for (const rangeRect of highlightRange.getClientRects()) {
854
+ if (this._isPointInRect(pointerEvent.clientX, pointerEvent.clientY, rangeRect, 5)) {
855
+ tappedHighlights.push(highlightInfo);
856
+ break;
1059
857
  }
1060
858
  }
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;
1069
859
  }
1070
-
1071
- let newRange = document.createRange();
1072
- newRange.setStart(startNode, startOffset);
1073
- newRange.setEnd(endNode, endOffset);
1074
- return newRange;
1075
- }
1076
-
1077
- // Get the character offset relative to the annotatable paragraph
1078
- // Adapted from https://stackoverflow.com/a/4812022/1349044
1079
- const getParagraphOffset = (referenceTextNode, referenceTextNodeOffset) => {
1080
- const paragraph = referenceTextNode.parentElement.closest(options.paragraphSelector);
1081
- const referenceRange = document.createRange();
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 ];
860
+ for (const hyperlinkInfo of Object.values(this._hyperlinksByPosition)) {
861
+ if (pointerEvent.target.closest('a') === hyperlinkInfo.hyperlinkElement) {
862
+ tappedHyperlinks.push(hyperlinkInfo);
1098
863
  }
1099
864
  }
1100
- // TODO: Direction isn't always accurate (maybe it resets when selection is cleared and set to a new range programmatically?)
1101
- const direction = window.getSelection().direction;
1102
- if (direction == 'backward') {
1103
- return [ firstTextNode, 0 ];
1104
- } else {
1105
- const lastTextNode = walker.currentNode;
1106
- return [ lastTextNode, lastTextNode.textContent.length ];
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];
1107
888
  }
1108
889
  }
1109
-
1110
- // Get the first text node in an element
1111
- const getFirstTextNode = (element) => {
1112
- const firstTextNodeWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
1113
- return firstTextNodeWalker.nextNode();
1114
- }
1115
-
1116
- // Get the last text node in an element
1117
- const getLastTextNode = (element) => {
1118
- const lastTextNodeWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
1119
- let nextNode;
1120
- while (nextNode = lastTextNodeWalker.nextNode());
1121
- return lastTextNodeWalker.currentNode;
1122
- }
1123
-
1124
- // Update appearance stylesheet (user-defined colors and styles)
1125
- const updateAppearanceStylesheet = () => {
1126
- appearanceStylesheet.replaceSync('');
1127
- for (const color of Object.keys(options.colors)) {
1128
- appearanceStylesheet.insertRule(`[data-color="${color}"] { --hh-color: ${options.colors[color]}; }`);
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);
1129
902
  }
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} }`);
903
+ for (const childHighlight of element.querySelectorAll('[data-highlight-id]')) {
904
+ overlappingHighlightIds.add(childHighlight.dataset.highlightId);
1133
905
  }
1134
- }
1135
-
1136
- // Get style template for a given highlight style
1137
- const getStyleTemplate = (style, type, clientRect = null, active = false) => {
1138
- style = options.styles.hasOwnProperty(style) ? style : options.defaultStyle;
1139
- let styleTemplate = options.styles[style]?.[type] ?? '';
1140
- if (active) {
1141
- styleTemplate = options.styles[style]?.[`${type}-active`] ?? styleTemplate;
906
+ if (element.matches('mark')) {
907
+ element.outerHTML = element.innerHTML;
908
+ } else {
909
+ element.remove();
1142
910
  }
1143
- if (!styleTemplate) {
1144
- console.warn(`Highlight style "${style}" in options does not have a defined "${type}" value.`);
1145
- return;
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]);
1146
918
  }
1147
- if (type === 'svg' && clientRect) {
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);
919
+ this.drawHighlights(overlappingHighlightIds);
920
+ }
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
+ `);
1158
1003
  }
1159
- return styleTemplate;
1160
- }
1161
-
1162
- // Add a CSS stylesheet to the document
1163
- function addStylesheet(stylesheets, stylesheetKey) {
1164
- let stylesheet = stylesheets[stylesheetKey];
1165
- if (!stylesheet) {
1166
- if (supportsCssStylesheetApi) {
1167
- stylesheet = new CSSStyleSheet();
1168
- document.adoptedStyleSheets.push(stylesheet);
1169
- } else {
1170
- // For browsers that don't fully support the CSSStyleSheet API, such as Safari < 16.4.
1171
- // See https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#browser_compatibility
1172
- stylesheet = document.createElement('style');
1173
- stylesheet.appendChild(document.createTextNode(''));
1174
- stylesheet.replaceSync = (newContent) => {
1175
- stylesheet.textContent = newContent;
1176
- }
1177
- stylesheet.insertRule = (newContent) => {
1178
- stylesheet.textContent += newContent;
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) {
1024
+ let side;
1025
+ if (selectionHandle.dataset.position === 'start') {
1026
+ side = startNodeIsRtl ? 'right' : 'left';
1027
+ selectionHandle.style.left = startRect[side] - relativeAncestorClientRect.left + 'px';
1028
+ selectionHandle.style.height = startRect.height + 'px';
1029
+ selectionHandle.style.top = startRect.top - relativeAncestorClientRect.top + 'px';
1030
+ } else {
1031
+ side = endNodeIsRtl ? 'left' : 'right';
1032
+ selectionHandle.style.left = endRect[side] - relativeAncestorClientRect.left + 'px';
1033
+ selectionHandle.style.height = endRect.height + 'px';
1034
+ selectionHandle.style.top = endRect.top - relativeAncestorClientRect.top + 'px';
1035
+ }
1036
+ if (selectionHandle.dataset.side !== side) {
1037
+ selectionHandle.dataset.side = side;
1038
+ this.setOption('selectionHandles', options.selectionHandles);
1039
+ }
1179
1040
  }
1180
- document.head.appendChild(stylesheet);
1041
+ this._setCustomSelectionHandleVisibility(true);
1181
1042
  }
1182
- stylesheets[stylesheetKey] = stylesheet;
1043
+ } else {
1044
+ this._setCustomSelectionHandleVisibility(false);
1183
1045
  }
1184
- return stylesheet;
1185
- }
1186
-
1187
- // Remove CSS stylesheets (only those from the current Highlighter instance)
1188
- function removeStylesheets(stylesheets) {
1189
- for (const stylesheet of Object.values(stylesheets)) {
1190
- if (supportsCssStylesheetApi) {
1191
- const adoptedStylesheetIndex = document.adoptedStyleSheets.indexOf(stylesheet);
1192
- document.adoptedStyleSheets.splice(adoptedStylesheetIndex, 1);
1193
- } else {
1194
- stylesheet.remove();
1195
- }
1046
+ }
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;
1076
+ }
1077
+ }
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;
1084
+ }
1085
+ if (this._shouldSkipTextNode(endNode)) {
1086
+ endNode = this._getPreviousValidTextNode(endNode);
1087
+ endOffset = endNode.length;
1088
+ }
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];
1196
1093
  }
1197
1094
  }
1198
-
1199
- // Restore the previous selection range in case the browser clears the selection
1200
- const getRestoredSelectionOrCaret = (selection, pointerEvent = null) => {
1201
- if (selection.type === 'None') {
1202
- if (previousSelectionRange) {
1203
- // iOS Safari deselects text when a button is tapped. This restores the selection.
1204
- selection.addRange(previousSelectionRange);
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);
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;
1209
1106
  }
1210
1107
  }
1211
- return selection;
1212
- }
1213
-
1214
- // Fix highlight range if the DOM changed and made the previous highlight range invalid
1215
- const getCorrectedRangeObj = (highlightId) => {
1216
- const highlightInfo = highlightsById[highlightId];
1217
- const highlightRange = highlightInfo?.rangeObj;
1218
- if (highlightRange && (
1219
- highlightRange.startContainer.nodeType !== Node.TEXT_NODE || highlightRange.endContainer.nodeType !== Node.TEXT_NODE ||
1220
- highlightRange.startOffset > highlightRange.startContainer.textContent.length - 1 ||
1221
- highlightRange.endOffset > highlightRange.endContainer.textContent.length
1222
- )) {
1223
- const [ startNode, startOffset ] = getTextNodeAndOffset(document.getElementById(highlightInfo.startParagraphId), highlightInfo.startParagraphOffset);
1224
- const [ endNode, endOffset ] = getTextNodeAndOffset(document.getElementById(highlightInfo.endParagraphId), highlightInfo.endParagraphOffset);
1225
- highlightRange.setStart(startNode, startOffset);
1226
- highlightRange.setEnd(endNode, endOffset);
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;
1116
+ }
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;
1136
+ }
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 ];
1150
+ }
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);
1227
1253
  }
1228
- return highlightRange;
1229
- }
1230
-
1231
- // Convert tap or click to a selection range
1232
- // Adapted from https://stackoverflow.com/a/12924488/1349044
1233
- const getCaretFromCoordinates = (clientX, clientY, checkAnnotatable = false, checkXDistance = false, checkYDistance = false) => {
1234
- let range;
1235
- if (supportsCaretPositionFromPoint) {
1236
- let caretPosition = document.caretPositionFromPoint(clientX, clientY);
1237
- range = document.createRange();
1238
- range.setStart(caretPosition.offsetNode, caretPosition.offset);
1239
- range.collapse(true);
1240
- } else if (supportsCaretRangeFromPoint) {
1241
- range = document.caretRangeFromPoint(clientX, clientY);
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;
1242
1318
  }
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;
1319
+ }
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;
1327
+ }
1249
1328
  }
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
- for (let ur = unmergedRects.length - 1; ur >= 0; ur--) {
1277
- const rect = unmergedRects[ur];
1278
- for (const element of ancestorElementsInRange) {
1279
- const elementRect = element.getBoundingClientRect();
1280
- 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)) {
1281
- unmergedRects.splice(ur, 1);
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
- // Loop through the highlight's paragraphs
1288
- for (const paragraph of paragraphs) {
1289
- const paragraphRect = paragraph.getBoundingClientRect();
1290
- const computedParagraphStyle = window.getComputedStyle(paragraph);
1291
- const paragraphTopPadding = parseInt(computedParagraphStyle.getPropertyValue('padding-top').replace('px', ''));
1292
-
1293
- // Get line positions (bottom edge of each line)
1294
- let linePositions = new Set();
1295
- let lineWalker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
1296
- let textNode;
1297
- while (textNode = lineWalker.nextNode()) {
1298
- // Skip text nodes in elements that are higher or lower than surrounding text
1299
- if (textNode.parentElement.closest('sup, sub, rt')) continue;
1300
- const referenceRange = document.createRange();
1301
- referenceRange.selectNode(textNode);
1302
- for (const rangeRect of referenceRange.getClientRects()) linePositions.add(rangeRect.bottom);
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
- linePositions = Array.from(linePositions);
1305
-
1306
- // Create a merged rect for each line
1307
- for (let ln = 0; ln < linePositions.length; ln++) {
1308
- const linePosition = linePositions[ln];
1309
- const previousLinePosition = ln === 0 ? paragraphRect.top + paragraphTopPadding : linePositions[ln-1];
1310
- const mergedRect = new DOMRect(-1, previousLinePosition, -1, linePosition - previousLinePosition);
1311
- if (mergedRects.length > 0 && ln === 1 && mergedRects[mergedRects.length-1].height < mergedRect.height) {
1312
- // Increase the row height for the first line in the paragraph to match the second line
1313
- mergedRects[mergedRects.length-1].y += mergedRects[mergedRects.length-1].height - mergedRect.height;
1314
- mergedRects[mergedRects.length-1].height = mergedRect.height;
1315
- }
1316
- for (let r = 0; r < unmergedRects.length; r++) {
1317
- const rect = unmergedRects[r];
1318
- const rectVerticalPosition = rect.y + (rect.height / 2);
1319
- if (rectVerticalPosition > mergedRect.y && rectVerticalPosition < mergedRect.y + mergedRect.height) {
1320
- if (mergedRect.x === -1) {
1321
- mergedRect.x = rect.x;
1322
- mergedRect.width = rect.width;
1323
- }
1324
- // Process then remove rects that apply to the current line
1325
- const minLeft = Math.max(paragraphRect.left, Math.min(mergedRect.x, rect.x));
1326
- const maxRight = Math.min(paragraphRect.right, Math.max(mergedRect.right, rect.right));
1327
- mergedRect.width = maxRight - minLeft;
1328
- mergedRect.x = minLeft;
1329
- unmergedRects.splice(r, 1); r--;
1330
- }
1331
- }
1332
- if (mergedRect.width > 0) mergedRects.push(mergedRect);
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;
1333
1424
  }
1425
+ stylesheet.insertRule = (newContent) => {
1426
+ stylesheet.textContent += newContent;
1427
+ }
1428
+ document.head.appendChild(stylesheet);
1334
1429
  }
1335
-
1336
- return mergedRects;
1337
- }
1338
-
1339
- // Debounce a function to prevent it from being executed too frequently
1340
- // Adapted from https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086
1341
- function debounce(func, wait) {
1342
- let timeout;
1343
- return function executedFunction(...args) {
1344
- const later = () => {
1345
- clearTimeout(timeout);
1346
- func(...args);
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
- timeout = setTimeout(later, wait);
1454
+ func(...args);
1350
1455
  };
1351
- }
1352
-
1353
- updateAppearanceStylesheet();
1354
- updateSelectionUi('appearance');
1355
- this.setOption('selectionHandles', options.selectionHandles);
1456
+ clearTimeout(timeout);
1457
+ timeout = setTimeout(later, wait);
1458
+ };
1356
1459
  }
1357
1460
 
1461
+ // Default function for generating a highlight ID
1462
+ const _getNewHighlightId = () => {
1463
+ return 'hh-' + Date.now().toString();
1464
+ }
1358
1465
 
1359
- // -------- DEFAULTS --------
1466
+
1467
+ /********************** Constants **********************/
1360
1468
 
1361
1469
  // Keep track of all Highlighter instances
1362
- let hhHighlighters = [];
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 && window.webkit?.messageHandlers;
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
- // Workaround to allow programmatic text selection on tap in iOS Safari
1374
- // See https://stackoverflow.com/a/79261423/1349044
1375
- if (isTouchDevice && isWebKit) {
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
- let hhDefaultOptions = {
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: hhGetNewHighlightId,
1547
+ highlightIdFunction: _getNewHighlightId,
1467
1548
  showCustomSelectionHandlesForActiveHighlights: true,
1468
1549
  showCustomSelectionHandlesForTextSelection: false,
1469
1550
  showCustomSelectionHandlesOnTouch: false,
1470
1551
  }
1471
1552
 
1472
- console.info('Highlighter loaded');
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 ES module (highlight-helper.mjs)
1475
- window.Highlighter = Highlighter;
1579
+ // Make Highlighter available to JavaScript module (highlight-helper.mjs)
1580
+ globalThis.Highlighter = Highlighter;