@samuelbradshaw/highlight-helper-js 0.10.0 → 1.0.1

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.
@@ -3,1620 +3,2134 @@
3
3
  * https://github.com/samuelbradshaw/highlight-helper-js
4
4
  */
5
5
 
6
- 'use strict';
6
+ (function () {
7
+ 'use strict';
7
8
 
8
- /********************** Highlighter Initialization **********************/
9
+ /********************** Highlighter Initialization **********************/
9
10
 
10
- function Highlighter(options = _defaultOptions) {
11
- for (const key of Object.keys(_defaultOptions)) {
12
- options[key] = options[key] ?? _defaultOptions[key];
13
- }
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.setOptions({ 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(',');
68
- }
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;
80
- }
11
+ function Highlighter(containerSelector = 'body', paragraphSelector = ':is(h1, h2, h3, h4, h5, h6, p, ol, ul, dl)[id]') {
12
+ this._options = structuredClone(_defaultOptions);
81
13
 
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;
14
+ // Load container
15
+ this._containerSelector = containerSelector;
16
+ this._container = document.querySelector(containerSelector);
17
+ if (!this._container) {
18
+ return console.error(`Unable to create Highlighter with container selector "${containerSelector}" (element not found).`);
88
19
  }
89
- ancestorElement = ancestorElement.parentElement;
90
- }
91
20
 
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
- const hyperlink = this._hyperlinkElements[hyp];
116
- hyperlink.dataset.hhPosition = hyp;
117
- this._hyperlinksByPosition[hyp] = {
118
- 'position': hyp,
119
- 'text': hyperlink.innerHTML,
120
- 'url': hyperlink.href,
121
- 'hyperlinkElement': hyperlink,
21
+ // Handle cases where a highlighter already exists for the container, or one of its children or ancestors
22
+ if (this._container.highlighter) {
23
+ this._container.highlighter.removeHighlighter();
24
+ } else if (this._container.closest('[data-hh-container]') || this._container.querySelector('[data-hh-container]')) {
25
+ return console.error(`Unable to create Highlighter with container selector "${containerSelector}" (annotatable container can't be a child or ancestor of another annotatable container).`);
122
26
  }
123
- }
124
27
 
125
- this._highlightsById = {};
126
- this._annotatableContainer.dataset.hhContainer = '';
127
- this._annotatableContainer.highlighter = this;
128
- _highlighters.push(this);
129
-
130
- return true;
131
- }
132
-
133
- Highlighter.prototype._loadStyles = function () {
134
- const options = this._options;
135
-
136
- // Set up stylesheets
137
- this._stylesheets = {}
138
- this._generalStylesheet = addStylesheet(this._stylesheets, 'general');
139
- this._appearanceStylesheet = addStylesheet(this._stylesheets, 'appearance');
140
- this._highlightApiStylesheet = addStylesheet(this._stylesheets, 'highlight-api');
141
- this._selectionStylesheet = addStylesheet(this._stylesheets, 'selection');
142
- this._generalStylesheet.replaceSync(`
143
- ${options.containerSelector} {
144
- -webkit-tap-highlight-color: transparent;
145
- }
146
- .hh-wrapper-start, .hh-wrapper-end, .hh-selection-handle {
147
- -webkit-user-select: none;
148
- user-select: none;
149
- }
150
- .hh-selection-handle {
151
- position: absolute;
152
- width: 0px;
153
- visibility: hidden;
154
- }
155
- [data-hh-pointer-down="true"] .hh-selection-handle {
156
- visibility: hidden !important;
157
- }
158
- .hh-selection-handle-content {
159
- position: absolute;
160
- height: 100%;
161
- }
162
- .hh-selection-handle [draggable] {
163
- position: absolute;
164
- top: -0.3em;
165
- width: 3.2em;
166
- height: calc(100% + 1.3em);
167
- background-color: transparent;
168
- z-index: 1;
169
- }
170
- .hh-selection-handle[data-side="left"] [draggable] { right: -2em; }
171
- .hh-selection-handle[data-side="right"] [draggable] { left: -2em; }
172
- .hh-default-handle {
173
- position: absolute;
174
- width: 0.8em;
175
- height: min(1.2em, 100%);
176
- background-color: hsl(from var(--hh-color) h 80% 40% / 1);
177
- outline: 0.1em solid hsla(0, 0%, 100%, 0.8);
178
- outline-offset: -0.05em;
179
- bottom: -0.2em;
180
- }
181
- .hh-selection-handle[data-side="left"] .hh-default-handle {
182
- right: 0;
183
- border-radius: 1em 0 0.6em 0.6em;
184
- }
185
- .hh-selection-handle[data-side="right"] .hh-default-handle {
186
- left: 0;
187
- border-radius: 0 1em 0.6em 0.6em;
188
- }
189
- .hh-svg-background {
190
- position: absolute;
191
- top: 0;
192
- left: 0;
193
- width: 100%;
194
- overflow: visible;
195
- z-index: -1;
196
- }
197
- .hh-svg-background g {
198
- fill: transparent;
199
- stroke: none;
200
- }
201
- mark[data-highlight-id] {
202
- background-color: transparent;
203
- color: inherit;
204
- }
205
- `);
206
- }
207
-
208
- Highlighter.prototype._loadEventListeners = function () {
209
- const options = this._options;
210
-
211
- // Selection change in document (new selection, change in selection range, or selection collapsing to a caret)
212
- const respondToSelectionChange = (event) => {
213
- const selection = this._getRestoredSelectionOrCaret(globalThis.getSelection());
214
- const selectionRange = selection.type === 'None' ? null : selection.getRangeAt(0);
28
+ // Load annotatable paragraphs
29
+ this._paragraphSelector = paragraphSelector;
30
+ this._paragraphIds = [];
31
+ this._paragraphs = Array.from(this._container.querySelectorAll(paragraphSelector));
32
+ for (const paragraph of this._paragraphs) {
33
+ if (!paragraph.id) {
34
+ return console.error(`Unable to create Highlighter with paragraph selector "${paragraphSelector}" (each selected paragraph must have a valid ID).`);
35
+ } else if (this._paragraphIds.includes(paragraph.id)) {
36
+ return console.error(`Unable to create Highlighter with paragraph selector "${paragraphSelector}" (each selected paragraph's ID must be unique).`);
37
+ }
38
+ this._paragraphIds.push(paragraph.id);
39
+ }
215
40
 
216
- // 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:
217
- // 1. While dragging custom selection handles (happens randomly). TODO: Dragging custom selection handles in this environment is still sometimes a little jumpy.
218
- // 2. Just after clicking to activate a highlight (happens if it's the first click after the page loads).
219
- 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))) {
220
- selection.setBaseAndExtent(this._previousSelectionRange.startContainer, this._previousSelectionRange.startOffset, this._previousSelectionRange.endContainer, this._previousSelectionRange.endOffset);
41
+ // Abort controller can be used to cancel event listeners if the highlighter is removed
42
+ this._controller = new AbortController();
43
+
44
+ // 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.
45
+ document.body.tabIndex = -1;
46
+
47
+ // Set up additions (SVG background and selection handles)
48
+ this._container.insertAdjacentHTML('beforeend', `
49
+ <div data-hh-additions="">
50
+ <svg data-hh-svg-background=""><g data-hh-svg-active-overlay=""></g></svg>
51
+ <div data-hh-handle="" data-hh-side="left" data-hh-position="start" data-hh-ignore=""><div draggable="true"></div><div data-hh-handle-content=""></div></div>
52
+ <div data-hh-handle="" data-hh-side="right" data-hh-position="end" data-hh-ignore=""><div draggable="true"></div><div data-hh-handle-content=""></div></div>
53
+ </div>
54
+ `);
55
+ this._additionsDiv = this._container.querySelector('[data-hh-additions]');
56
+ this._svgBackground = this._additionsDiv.querySelector('[data-hh-svg-background]');
57
+ this._svgActiveOverlay = this._additionsDiv.querySelector('[data-hh-svg-active-overlay]');
58
+ this._dragHandles = this._additionsDiv.querySelectorAll('[data-hh-handle]');
59
+
60
+ // Check for hyperlinks on the page
61
+ this._hyperlinkElements = this._container.getElementsByTagName('a');
62
+ this._hyperlinksByIndex = {}
63
+ for (let hyp = 0; hyp < this._hyperlinkElements.length; hyp++) {
64
+ const hyperlink = this._hyperlinkElements[hyp];
65
+ hyperlink.dataset.hhIndex = hyp;
66
+ this._hyperlinksByIndex[hyp] = {
67
+ 'index': hyp,
68
+ 'text': hyperlink.innerHTML,
69
+ 'url': hyperlink.href,
70
+ 'hyperlinkElement': hyperlink,
71
+ }
221
72
  }
222
73
 
223
- // Deactivate highlights when tapping or creating a selection outside of the previous selection range
224
- if (!this._activeSelectionHandle && this._previousSelectionRange && (selection.type !== 'Range' || this._previousSelectionRange.comparePoint(selectionRange.startContainer, selectionRange.startOffset) === 1 || this._previousSelectionRange.comparePoint(selectionRange.endContainer, selectionRange.endOffset) === -1)) {
225
- this.deactivateHighlights(false);
74
+ this._highlightsById = {};
75
+ this._wrapperElements = new Map();
76
+ this._container.dataset.hhContainer = '';
77
+ this._container.highlighter = this;
78
+ this._selectionState = {
79
+ activeHighlightId: null,
80
+ color: null,
81
+ style: null,
82
+ isResizing: false,
83
+ pointerType: null,
84
+ selection: globalThis.getSelection(),
85
+ changes: [],
86
+ rangeText: null,
87
+ rangeHtml: null,
88
+ rangeRect: null,
89
+ rangeLineRects: [],
90
+ rangeParagraphIds: null,
91
+ rangeObj: null,
92
+ };
93
+ this._isResizingSelection = false;
94
+ this._previousBoundsHash = null;
95
+ this._containerRect = null;
96
+ this._additionsRect = null;
97
+
98
+ this._loadStyles();
99
+ this._loadEventListeners();
100
+ this._updateColorStylesheet();
101
+ this._updateSelectionState();
102
+ for (const handle of this._dragHandles) {
103
+ handle.children[1].innerHTML = this._options.dragHandles[handle.dataset.hhSide] ?? '';
226
104
  }
105
+ }
227
106
 
228
- if (selection.type === 'Range') {
229
- // Clear tap result (prevents hh:tap event from being sent when long-pressing or dragging to select text)
230
- this._tapResult = null;
107
+ Highlighter.prototype._loadStyles = function () {
108
+ // Set up stylesheets
109
+ this._stylesheets = {}
110
+ this._generalStylesheet = _addStylesheet(this._stylesheets, 'general');
111
+ this._colorStylesheet = _addStylesheet(this._stylesheets, 'color');
112
+ this._highlightStylesheet = _addStylesheet(this._stylesheets, 'highlight');
113
+ this._selectionStylesheet = _addStylesheet(this._stylesheets, 'selection');
114
+ this._generalStylesheet.replaceSync(`
115
+ ${this._containerSelector} {
116
+ -webkit-tap-highlight-color: transparent;
117
+ }
118
+ [data-hh-wrapper], [data-hh-additions], rt {
119
+ /* Disable selection on elements in the container that might be absolutely-positioned (reduces jumpy selection) */
120
+ -webkit-user-select: none;
121
+ user-select: none;
122
+ }
123
+ [data-hh-handle] {
124
+ position: absolute;
125
+ width: 0px;
126
+ visibility: hidden;
127
+ }
128
+ [data-hh-pointer-down="true"] [data-hh-handle] {
129
+ /* Hiding selection handle reduces jumpiness when dragging (and makes it easier to see the selection) */
130
+ visibility: hidden !important;
131
+ }
132
+ [data-hh-handle-content] {
133
+ position: absolute;
134
+ height: 100%;
135
+ }
136
+ [data-hh-handle] [draggable] {
137
+ position: absolute;
138
+ top: -0.3em;
139
+ width: 3.2em;
140
+ height: calc(100% + 1.3em);
141
+ background-color: transparent;
142
+ z-index: 1;
143
+ }
144
+ [data-hh-handle][data-hh-side="left"] [draggable] { right: -2em; }
145
+ [data-hh-handle][data-hh-side="right"] [draggable] { left: -2em; }
146
+ [data-hh-default-handle] {
147
+ position: absolute;
148
+ width: 0.8em;
149
+ height: min(1.2em, 100%);
150
+ background-color: hsl(from var(--hh-color) h 80% 40% / 1);
151
+ outline: 0.1em solid hsla(0, 0%, 100%, 0.8);
152
+ outline-offset: -0.05em;
153
+ bottom: -0.2em;
154
+ }
155
+ [data-hh-handle][data-hh-side="left"] [data-hh-default-handle] {
156
+ right: 0;
157
+ border-radius: 1em 0 0.6em 0.6em;
158
+ }
159
+ [data-hh-handle][data-hh-side="right"] [data-hh-default-handle] {
160
+ left: 0;
161
+ border-radius: 0 1em 0.6em 0.6em;
162
+ }
163
+ [data-hh-additions] {
164
+ position: absolute;
165
+ top: 0; left: 0;
166
+ overflow: visible;
167
+ }
168
+ [data-hh-svg-background] {
169
+ position: absolute;
170
+ top: 0; left: 0;
171
+ /* In mobile Chrome and Safari, if a user drags a text selection handle up to the very top of the page, it gets caught by the SVG background, causing text selection to jump to the bottom of the page. Setting pointer-events to none prevents this. */
172
+ pointer-events: none;
173
+ overflow: visible;
174
+ z-index: -1;
175
+ }
176
+ [data-hh-svg-background] g {
177
+ fill: transparent;
178
+ stroke: none;
179
+ }
180
+ mark[data-hh-highlight-id] {
181
+ print-color-adjust: exact;
182
+ background-color: transparent;
183
+ color: inherit;
184
+ }
185
+ .sr-only {
186
+ position: absolute;
187
+ width: 1px; height: 1px;
188
+ padding: 0; margin: -1px;
189
+ overflow: hidden;
190
+ clip: rect(0, 0, 0, 0);
191
+ white-space: nowrap;
192
+ border: 0;
193
+ }
194
+ `);
195
+ }
196
+
197
+ Highlighter.prototype._loadEventListeners = function () {
198
+ const options = this._options;
199
+
200
+ // Selection change in document (new selection, change in selection range, or selection collapsing to a caret)
201
+ const respondToSelectionChange = (event) => {
202
+ const selection = this._getRestoredSelectionOrCaret(globalThis.getSelection());
203
+ const selectionRange = selection.type === 'None' ? null : selection.getRangeAt(0);
204
+ const selectionContainer = this._getSelectionContainer();
205
+ const selectionInThisContainer = selectionContainer === this._container;
206
+ const selectionInOtherContainer = selectionContainer !== null && !selectionInThisContainer;
207
+ if (selectionInOtherContainer) return;
208
+
209
+ // If the selection collapses to a caret inside the previous selection range, restore the range. This handles:
210
+ // 1. "Mac (Designed for iPad)" in-app webviews collapsing the selection just after clicking to activate a highlight, or randomly while dragging custom handles.
211
+ // 2. Android Chrome collapsing the selection when the user taps a form control (e.g. a <select>) outside the container while a highlight is active.
212
+ if (this._previousSelectionRange && selection.type === 'Caret') {
213
+ const caret = selectionRange;
214
+ const isCaretInPreviousRange = this._previousSelectionRange.comparePoint(caret.startContainer, caret.startOffset) === 0;
215
+ if (this._activeHandle || isCaretInPreviousRange) {
216
+ return this._restorePreviousSelectionRange();
217
+ }
218
+ }
231
219
 
232
- if (this._annotatableContainer.contains(selection.anchorNode)) {
233
- if (this._activeHighlightId || (options.pointerMode === 'live' || (options.pointerMode === 'auto' && this._pointerType === 'pen'))) {
234
- this.createOrUpdateHighlight({ highlightId: this._activeHighlightId, });
220
+ // Mitigate a bug in Chrome (desktop and Android) where dragging a selection past the viewport edge jumps to everything before or after the selection anchor
221
+ if (this._previousSelectionRange && selection.focusNode.nodeType !== Node.TEXT_NODE) {
222
+ return this._restorePreviousSelectionRange();
223
+ }
224
+
225
+ // If the user taps away or creates a new selection range outside of the previous selection range, deactivate any active highlights (but don't clear the selection range that was just created)
226
+ this._detectSelectionResize(this._previousSelectionRange, selectionRange, selection.type);
227
+ if (!this._activeHandle && !this._isResizingSelection && this._previousSelectionRange) {
228
+ const isSelectionOutsidePreviousRange =
229
+ this._previousSelectionRange.comparePoint(selectionRange.startContainer, selectionRange.startOffset) === 1 ||
230
+ this._previousSelectionRange.comparePoint(selectionRange.endContainer, selectionRange.endOffset) === -1;
231
+ if (selection.type !== 'Range' || isSelectionOutsidePreviousRange) {
232
+ this.deactivateHighlights(false);
235
233
  }
236
- this._previousSelectionRange = selectionRange.cloneRange();
237
234
  }
235
+
236
+ if (selection.type === 'Range') {
237
+ // Clear tap result (prevents hh:tap event from being sent when long-pressing or dragging to select text)
238
+ this._tapResult = null;
239
+
240
+ if (selectionInThisContainer) {
241
+ if (this._activeHighlightId) {
242
+ this.createOrUpdateHighlight({ highlightId: this._activeHighlightId });
243
+ }
244
+
245
+ const anchorCaret = new Range();
246
+ anchorCaret.setStart(selection.anchorNode, selection.anchorOffset);
247
+ anchorCaret.collapse(true);
248
+ this._previousSelectionDirection = anchorCaret.comparePoint(selection.focusNode, selection.focusOffset) === 1 ? 'forward' : 'backward';
249
+ this._previousSelectionRange = selectionRange.cloneRange();
250
+ }
251
+ }
252
+
253
+ this._updateSelectionState();
238
254
  }
255
+ document.addEventListener('selectionchange', (event) => respondToSelectionChange(event), { signal: this._controller.signal });
256
+
257
+ // Pointer down in container
258
+ const respondToPointerDown = (event) => {
259
+ const isSecondaryClick = (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey);
260
+ if (!isSecondaryClick) this._container.dataset.hhPointerDown = 'true';
261
+ this._pointerType = event.pointerType;
262
+
263
+ // Pointer down on a selection handle
264
+ if (event.target?.closest('[data-hh-handle]')) {
265
+ if (!isSecondaryClick) {
266
+ this._activeHandle = event.target.parentElement.closest('[data-hh-handle]');
267
+ const handleClientRect = this._activeHandle.getBoundingClientRect();
268
+ const lineHeight = handleClientRect.bottom - handleClientRect.top;
269
+ this._activeHandle.dataset.hhDragYOffset = Math.max(0, event.clientY - handleClientRect.bottom + (lineHeight / 6));
270
+ const selectionRange = globalThis.getSelection().getRangeAt(0);
271
+ this._dragAnchorNode = this._activeHandle.dataset.hhPosition === 'start' ? selectionRange.endContainer : selectionRange.startContainer;
272
+ this._dragAnchorOffset = this._activeHandle.dataset.hhPosition === 'start' ? selectionRange.endOffset : selectionRange.startOffset;
273
+ this._container.addEventListener('pointermove', respondToHandleDrag, { signal: this._controller.signal });
274
+ this._updateSelectionState();
275
+ }
276
+ // Prevent default drag interaction (which would show a thumbnail and drag selected text)
277
+ return event.preventDefault();
278
+ }
239
279
 
240
- this._updateSelectionUi('bounds');
241
- }
242
- document.addEventListener('selectionchange', (event) => respondToSelectionChange(event), { signal: this._controller.signal });
243
-
244
- // Pointer down in annotatable container
245
- const respondToPointerDown = (event) => {
246
- const isSecondaryClick = (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey);
247
- if (!isSecondaryClick) this._annotatableContainer.dataset.hhPointerDown = 'true';
248
- this._pointerType = event.pointerType;
249
-
250
- // Pointer down on a selection handle
251
- if (event.target?.closest('.hh-selection-handle')) {
252
- if (!isSecondaryClick) {
253
- this._activeSelectionHandle = event.target.parentElement.closest('.hh-selection-handle');
254
- const selectionHandleClientRect = this._activeSelectionHandle.getBoundingClientRect();
255
- const lineHeight = selectionHandleClientRect.bottom - selectionHandleClientRect.top;
256
- this._activeSelectionHandle.dataset.dragYOffset = Math.max(0, event.clientY - selectionHandleClientRect.bottom + (lineHeight / 6));
257
- const selectionRange = globalThis.getSelection().getRangeAt(0);
258
- this._dragAnchorNode = this._activeSelectionHandle.dataset.position === 'start' ? selectionRange.endContainer : selectionRange.startContainer;
259
- this._dragAnchorOffset = this._activeSelectionHandle.dataset.position === 'start' ? selectionRange.endOffset : selectionRange.startOffset;
260
- this._annotatableContainer.addEventListener('pointermove', respondToSelectionHandleDrag, { signal: this._controller.signal });
261
- this._updateSelectionUi('bounds');
262
- }
263
- // Prevent default drag interaction (which would show a thumbnail and drag selected text)
264
- return event.preventDefault();
265
- }
266
-
267
- // 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.
268
- if (this._doubleTapTimeoutId) {
269
- return this.deactivateHighlights();
270
- } else {
271
- this._doubleTapTimeoutId = setTimeout(() => this._doubleTapTimeoutId = clearTimeout(this._doubleTapTimeoutId), 500);
280
+ // 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.
281
+ if (this._doubleTapTimeoutId) {
282
+ return this.deactivateHighlights();
283
+ } else {
284
+ this._doubleTapTimeoutId = setTimeout(() => this._doubleTapTimeoutId = clearTimeout(this._doubleTapTimeoutId), 500);
285
+ }
286
+
287
+ // Return if it's not a regular click
288
+ if (isSecondaryClick) return;
289
+
290
+ // Trigger a long-press event if the user doesn't lift their finger within the specified time
291
+ if (options.longPressTimeout) this._longPressTimeoutId = setTimeout(() => respondToLongPress(event), options.longPressTimeout);
292
+
293
+ this._tapResult = this._checkForTapTargets(event);
272
294
  }
295
+ this._container.addEventListener('pointerdown', (event) => respondToPointerDown(event), { signal: this._controller.signal });
273
296
 
274
- // Return if it's not a regular click, or if the user is tapping away from an existing selection
275
- if (this._previousSelectionRange || isSecondaryClick) return;
297
+ // Selection handle drag (this function is added as an event listener on pointerdown, and removed on pointerup)
298
+ const respondToHandleDrag = (event) => {
299
+ this._activeHandle.dataset.hhPointerXPosition = event.clientX;
300
+ this._activeHandle.dataset.hhPointerYPosition = event.clientY;
301
+ const selection = globalThis.getSelection();
302
+ const selectionRange = selection.getRangeAt(0);
303
+ const dragCaret = this._getCaretFromCoordinates(event.clientX, event.clientY - this._activeHandle.dataset.hhDragYOffset, true, false, true);
276
304
 
277
- // Trigger a long-press event if the user doesn't lift their finger within the specified time
278
- if (options.longPressTimeout) this._longPressTimeoutId = setTimeout(() => respondToLongPress(event), options.longPressTimeout);
305
+ // 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
306
+ if (!dragCaret || dragCaret.startContainer.nodeType !== Node.TEXT_NODE || dragCaret.endContainer.nodeType !== Node.TEXT_NODE || (this._dragAnchorNode === dragCaret.endContainer && this._dragAnchorOffset === dragCaret.endOffset)) return;
279
307
 
280
- this._tapResult = this._checkForTapTargets(event);
281
- }
282
- this._annotatableContainer.addEventListener('pointerdown', (event) => respondToPointerDown(event), { signal: this._controller.signal });
308
+ // Check if start and end selection handles switched positions
309
+ const dragPositionRelativeToSelectionStart = dragCaret.compareBoundaryPoints(Range.START_TO_END, selectionRange);
310
+ const dragPositionRelativeToSelectionEnd = dragCaret.compareBoundaryPoints(Range.END_TO_END, selectionRange);
311
+ if (this._activeHandle.dataset.hhPosition === 'start' && dragPositionRelativeToSelectionEnd === 1 || this._activeHandle.dataset.hhPosition === 'end' && dragPositionRelativeToSelectionStart === -1) {
312
+ for (const handle of this._dragHandles) {
313
+ handle.dataset.hhPosition = handle.dataset.hhPosition === 'start' ? 'end' : 'start';
314
+ }
315
+ }
283
316
 
284
- // Selection handle drag (this function is added as an event listener on pointerdown, and removed on pointerup)
285
- const respondToSelectionHandleDrag = (event) => {
286
- this._activeSelectionHandle.dataset.pointerXPosition = event.clientX;
287
- this._activeSelectionHandle.dataset.pointerYPosition = event.clientY;
288
- const selection = globalThis.getSelection();
289
- const selectionRange = selection.getRangeAt(0);
290
- const dragCaret = this._getCaretFromCoordinates(event.clientX, event.clientY - this._activeSelectionHandle.dataset.dragYOffset, true, false, true);
317
+ // Update selection
318
+ selection.setBaseAndExtent(this._dragAnchorNode, this._dragAnchorOffset, dragCaret.endContainer, dragCaret.endOffset);
319
+ }
291
320
 
292
- // 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
293
- if (!dragCaret || dragCaret.startContainer.nodeType !== Node.TEXT_NODE || dragCaret.endContainer.nodeType !== Node.TEXT_NODE || (this._dragAnchorNode === dragCaret.endContainer && this._dragAnchorOffset === dragCaret.endOffset)) return;
321
+ // Long press in container (triggered by setTimeout() in pointerdown event)
322
+ const respondToLongPress = (event) => {
323
+ respondToPointerUp(event, true);
324
+ this._tapResult = null;
325
+ }
294
326
 
295
- // Check if start and end selection handles switched positions
296
- const dragPositionRelativeToSelectionStart = dragCaret.compareBoundaryPoints(Range.START_TO_END, selectionRange);
297
- const dragPositionRelativeToSelectionEnd = dragCaret.compareBoundaryPoints(Range.END_TO_END, selectionRange);
298
- if (this._activeSelectionHandle.dataset.position === 'start' && dragPositionRelativeToSelectionEnd === 1 || this._activeSelectionHandle.dataset.position === 'end' && dragPositionRelativeToSelectionStart === -1) {
299
- for (const selectionHandle of this._selectionHandles) {
300
- selectionHandle.dataset.position = selectionHandle.dataset.position === 'start' ? 'end' : 'start';
327
+ // Pointer up in container
328
+ const respondToPointerUp = (event, isLongPress = false) => {
329
+ if (this._tapResult) {
330
+ this._tapResult.isLongPress = isLongPress;
331
+ this._container.dispatchEvent(new CustomEvent('hh:tap', { detail: this._tapResult, }));
301
332
  }
302
333
  }
334
+ this._container.addEventListener('pointerup', (event) => respondToPointerUp(event), { signal: this._controller.signal });
335
+
336
+ // Pointer up or cancel in window
337
+ const respondToWindowPointerUp = (event) => {
338
+ const selection = globalThis.getSelection();
339
+ if (selection.type === 'Range' && this._activeHighlightId && this._getSelectionContainer() === this._container) {
340
+ const adjustedSelectionRange = this._snapRangeToBoundaries(selection.getRangeAt(0), selection.anchorNode);
341
+
342
+ // Update the selection range if needed
343
+ // 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.
344
+ const selectionRange = selection.getRangeAt(0);
345
+ const rangesMatch = _rangesEqual(selectionRange, adjustedSelectionRange);
346
+ if (!rangesMatch) {
347
+ selection.setBaseAndExtent(adjustedSelectionRange.startContainer, adjustedSelectionRange.startOffset, adjustedSelectionRange.endContainer, adjustedSelectionRange.endOffset);
348
+ }
349
+ }
350
+ this._tapResult = null;
351
+ this._longPressTimeoutId = clearTimeout(this._longPressTimeoutId);
352
+ this._container.dataset.hhPointerDown = 'false';
353
+ if (this._activeHandle || this._isResizingSelection) {
354
+ this._activeHandle = null;
355
+ this._isResizingSelection = false;
356
+ this._updateSelectionState();
357
+ this._container.removeEventListener('pointermove', respondToHandleDrag);
358
+ }
359
+ }
360
+ globalThis.addEventListener('pointerup', (event) => respondToWindowPointerUp(event), { signal: this._controller.signal });
361
+ globalThis.addEventListener('pointercancel', (event) => respondToWindowPointerUp(event), { signal: this._controller.signal });
362
+
363
+ // Mouse move in container (for hh:hover events)
364
+ let rafId = null;
365
+ let latestMoveEvent = null;
366
+ const handleHoverEvent = (event) => {
367
+ const targets = this.getTargetsAtPoint(event.clientX, event.clientY);
368
+ const newHoverHash = [
369
+ ...targets.highlights.map(h => h.highlightId),
370
+ ...targets.wrappers.map(w => `${w.highlight.highlightId}:${w.position}`),
371
+ ...targets.hyperlinks.map(h => h.index),
372
+ ].sort().join('\u0000');
373
+ if (newHoverHash === this._hoverHash) return;
374
+ this._hoverHash = newHoverHash;
375
+ this._container.dispatchEvent(new CustomEvent('hh:hover', {
376
+ detail: {
377
+ ...targets,
378
+ selectionType: globalThis.getSelection().type,
379
+ activeHighlightId: this._activeHighlightId,
380
+ nativeEvent: event,
381
+ },
382
+ }));
383
+ };
384
+ this._container.addEventListener('mousemove', (event) => {
385
+ if (!options.enableHover) return;
386
+ latestMoveEvent = event;
387
+ rafId ??= requestAnimationFrame(() => { rafId = null; handleHoverEvent(latestMoveEvent); });
388
+ }, { signal: this._controller.signal });
389
+ this._container.addEventListener('mouseleave', (event) => {
390
+ if (!options.enableHover) return;
391
+ handleHoverEvent(event);
392
+ }, { signal: this._controller.signal });
303
393
 
304
- // Update selection
305
- selection.setBaseAndExtent(this._dragAnchorNode, this._dragAnchorOffset, dragCaret.endContainer, dragCaret.endOffset);
306
- }
394
+ // Hyperlink click (for each hyperlink in container)
395
+ for (const hyperlinkElement of this._hyperlinkElements) {
396
+ hyperlinkElement.addEventListener('click', (event) => {
397
+ this.deactivateHighlights();
398
+ if (!this._allowHyperlinkClick) event.preventDefault();
399
+ }, { signal: this._controller.signal });
400
+ }
401
+
402
+ // Container resize (debounced)
403
+ let computedStyle = globalThis.getComputedStyle(this._container);
404
+ let previousWidth = Math.round(this._container.clientWidth - Number.parseInt(computedStyle.getPropertyValue('padding-left')) - Number.parseInt(computedStyle.getPropertyValue('padding-right')));
405
+ let previousHeight = Math.round(this._container.clientHeight - Number.parseInt(computedStyle.getPropertyValue('padding-top')) - Number.parseInt(computedStyle.getPropertyValue('padding-bottom')));
406
+ this._resizeObserver = new ResizeObserver(_debounce((entries) => {
407
+ for (const entry of entries) {
408
+ const width = Math.round(entry.contentBoxSize[0].inlineSize);
409
+ const height = Math.round(entry.contentBoxSize[0].blockSize);
410
+ // Respond to width changes (resize window, device rotation) and height changes (lazy-loaded images, dynamically-inserted elements)
411
+ if (width !== previousWidth || height !== previousHeight) {
412
+ if (options.drawingMode === 'svg') {
413
+ this.drawHighlights();
414
+ } else {
415
+ this._updateHighlightRects();
416
+ }
417
+ if (this._previousSelectionRange) this._updateSelectionState();
418
+ previousWidth = width;
419
+ previousHeight = height;
420
+ }
421
+ }
422
+ }, Math.floor(Object.keys(this._highlightsById).length / 20)));
423
+ this._resizeObserver.observe(this._container);
424
+
425
+ // Set drawing mode to mark elements temporarily when printing (highlights get offset in SVG drawing mode)
426
+ let previousDrawingMode = null;
427
+ globalThis.addEventListener('beforeprint', () => {
428
+ previousDrawingMode = this._options.drawingMode;
429
+ this.setOptions({ drawingMode: 'mark-elements' });
430
+ this.drawHighlights();
431
+ }, { signal: this._controller.signal });
432
+ globalThis.addEventListener('afterprint', () => {
433
+ this.setOptions({ drawingMode: previousDrawingMode });
434
+ this.drawHighlights();
435
+ }, { signal: this._controller.signal });
307
436
 
308
- // Long press in annotatable container (triggered by setTimeout() in pointerdown event)
309
- const respondToLongPress = (event) => {
310
- respondToPointerUp(event, true);
311
- this._tapResult = null;
312
437
  }
313
438
 
314
- // Pointer up in annotatable container
315
- const respondToPointerUp = (event, isLongPress = false) => {
316
- if (this._tapResult) {
317
- this._tapResult.isLongPress = isLongPress;
318
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:tap', { detail: this._tapResult, }));
319
- if (options.autoTapToActivate && this._tapResult?.targetFound && !isLongPress) {
320
- if (this._tapResult.targetCount > 1) {
321
- return this._annotatableContainer.dispatchEvent(new CustomEvent('hh:ambiguousaction', { detail: this._tapResult, }));
322
- } else if (this._tapResult.highlights.length === 1) {
323
- return this.activateHighlight(this._tapResult.highlights[0].highlightId);
324
- } else if (this._tapResult.hyperlinks.length === 1) {
325
- return this.activateHyperlink(this._tapResult.hyperlinks[0].position);
439
+
440
+ /********************** Public Methods **********************/
441
+
442
+ // Load highlights
443
+ Highlighter.prototype.loadHighlights = function (highlights) {
444
+ // Don't load highlights until the document is ready (otherwise, highlights may be offset)
445
+ if (document.readyState !== 'complete') return setTimeout(this.loadHighlights.bind(this), 10, highlights);
446
+ const startTimestamp = Date.now();
447
+
448
+ const highlightIdsToDraw = new Set();
449
+ const knownHighlightIds = new Set(Object.keys(this._highlightsById));
450
+ let addedCount = 0, updatedCount = 0;
451
+ for (const highlight of highlights) {
452
+ if (knownHighlightIds.has(highlight.highlightId)) {
453
+ knownHighlightIds.delete(highlight.highlightId);
454
+ const changedHighlightInfo = this._diffHighlight(highlight, this._highlightsById[highlight.highlightId]);
455
+ if (changedHighlightInfo) {
456
+ this.createOrUpdateHighlight(changedHighlightInfo, false, false);
457
+ highlightIdsToDraw.add(highlight.highlightId);
458
+ updatedCount++;
326
459
  }
460
+ } else {
461
+ this.createOrUpdateHighlight(highlight, false, false);
462
+ highlightIdsToDraw.add(highlight.highlightId);
463
+ addedCount++;
327
464
  }
328
465
  }
466
+ if (knownHighlightIds.size > 0) this.removeHighlights([...knownHighlightIds]);
467
+ this.drawHighlights(highlightIdsToDraw);
468
+
469
+ const container = this._container;
470
+ const eventDetail = {
471
+ addedCount: addedCount, removedCount: knownHighlightIds.size, updatedCount: updatedCount,
472
+ totalCount: Object.keys(this._highlightsById).length,
473
+ };
474
+
475
+ // Fire event after browser paint (for accurate loadTimeMs)
476
+ requestAnimationFrame(() => {
477
+ setTimeout(() => {
478
+ container.dispatchEvent(new CustomEvent('hh:highlightsload', { detail: {
479
+ ...eventDetail, loadTimeMs: Date.now() - startTimestamp,
480
+ } }));
481
+ }, 0);
482
+ });
329
483
  }
330
- this._annotatableContainer.addEventListener('pointerup', (event) => respondToPointerUp(event), { signal: this._controller.signal });
331
484
 
332
- // Pointer up or cancel in window
333
- const respondToWindowPointerUp = (event) => {
334
- const selection = globalThis.getSelection();
335
- if (selection.type === 'Range' && this._activeHighlightId && this._annotatableContainer.contains(selection.anchorNode)) {
336
- const adjustedSelectionRange = this._snapRangeToBoundaries(selection.getRangeAt(0), selection.anchorNode);
485
+ // Create a new highlight, or update an existing highlight when it changes
486
+ Highlighter.prototype.createOrUpdateHighlight = function (properties, draw = true, activate = true) {
487
+ const options = this._options;
488
+ const highlightId = properties.highlightId ?? this._activeHighlightId ?? _getNewHighlightId();
489
+ const appearanceChanges = [];
490
+ const boundsChanges = [];
337
491
 
338
- // Update the selection range if needed
339
- // 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.
340
- const selectionRange = selection.getRangeAt(0);
341
- if (!(
342
- adjustedSelectionRange.startContainer === selectionRange.startContainer &&
343
- adjustedSelectionRange.startOffset === selectionRange.startOffset &&
344
- adjustedSelectionRange.endContainer === selectionRange.endContainer &&
345
- adjustedSelectionRange.endOffset === selectionRange.endOffset)
346
- ) {
347
- selection.setBaseAndExtent(adjustedSelectionRange.startContainer, adjustedSelectionRange.startOffset, adjustedSelectionRange.endContainer, adjustedSelectionRange.endOffset);
348
- }
349
- }
350
- this._tapResult = null;
351
- this._longPressTimeoutId = clearTimeout(this._longPressTimeoutId);
352
- this._annotatableContainer.dataset.hhPointerDown = 'false';
353
- if (this._activeSelectionHandle) {
354
- this._activeSelectionHandle = null;
355
- this._updateSelectionUi('bounds');
356
- this._annotatableContainer.removeEventListener('pointermove', respondToSelectionHandleDrag);
492
+ const oldHighlightInfo = this._highlightsById[highlightId];
493
+ const isNewHighlight = !oldHighlightInfo;
494
+
495
+ // If the highlight is currently active, ignore programmatic bounds changes (selection range takes priority)
496
+ if (highlightId === this._activeHighlightId) {
497
+ properties.startParagraphId = null;
498
+ properties.startParagraphOffset = null;
499
+ properties.endParagraphId = null;
500
+ properties.endParagraphOffset = null;
501
+ }
502
+
503
+ // Warn if color, style, or wrapper properties are invalid
504
+ if (properties.color && !Object.hasOwn(options.colorDefs, properties.color)) {
505
+ console.warn(`Highlight color "${properties.color}" is not defined in options (highlightId: ${highlightId}).`);
506
+ }
507
+ if (properties.style && !Object.hasOwn(options.styleDefs, properties.style)) {
508
+ console.warn(`Highlight style "${properties.style}" is not defined in options (highlightId: ${highlightId}).`);
509
+ }
510
+ if (properties.wrapper && !Object.hasOwn(options.wrapperDefs, properties.wrapper)) {
511
+ console.warn(`Highlight wrapper "${properties.wrapper}" is not defined in options (highlightId: ${highlightId}).`);
357
512
  }
358
- }
359
- globalThis.addEventListener('pointerup', (event) => respondToWindowPointerUp(event), { signal: this._controller.signal });
360
- globalThis.addEventListener('pointercancel', (event) => respondToWindowPointerUp(event), { signal: this._controller.signal });
361
513
 
362
- // Hyperlink click (for each hyperlink in annotatable container)
363
- for (const hyperlinkElement of this._hyperlinkElements) {
364
- hyperlinkElement.addEventListener('click', (event) => {
514
+ // Check which appearance properties changed
515
+ for (const key of ['color', 'style', 'wrapper', 'variables', 'readOnly']) {
516
+ if (isNewHighlight || (properties[key] != null && properties[key] !== oldHighlightInfo[key])) appearanceChanges.push(key);
517
+ }
518
+
519
+ // If the highlight was and still is read-only, return
520
+ if (oldHighlightInfo?.readOnly && (properties.readOnly == null || properties.readOnly === true)) {
365
521
  this.deactivateHighlights();
366
- if (!this._allowHyperlinkClick) event.preventDefault();
367
- }, { signal: this._controller.signal });
368
- }
522
+ return;
523
+ }
369
524
 
370
- // Annotatable container resize (debounced)
371
- let computedStyle = globalThis.getComputedStyle(this._annotatableContainer);
372
- let previousWidth = Math.round(this._annotatableContainer.clientWidth - Number.parseInt(computedStyle.getPropertyValue('padding-left')) - Number.parseInt(computedStyle.getPropertyValue('padding-right')));
373
- this._resizeObserver = new ResizeObserver(debounce((entries) => {
374
- for (const entry of entries) {
375
- const width = Math.round(entry.contentBoxSize[0].inlineSize);
376
- // Only respond if the annotatable content width changed
377
- if (width !== previousWidth) {
378
- if (options.drawingMode === 'svg') this.drawHighlights();
379
- if (this._previousSelectionRange) this._updateSelectionUi('bounds');
380
- previousWidth = width;
525
+ // Calculate the bounds of the highlight range, if it's changed
526
+ let adjustedSelectionRange, highlightRange;
527
+ let rangeText, rangeHtml, rangeParagraphIds;
528
+ let startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset;
529
+ const selection = this._getRestoredSelectionOrCaret(globalThis.getSelection());
530
+ if (selection.type === 'Range') adjustedSelectionRange = this._snapRangeToBoundaries(selection.getRangeAt(0));
531
+ if ((properties.startParagraphId ?? properties.startParagraphOffset ?? properties.endParagraphId ?? properties.endParagraphOffset != null) || (adjustedSelectionRange && !adjustedSelectionRange.collapsed)) {
532
+ let startNode, startOffset, endNode, endOffset;
533
+ if (properties.startParagraphId ?? properties.startParagraphOffset ?? properties.endParagraphId ?? properties.endParagraphOffset != null) {
534
+ startParagraphId = properties.startParagraphId ?? oldHighlightInfo?.startParagraphId;
535
+ startParagraphOffset = Number.parseInt(properties.startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset);
536
+ endParagraphId = properties.endParagraphId ?? oldHighlightInfo?.endParagraphId;
537
+ endParagraphOffset = Number.parseInt(properties.endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset);
538
+ if (!this._paragraphIds.includes(startParagraphId) || !this._paragraphIds.includes(endParagraphId)) return;
539
+ ([ startNode, startOffset ] = this._getTextNodeAndOffset(document.getElementById(startParagraphId), startParagraphOffset, 'start'));
540
+ ([ endNode, endOffset ] = this._getTextNodeAndOffset(document.getElementById(endParagraphId), endParagraphOffset, 'end'));
541
+ } else if (adjustedSelectionRange) {
542
+ startNode = adjustedSelectionRange.startContainer;
543
+ startOffset = adjustedSelectionRange.startOffset;
544
+ if (this._shouldSkipTextNode(startNode)) {
545
+ startNode = this._getNextValidTextNode(startNode);
546
+ startOffset = 0;
547
+ }
548
+ endNode = adjustedSelectionRange.endContainer;
549
+ endOffset = adjustedSelectionRange.endOffset;
550
+ if (this._shouldSkipTextNode(endNode)) {
551
+ endNode = this._getPreviousValidTextNode(endNode);
552
+ endOffset = endNode?.length;
553
+ }
554
+ if (!startNode || !endNode) return;
555
+ ([ startParagraphId, startParagraphOffset ] = this._getParagraphOffset(startNode, startOffset));
556
+ ([ endParagraphId, endParagraphOffset ] = this._getParagraphOffset(endNode, endOffset));
381
557
  }
382
- }
383
- }, Math.floor(Object.keys(this._highlightsById).length / 20)));
384
- this._resizeObserver.observe(this._annotatableContainer);
385
- }
386
558
 
559
+ // Create a new highlight range
560
+ highlightRange = document.createRange();
561
+ highlightRange.setStart(startNode, startOffset);
562
+ highlightRange.setEnd(endNode, endOffset);
387
563
 
388
- /********************** Public Methods **********************/
564
+ // Check which bounds properties changed
565
+ const boundsValues = { startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset };
566
+ for (const key of Object.keys(boundsValues)) {
567
+ if (isNewHighlight || boundsValues[key] !== oldHighlightInfo[key]) boundsChanges.push(key);
568
+ }
389
569
 
390
- // Load highlights
391
- Highlighter.prototype.loadHighlights = function (highlights) {
392
- // Don't load highlights until the document is ready (otherwise, highlights may be offset)
393
- if (document.readyState !== 'complete') return setTimeout(this.loadHighlights.bind(this), 10, highlights);
394
- const startTimestamp = Date.now();
570
+ // Set variables that depend on the range
571
+ ({ rangeHtml, rangeText } = this._getRangeContent(highlightRange, true, true));
572
+ let startParagraphIndex = this._paragraphIds.indexOf(startParagraphId);
573
+ let endParagraphIndex = this._paragraphIds.indexOf(endParagraphId);
574
+ if (startParagraphIndex === -1) startParagraphIndex = 0;
575
+ if (endParagraphIndex === -1) endParagraphIndex = this._paragraphIds.length - 1;
576
+ rangeParagraphIds = this._paragraphIds.slice(startParagraphIndex, endParagraphIndex + 1);
577
+ }
395
578
 
396
- // Hide container (repeated DOM manipulations are faster if the container is hidden)
397
- if (highlights.length > 1) {
398
- let containerToHide = this._options.drawingMode === 'svg' ? this._svgBackground : this._annotatableContainer;
399
- containerToHide.style.display = 'none';
400
- }
579
+ // If there are no valid changes, return
580
+ // TODO: Checking for an empty highlight range ensures that regular highlights can't collapse and become invisible. However, there may be use cases where showing just a caret is desired (example: document draft demo).
581
+ if (!highlightRange || highlightRange.toString() === '' || appearanceChanges.length + boundsChanges.length === 0) {
582
+ return;
583
+ }
401
584
 
402
- // Load read-only highlights first (read-only highlights change the DOM, affecting other highlights' ranges)
403
- const sortedHighlights = highlights.sort((a,b) => a.readOnly === b.readOnly ? 0 : a.readOnly ? -1 : 1);
585
+ // Update saved highlight info
586
+ const newHighlightInfo = {
587
+ highlightId: highlightId,
588
+ color: properties.color ?? oldHighlightInfo?.color ?? _defaultColor,
589
+ style: properties.style ?? oldHighlightInfo?.style ?? _defaultStyle,
590
+ wrapper: properties.wrapper ?? oldHighlightInfo?.wrapper ?? null,
591
+ variables: properties.variables ?? oldHighlightInfo?.variables ?? {},
592
+ readOnly: properties.readOnly ?? oldHighlightInfo?.readOnly ?? false,
593
+ startParagraphId: startParagraphId ?? oldHighlightInfo?.startParagraphId,
594
+ startParagraphOffset: startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset,
595
+ endParagraphId: endParagraphId ?? oldHighlightInfo?.endParagraphId,
596
+ endParagraphOffset: endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset,
597
+ // Generated properties
598
+ rangeText: rangeText ?? oldHighlightInfo?.rangeText,
599
+ rangeHtml: rangeHtml ?? oldHighlightInfo?.rangeHtml,
600
+ rangeRect: oldHighlightInfo?.rangeRect ?? null,
601
+ rangeLineRects: oldHighlightInfo?.rangeLineRects ?? null,
602
+ rangeParagraphIds: rangeParagraphIds ?? oldHighlightInfo?.rangeParagraphIds,
603
+ rangeObj: highlightRange ?? oldHighlightInfo?.rangeObj,
604
+ resolvedDrawingMode: null,
605
+ escapedHighlightId: CSS.escape(highlightId),
606
+ };
607
+ this._highlightsById[highlightId] = newHighlightInfo;
608
+ newHighlightInfo.resolvedDrawingMode = this._getResolvedDrawingMode(newHighlightInfo);
404
609
 
405
- const knownHighlightIds = Object.keys(this._highlightsById);
406
- let addedCount = 0, updatedCount = 0;
407
- for (const highlight of sortedHighlights) {
408
- const highlightInfo = this._diffHighlight(highlight, this._highlightsById[highlight.highlightId]);
409
- highlightInfo.highlightId = highlight.highlightId;
410
- const knownHighlightIndex = knownHighlightIds.indexOf(highlightInfo.highlightId);
411
- if (knownHighlightIndex > -1) {
412
- knownHighlightIds.splice(knownHighlightIndex, 1);
413
- if (Object.keys(highlightInfo).length > 1) {
414
- this.createOrUpdateHighlight(highlightInfo, false); updatedCount++;
610
+ // Draw highlight if needed
611
+ if (draw) {
612
+ if (highlightId !== this._activeHighlightId || appearanceChanges.some(k => k === 'wrapper' || k === 'variables')) {
613
+ this.drawHighlights([highlightId]);
614
+ } else {
615
+ // If the highlight is active, just update the selection style (the highlight will be redrawn on deactivate)
616
+ this._updateSelectionState();
415
617
  }
618
+ }
619
+
620
+ // Activate highlight if needed
621
+ if (activate && highlightId !== this._activeHighlightId) {
622
+ this.activateHighlight(highlightId);
623
+ }
624
+
625
+ const detail = {
626
+ highlight: newHighlightInfo,
627
+ changes: appearanceChanges.concat(boundsChanges),
628
+ }
629
+ if (isNewHighlight) {
630
+ this._container.dispatchEvent(new CustomEvent('hh:highlightcreate', { detail: detail }));
416
631
  } else {
417
- this.createOrUpdateHighlight(highlightInfo, false); addedCount++;
632
+ this._container.dispatchEvent(new CustomEvent('hh:highlightupdate', { detail: detail }));
418
633
  }
419
- }
420
- if (knownHighlightIds.length > 0) this.removeHighlights(knownHighlightIds);
421
-
422
- (this._options.drawingMode === 'svg' ? this._svgBackground : this._annotatableContainer).style.display = '';
423
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightsload', { detail: {
424
- addedCount: addedCount, removedCount: knownHighlightIds.length, updatedCount: updatedCount,
425
- totalCount: Object.keys(this._highlightsById).length,
426
- timeToLoad: Date.now() - startTimestamp,
427
- } }));
428
- }
429
-
430
- // Draw (or redraw) specified highlights, or all highlights on the page
431
- Highlighter.prototype.drawHighlights = function (highlightIds = Object.keys(this._highlightsById)) {
432
- const options = this._options;
433
-
434
- // Hide container (repeated DOM manipulations is faster if the container is hidden)
435
- if (highlightIds.length > 1) {
436
- let containerToHide = options.drawingMode === 'svg' ? this._svgBackground : this._annotatableContainer;
437
- containerToHide.style.display = 'none';
438
- }
439
634
 
440
- for (const highlightId of highlightIds) {
441
- const highlightInfo = this._highlightsById[highlightId];
442
- let range = this._getCorrectedRangeObj(highlightId);
443
- const rangeParagraphs = this._annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
444
- const wasDrawnAsReadOnly = this._annotatableContainer.querySelector(`[data-highlight-id="${highlightId}"][data-read-only]`);
445
-
446
- // Remove old highlight elements and styles
447
- if (!wasDrawnAsReadOnly || !highlightInfo.readOnly) this._undrawHighlight(highlightInfo);
448
- // Don't redraw a read-only highlight that's already drawn
449
- if (wasDrawnAsReadOnly) continue;
450
-
451
- // Insert wrappers before drawing, so any layout shift from wrapper content is reflected in drawing positions
452
- if (highlightInfo.wrapper && (options.wrappers[highlightInfo.wrapper]?.start || options.wrappers[highlightInfo.wrapper]?.end)) {
453
- const addWrapper = (edge, range, htmlString) => {
454
- htmlString = `<span class="hh-wrapper-${edge}" data-highlight-id="${highlightId}" data-color="${highlightInfo.color}" data-style="${highlightInfo.style}" data-hh-ignore="">${htmlString}</span>`
455
- for (const key of Object.keys(highlightInfo.wrapperVariables)) {
456
- htmlString = htmlString.replaceAll(`{${key}}`, highlightInfo.wrapperVariables[key]);
635
+ return newHighlightInfo;
636
+ }
637
+
638
+ // Draw (or redraw) specified highlights, or all highlights on the page
639
+ Highlighter.prototype.drawHighlights = function (highlightIds = Object.keys(this._highlightsById)) {
640
+ if ((highlightIds.size ?? highlightIds.length) === 0) return;
641
+ const options = this._options;
642
+ this._containerRect = this._container.getBoundingClientRect();
643
+ this._additionsRect = this._additionsDiv.getBoundingClientRect();
644
+ const wrapperElements = this._wrapperElements;
645
+
646
+ // Collect highlights to draw (mark-elements first, since they cause DOM mutation)
647
+ let markHighlights = [], otherHighlights = [];
648
+ if (options.drawingMode === 'svg') {
649
+ // In SVG drawing mode, if any queued highlight has a changed wrapper, also queue SVG highlights from that point forward (prevents misaligned highlights if wrapper change causes text reflow)
650
+ let previousWrapperChanged = false;
651
+ const highlightIdSet = new Set(highlightIds);
652
+ const allHighlights = this.getHighlightInfo();
653
+ for (const highlightInfo of allHighlights) {
654
+ highlightInfo.resolvedDrawingMode = this._getResolvedDrawingMode(highlightInfo);
655
+ if (previousWrapperChanged && highlightInfo.resolvedDrawingMode === 'svg') {
656
+ otherHighlights.push(highlightInfo);
657
+ } else if (highlightIdSet.has(highlightInfo.highlightId)) {
658
+ if (!previousWrapperChanged) {
659
+ const wrapper = wrapperElements.get(`${highlightInfo.highlightId}:start`) ?? wrapperElements.get(`${highlightInfo.highlightId}:end`) ?? null;
660
+ if (this._getWrapperHash(highlightInfo) !== (wrapper?.dataset?.hhWrapperHash ?? '')) {
661
+ previousWrapperChanged = true;
662
+ }
663
+ }
664
+ (highlightInfo.resolvedDrawingMode === 'mark-elements' ? markHighlights : otherHighlights).push(highlightInfo);
457
665
  }
458
- const template = document.createElement('template');
459
- template.innerHTML = htmlString;
460
- let htmlElement = template.content.firstChild;
461
- range.insertNode(htmlElement);
462
- }
463
- const startRange = highlightInfo.rangeObj;
464
- const endRange = document.createRange(); endRange.setStart(highlightInfo.rangeObj.endContainer, highlightInfo.rangeObj.endOffset);
465
- const wrapperInfo = options.wrappers[highlightInfo.wrapper];
466
- addWrapper('start', startRange, wrapperInfo.start);
467
- addWrapper('end', endRange, wrapperInfo.end);
468
- rangeParagraphs.forEach(p => { p.normalize(); });
469
- range = this._getCorrectedRangeObj(highlightId);
470
- if (!highlightInfo.readOnly && options.drawingMode === 'svg') {
471
- const startWrapperEl = this._annotatableContainer.querySelector(`.hh-wrapper-start[data-highlight-id="${highlightId}"]`);
472
- const endWrapperEl = this._annotatableContainer.querySelector(`.hh-wrapper-end[data-highlight-id="${highlightId}"]`);
473
- range = document.createRange();
474
- range.setStartAfter(startWrapperEl);
475
- range.setEndBefore(endWrapperEl);
476
- }
477
- }
478
-
479
- // Draw highlights with mark elements
480
- if (highlightInfo.readOnly || options.drawingMode === 'mark-elements') {
481
- // Inject HTML <mark> elements
482
- if (range.startOffset < range.startContainer.length) range.startContainer.splitText(range.startOffset);
483
- if (range.endOffset > 0 && range.endOffset < range.endContainer.length) range.endContainer.splitText(range.endOffset);
484
- const walker = this._getTextNodeWalker(range.commonAncestorContainer);
485
- walker.currentNode = range.startContainer;
486
- const relevantTextNodes = [];
487
- let node;
488
- while (node = walker.nextNode()) {
489
- if (!node.parentElement.closest('rt, rp')) relevantTextNodes.push(node);
490
- if (node === range.endContainer) break;
491
- }
492
- const overlappingHighlightIds = new Set();
493
- const createStyledMark = () => {
494
- const mark = document.createElement('mark');
495
- mark.dataset.highlightId = highlightId;
496
- if (highlightInfo.readOnly) mark.dataset.readOnly = '';
497
- mark.dataset.color = highlightInfo.color;
498
- mark.dataset.style = highlightInfo.style;
499
- return mark;
500
- };
501
- for (let tn = 0; tn < relevantTextNodes.length; tn++) {
502
- const textNode = relevantTextNodes[tn];
503
- if (textNode.parentElement.dataset.highlightId) overlappingHighlightIds.add(textNode.parentElement.dataset.highlightId);
504
- const styledMark = createStyledMark();
505
- if (tn === 0) styledMark.dataset.start = '';
506
- if (tn === relevantTextNodes.length - 1) styledMark.dataset.end = '';
507
- textNode.before(styledMark);
508
- styledMark.appendChild(textNode);
509
- // Absorb adjacent elements without text into the mark element. This prevents unstyled gaps around CSS-rendered pseudoelement content (such as superscript footnote markers).
510
- let prev = styledMark.previousSibling;
511
- while (prev && prev.nodeType === Node.ELEMENT_NODE && prev.textContent === '' && !prev.hasAttribute('data-hh-ignore')) {
512
- const toAbsorb = prev; prev = prev.previousSibling;
513
- styledMark.prepend(toAbsorb);
666
+ }
667
+ } else {
668
+ for (const highlightInfo of this.getHighlightInfo(highlightIds)) {
669
+ highlightInfo.resolvedDrawingMode = this._getResolvedDrawingMode(highlightInfo);
670
+ (highlightInfo.resolvedDrawingMode === 'mark-elements' ? markHighlights : otherHighlights).push(highlightInfo);
671
+ }
672
+ }
673
+ const sortedHighlights = markHighlights.concat(otherHighlights);
674
+
675
+ // Prepare wrappers
676
+ for (const highlightInfo of sortedHighlights) {
677
+ if (!highlightInfo.wrapper) continue;
678
+ const highlightId = highlightInfo.highlightId;
679
+ const wrapperDef = options.wrapperDefs[highlightInfo.wrapper];
680
+ for (const position of ['start', 'end']) {
681
+ if (!wrapperDef?.[position]) continue;
682
+ // Reuse previous wrapper element if it exists (to preserve its state if it was interacted with)
683
+ let wrapper = wrapperElements.get(`${highlightId}:${position}`) ?? null;
684
+ if (wrapper) {
685
+ wrapper.remove();
686
+ } else {
687
+ wrapper = document.createElement('span');
688
+ wrapper.dataset.hhPosition = position;
689
+ wrapper.dataset.hhHighlightId = highlightId;
690
+ wrapper.dataset.hhIgnore = '';
514
691
  }
515
- if (tn < relevantTextNodes.length - 1) {
516
- let next = styledMark.nextSibling;
517
- while (next && next.nodeType === Node.ELEMENT_NODE && next.textContent === '' && !next.hasAttribute('data-hh-ignore')) {
518
- const toAbsorb = next; next = next.nextSibling;
519
- styledMark.appendChild(toAbsorb);
692
+ wrapper.dataset.hhColor = highlightInfo.color;
693
+ wrapper.dataset.hhStyle = highlightInfo.style;
694
+ wrapper.dataset.hhWrapper = highlightInfo.wrapper;
695
+ wrapperElements.set(`${highlightId}:${position}`, wrapper);
696
+ }
697
+ }
698
+
699
+ // Undraw highlights that were previously drawn
700
+ this._undrawHighlights(sortedHighlights.map(h => h.highlightId));
701
+
702
+ // Cache paragraph and column geometry
703
+ const paragraphLineRectsCache = new Map();
704
+ const columnGeometryCache = new Map();
705
+
706
+ // Insert wrappers and update rects
707
+ for (const highlightInfo of sortedHighlights) {
708
+ const highlightId = highlightInfo.highlightId;
709
+ let range = this._getCorrectedRangeObj(highlightId);
710
+
711
+ // Insert wrappers
712
+ const wrapperDef = options.wrapperDefs[highlightInfo.wrapper];
713
+ if (wrapperDef) {
714
+ // Insert end wrapper first (avoids invalidating start node if start and end are in the same text node)
715
+ if (wrapperDef.end) {
716
+ range.endContainer.splitText(range.endOffset);
717
+ range.endContainer.after(wrapperElements.get(`${highlightId}:end`));
718
+ }
719
+ if (wrapperDef.start) range.insertNode(wrapperElements.get(`${highlightId}:start`));
720
+ range = this._getCorrectedRangeObj(highlightId, true);
721
+
722
+ // If current highlight is active, update selection range to match highlight range
723
+ if (highlightId === this._activeHighlightId) {
724
+ const selectionForWrapper = globalThis.getSelection();
725
+ const selectionRangeForWrapper = selectionForWrapper.type !== 'None' ? selectionForWrapper.getRangeAt(0) : null;
726
+ const rangesMatchForWrapper = _rangesEqual(selectionRangeForWrapper, range);
727
+ if (selectionRangeForWrapper && !rangesMatchForWrapper) {
728
+ selectionForWrapper.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset);
520
729
  }
521
730
  }
522
731
  }
523
732
 
524
- // Update highlight ranges that were invalidated by the DOM change
525
- this._getCorrectedRangeObj(highlightId);
526
- for (const overlappingHighlightId of overlappingHighlightIds) {
527
- this._getCorrectedRangeObj(overlappingHighlightId);
733
+ // Update range rects (after adding wrappers)
734
+ const rangeParagraphs = highlightInfo.rangeParagraphIds.map(id => document.getElementById(id));
735
+ const [rangeRect, rangeLineRects] = this._getRangeRects(range, rangeParagraphs, paragraphLineRectsCache, columnGeometryCache);
736
+ highlightInfo.rangeRect = rangeRect;
737
+ highlightInfo.rangeLineRects = rangeLineRects;
738
+
739
+ // Update wrapper variables
740
+ let wrapperRectChanged = false;
741
+ if (wrapperDef) {
742
+ for (const position of ['start', 'end']) {
743
+ const template = wrapperDef[position];
744
+ const wrapper = wrapperElements.get(`${highlightId}:${position}`);
745
+ if (!wrapper) continue;
746
+ const beforeRect = wrapper.getBoundingClientRect();
747
+ const wrapperTemplateHash = this._getWrapperTemplateHash(highlightInfo);
748
+ const wrapperHash = this._getWrapperHash(highlightInfo, wrapperTemplateHash);
749
+ if (wrapperHash !== wrapper.dataset.hhWrapperHash) {
750
+ const lineRect = position === 'start' ? highlightInfo.rangeLineRects[0] : highlightInfo.rangeLineRects.at(-1);
751
+ const innerHtml = this._applySubstitutions(template, highlightInfo.variables, highlightInfo.rangeRect, lineRect);
752
+ if (wrapperTemplateHash === wrapper.dataset.hhWrapperTemplateHash) {
753
+ this._updateWrapperInPlace(wrapper, innerHtml);
754
+ } else {
755
+ wrapper.innerHTML = innerHtml;
756
+ wrapper.dataset.hhWrapperTemplateHash = wrapperTemplateHash;
757
+ }
758
+ wrapper.dataset.hhWrapperHash = wrapperHash;
759
+ }
760
+ const afterRect = wrapper.getBoundingClientRect();
761
+ if (!_rectsEqual(beforeRect, afterRect)) wrapperRectChanged = true;
762
+ }
528
763
  }
529
764
 
530
- // Draw highlights with Custom Highlight API
531
- } else if (options.drawingMode === 'highlight-api' && supportsHighlightApi) {
532
- let highlightObj;
533
- if (CSS.highlights.has(highlightId)) {
534
- highlightObj = CSS.highlights.get(highlightId);
535
- highlightObj.clear();
536
- } else {
537
- highlightObj = new Highlight();
538
- CSS.highlights.set(highlightId, highlightObj);
539
- }
540
- highlightObj.add(range);
541
- const styleTemplate = this._getStyleTemplate(highlightInfo.style, 'css', null);
542
- const colorString = options.colors[highlightInfo.color];
543
- this._highlightApiStylesheet.insertRule(`${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { --hh-color: ${colorString}; ${styleTemplate} }`);
544
- this._highlightApiStylesheet.insertRule(`${options.containerSelector} rt::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }`);
545
- this._highlightApiStylesheet.insertRule(`${options.containerSelector} img::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }`);
546
-
547
- // Draw highlights with SVG shapes
548
- } else if (options.drawingMode === 'svg') {
549
- const clientRects = this._getMergedClientRects(range, rangeParagraphs);
550
- let group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
551
- group.dataset.highlightId = highlightId;
552
- group.dataset.color = highlightInfo.color;
553
- group.dataset.style = highlightInfo.style;
554
- let svgContent = '';
555
- for (const clientRect of clientRects) {
556
- svgContent += this._getStyleTemplate(highlightInfo.style, 'svg', clientRect);
765
+ // Update rects one more time if the wrapper width changed
766
+ if (wrapperRectChanged) {
767
+ for (const p of rangeParagraphs) {
768
+ paragraphLineRectsCache.delete(p.id);
769
+ let el = p.parentElement;
770
+ while (el) {
771
+ columnGeometryCache.delete(el);
772
+ if (el.hasAttribute('data-hh-container')) break;
773
+ el = el.parentElement;
774
+ }
775
+ }
776
+ const [rangeRect, rangeLineRects] = this._getRangeRects(range, rangeParagraphs, paragraphLineRectsCache, columnGeometryCache);
777
+ highlightInfo.rangeRect = rangeRect;
778
+ highlightInfo.rangeLineRects = rangeLineRects;
557
779
  }
558
- group.innerHTML = svgContent;
559
- this._svgBackground.appendChild(group);
560
780
  }
561
- }
562
781
 
563
- // Show container
564
- (options.drawingMode === 'svg' ? this._svgBackground : this._annotatableContainer).style.display = '';
565
- }
782
+ // Draw highlights
783
+ for (const highlightInfo of sortedHighlights) {
784
+ const highlightId = highlightInfo.highlightId;
785
+ const range = this._getCorrectedRangeObj(highlightId);
786
+
787
+ // Draw highlights with mark elements
788
+ const useMarkElements = highlightInfo.resolvedDrawingMode === 'mark-elements';
789
+ if (useMarkElements) {
790
+ // Inject HTML <mark> elements
791
+ if (range.startOffset < range.startContainer.length) range.startContainer.splitText(range.startOffset);
792
+ if (range.endOffset > 0 && range.endOffset < range.endContainer.length) range.endContainer.splitText(range.endOffset);
793
+ const walker = this._getTextNodeWalker(range.commonAncestorContainer);
794
+ walker.currentNode = range.startContainer;
795
+ const relevantTextNodes = [];
796
+ let node;
797
+ while (node = walker.nextNode()) {
798
+ if (node === range.endContainer && range.endOffset === 0) break;
799
+ if (!node.parentElement.closest('rt, rp')) relevantTextNodes.push(node);
800
+ if (node === range.endContainer) break;
801
+ }
802
+ const overlappingHighlightIds = new Set();
803
+ const createStyledMark = () => {
804
+ const mark = document.createElement('mark');
805
+ mark.dataset.hhHighlightId = highlightId;
806
+ mark.dataset.hhColor = highlightInfo.color;
807
+ mark.dataset.hhStyle = highlightInfo.style;
808
+ return mark;
809
+ };
810
+ for (let tn = 0; tn < relevantTextNodes.length; tn++) {
811
+ const textNode = relevantTextNodes[tn];
812
+ if (textNode.parentElement.dataset.hhHighlightId) overlappingHighlightIds.add(textNode.parentElement.dataset.hhHighlightId);
813
+ const styledMark = createStyledMark();
814
+ if (tn === 0 && tn === relevantTextNodes.length - 1) styledMark.dataset.hhPosition = 'start end';
815
+ else if (tn === 0) styledMark.dataset.hhPosition = 'start';
816
+ else if (tn === relevantTextNodes.length - 1) styledMark.dataset.hhPosition = 'end';
817
+ textNode.before(styledMark);
818
+ styledMark.appendChild(textNode);
819
+ // Absorb adjacent elements without text into the mark element. This prevents unstyled gaps around CSS-rendered pseudoelement content (such as superscript footnote markers).
820
+ let prev = styledMark.previousSibling;
821
+ while (prev && prev.nodeType === Node.ELEMENT_NODE && prev.textContent === '' && !prev.hasAttribute('data-hh-ignore')) {
822
+ const toAbsorb = prev; prev = prev.previousSibling;
823
+ styledMark.prepend(toAbsorb);
824
+ }
825
+ if (tn < relevantTextNodes.length - 1) {
826
+ let next = styledMark.nextSibling;
827
+ while (next && next.nodeType === Node.ELEMENT_NODE && next.textContent === '' && !next.hasAttribute('data-hh-ignore')) {
828
+ const toAbsorb = next; next = next.nextSibling;
829
+ styledMark.appendChild(toAbsorb);
830
+ }
831
+ }
832
+ }
566
833
 
567
- // Create a new highlight, or update an existing highlight when it changes
568
- Highlighter.prototype.createOrUpdateHighlight = function (attributes = {}, triggeredByUserAction = true) {
569
- const options = this._options;
570
- let highlightId = attributes.highlightId ?? this._activeHighlightId ?? options.highlightIdFunction();
571
- const appearanceChanges = [];
572
- const boundsChanges = [];
834
+ // Update highlight ranges that were invalidated by the DOM change
835
+ this._getCorrectedRangeObj(highlightId);
836
+ for (const overlappingHighlightId of overlappingHighlightIds) {
837
+ this._getCorrectedRangeObj(overlappingHighlightId);
838
+ }
573
839
 
574
- const oldHighlightInfo = this._highlightsById[highlightId];
575
- const isNewHighlight = !oldHighlightInfo;
840
+ // Draw highlights with CSS Highlight API
841
+ } else if (options.drawingMode === 'highlight-api') {
842
+ let cssHighlight;
843
+ if (CSS.highlights.has(highlightId)) {
844
+ cssHighlight = CSS.highlights.get(highlightId);
845
+ cssHighlight.clear();
846
+ } else {
847
+ cssHighlight = new Highlight();
848
+ CSS.highlights.set(highlightId, cssHighlight);
849
+ }
850
+ cssHighlight.add(range);
851
+
852
+ // Draw highlights with SVG shapes
853
+ } else if (options.drawingMode === 'svg') {
854
+ let group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
855
+ group.dataset.hhHighlightId = highlightId;
856
+ group.dataset.hhColor = highlightInfo.color;
857
+ group.dataset.hhStyle = highlightInfo.style;
858
+ let svgContent = '';
859
+ for (const lineRect of highlightInfo.rangeLineRects) {
860
+ svgContent += this._getStyleString(highlightInfo.style, 'svg', false, highlightInfo.variables, highlightInfo.rangeRect, lineRect);
861
+ }
862
+ group.innerHTML = svgContent;
863
+ this._svgBackground.appendChild(group);
864
+ }
865
+ }
576
866
 
577
- // If a different highlight is active, deactivate it
578
- if (this._activeHighlightId && highlightId !== this._activeHighlightId && triggeredByUserAction) {
579
- this.deactivateHighlights();
867
+ this._rebuildHighlightStylesheet();
580
868
  }
581
869
 
582
- // If the highlight is currently activate, ignore bounds changes that weren't initiated by the user
583
- if (highlightId === this._activeHighlightId && !triggeredByUserAction) {
584
- attributes.startParagraphId = null;
585
- attributes.startParagraphOffset = null;
586
- attributes.endParagraphId = null;
587
- attributes.endParagraphOffset = null;
588
- }
870
+ // Activate a highlight by ID
871
+ Highlighter.prototype.activateHighlight = function (highlightId) {
872
+ const highlightToActivate = this._highlightsById[highlightId];
873
+ if (!highlightToActivate || highlightId === this._activeHighlightId) return;
874
+ if (highlightToActivate.readOnly) {
875
+ // If the highlight is read-only, return events, but don't actually activate it
876
+ this._container.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
877
+ this._container.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: { highlight: highlightToActivate } }));
878
+ return;
879
+ }
880
+ const selection = globalThis.getSelection();
881
+ const highlightRange = this._getCorrectedRangeObj(highlightId);
882
+ this._activeHighlightId = highlightId;
883
+
884
+ // Update the selection range if needed
885
+ // 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.
886
+ let selectionRange = selection.type === 'Range' ? selection.getRangeAt(0) : null;
887
+ const rangesMatch = _rangesEqual(selectionRange, highlightRange);
888
+ if (!rangesMatch) {
889
+ selection.setBaseAndExtent(highlightRange.startContainer, highlightRange.startOffset, highlightRange.endContainer, highlightRange.endOffset);
890
+ }
589
891
 
590
- // Warn if color, style, or wrapper attributes are invalid
591
- if (attributes.color && !Object.hasOwn(options.colors, attributes.color)) {
592
- console.warn(`Highlight color "${attributes.color}" is not defined in options (highlightId: ${highlightId}).`);
593
- }
594
- if (attributes.style && !Object.hasOwn(options.styles, attributes.style)) {
595
- console.warn(`Highlight style "${attributes.style}" is not defined in options (highlightId: ${highlightId}).`);
596
- }
597
- if (attributes.wrapper && !Object.hasOwn(options.wrappers, attributes.wrapper)) {
598
- console.warn(`Highlight wrapper "${attributes.wrapper}" is not defined in options (highlightId: ${highlightId}).`);
892
+ this._updateSelectionState();
893
+ this._container.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
599
894
  }
600
895
 
601
- // Update defaults
602
- if (options.rememberStyle && triggeredByUserAction) {
603
- if (attributes.color) options.defaultColor = attributes.color;
604
- if (attributes.style) options.defaultStyle = attributes.style;
605
- if (attributes.wrapper) options.defaultWrapper = attributes.wrapper;
896
+ // Activate a link by index
897
+ Highlighter.prototype.activateHyperlink = function (index) {
898
+ this.deactivateHighlights();
899
+ this._allowHyperlinkClick = true;
900
+ this._hyperlinksByIndex[index].hyperlinkElement.click();
901
+ this._allowHyperlinkClick = false;
902
+ }
903
+
904
+ // Deactivate any highlights that are currently active/selected
905
+ Highlighter.prototype.deactivateHighlights = function (removeSelectionRanges = true, redraw = true) {
906
+ const deactivatedHighlight = this._highlightsById[this._activeHighlightId];
907
+ this._activeHighlightId = null;
908
+ this._previousSelectionRange = null;
909
+ if (removeSelectionRanges && this._getSelectionContainer() === this._container) {
910
+ const selection = globalThis.getSelection();
911
+ selection.collapseToStart();
912
+ }
913
+ this._updateSelectionState();
914
+ if (deactivatedHighlight) {
915
+ if (redraw) this.drawHighlights([deactivatedHighlight.highlightId]);
916
+ this._container.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: {
917
+ highlight: deactivatedHighlight,
918
+ }}));
919
+ }
606
920
  }
607
921
 
608
- // Check which appearance properties changed
609
- for (const key of ['color', 'style', 'wrapper', 'wrapperVariables', 'readOnly']) {
610
- if (isNewHighlight || (attributes[key] != null && attributes[key] !== oldHighlightInfo[key])) appearanceChanges.push(key);
922
+ // Get the active highlight ID (if there is one)
923
+ Highlighter.prototype.getActiveHighlightId = function () {
924
+ return this._activeHighlightId;
611
925
  }
612
926
 
613
- // If the highlight was and still is read-only, return
614
- if (oldHighlightInfo?.readOnly && (attributes.readOnly == null || attributes.readOnly === true)) return this.deactivateHighlights();
615
-
616
- // Calculate the bounds of the highlight range, if it's changed
617
- let adjustedSelectionRange, highlightRange;
618
- let rangeText, rangeHtml, rangeParagraphIds;
619
- let startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset;
620
- const selection = this._getRestoredSelectionOrCaret(globalThis.getSelection());
621
- if (selection.type === 'Range') adjustedSelectionRange = this._snapRangeToBoundaries(selection.getRangeAt(0));
622
- if ((attributes.startParagraphId ?? attributes.startParagraphOffset ?? attributes.endParagraphId ?? attributes.endParagraphOffset != null) || (adjustedSelectionRange && !adjustedSelectionRange.collapsed)) {
623
- let startNode, startOffset, endNode, endOffset;
624
- if (attributes.startParagraphId ?? attributes.startParagraphOffset ?? attributes.endParagraphId ?? attributes.endParagraphOffset != null) {
625
- startParagraphId = attributes.startParagraphId ?? oldHighlightInfo?.startParagraphId;
626
- startParagraphOffset = Number.parseInt(attributes.startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset);
627
- endParagraphId = attributes.endParagraphId ?? oldHighlightInfo?.endParagraphId;
628
- endParagraphOffset = Number.parseInt(attributes.endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset);
629
- ([ startNode, startOffset ] = this._getTextNodeAndOffset(document.getElementById(startParagraphId), startParagraphOffset));
630
- ([ endNode, endOffset ] = this._getTextNodeAndOffset(document.getElementById(endParagraphId), endParagraphOffset));
631
- } else if (adjustedSelectionRange) {
632
- startNode = adjustedSelectionRange.startContainer;
633
- startOffset = adjustedSelectionRange.startOffset;
634
- endNode = adjustedSelectionRange.endContainer;
635
- endOffset = adjustedSelectionRange.endOffset;
636
- ([ startParagraphId, startParagraphOffset ] = this._getParagraphOffset(startNode, startOffset));
637
- ([ endParagraphId, endParagraphOffset ] = this._getParagraphOffset(endNode, endOffset));
638
- }
639
-
640
- // Create a new highlight range
641
- highlightRange = document.createRange();
642
- highlightRange.setStart(startNode, startOffset);
643
- highlightRange.setEnd(endNode, endOffset);
644
-
645
- // Check which bounds properties changed
646
- const boundsValues = { startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset };
647
- for (const key of Object.keys(boundsValues)) {
648
- if (isNewHighlight || boundsValues[key] !== oldHighlightInfo[key]) boundsChanges.push(key);
649
- }
650
-
651
- // Set variables that depend on the range
652
- const temporaryHtmlElement = document.createElement('div');
653
- temporaryHtmlElement.appendChild(highlightRange.cloneContents());
654
- for (const element of temporaryHtmlElement.querySelectorAll(`a, [data-highlight-id]:not([data-highlight-id="${highlightId}"])`)) element.outerHTML = element.innerHTML;
655
- for (const element of temporaryHtmlElement.querySelectorAll(`[data-hh-ignore]`)) element.remove();
656
- rangeText = temporaryHtmlElement.textContent;
657
- rangeHtml = temporaryHtmlElement.innerHTML;
658
- let startParagraphIndex = this._annotatableParagraphIds.indexOf(startParagraphId);
659
- let endParagraphIndex = this._annotatableParagraphIds.indexOf(endParagraphId);
660
- if (startParagraphIndex === -1) startParagraphIndex = 0;
661
- if (endParagraphIndex === -1) endParagraphIndex = this._annotatableParagraphIds.length - 1;
662
- rangeParagraphIds = this._annotatableParagraphIds.slice(startParagraphIndex, endParagraphIndex + 1);
663
- }
927
+ // Get info for specified highlights, or all highlights on the page
928
+ Highlighter.prototype.getHighlightInfo = function (highlightIds = Object.keys(this._highlightsById), paragraphId = null) {
929
+ const highlights = Array.from(new Set(highlightIds)).map(id => this._highlightsById[id])
930
+ .filter(info => info && (!paragraphId || paragraphId === info.startParagraphId));
664
931
 
665
- // If there are no valid changes, return
666
- if (!highlightRange || highlightRange.toString() === '' || appearanceChanges.length + boundsChanges.length === 0) return;
667
-
668
- // Update saved highlight info
669
- const newHighlightInfo = {
670
- highlightId: highlightId,
671
- color: attributes.color ?? oldHighlightInfo?.color ?? options.defaultColor,
672
- style: attributes.style ?? oldHighlightInfo?.style ?? options.defaultStyle,
673
- wrapper: attributes.wrapper ?? oldHighlightInfo?.wrapper ?? options.defaultWrapper,
674
- wrapperVariables: attributes.wrapperVariables ?? oldHighlightInfo?.wrapperVariables ?? {},
675
- readOnly: attributes.readOnly ?? oldHighlightInfo?.readOnly ?? false,
676
- startParagraphId: startParagraphId ?? oldHighlightInfo?.startParagraphId,
677
- startParagraphOffset: startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset,
678
- endParagraphId: endParagraphId ?? oldHighlightInfo?.endParagraphId,
679
- endParagraphOffset: endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset,
680
- // Read-only properties
681
- escapedHighlightId: CSS.escape(highlightId),
682
- rangeText: rangeText ?? oldHighlightInfo?.rangeText,
683
- rangeHtml: rangeHtml ?? oldHighlightInfo?.rangeHtml,
684
- rangeParagraphIds: rangeParagraphIds ?? oldHighlightInfo?.rangeParagraphIds,
685
- rangeObj: highlightRange ?? oldHighlightInfo?.rangeObj,
932
+ // Sort highlights based on their order on the page
933
+ this._paragraphIdsMap ??= new Map(this._paragraphIds.map((id, i) => [id, i]));
934
+ const paragraphIdsMap = this._paragraphIdsMap;
935
+ highlights.sort((a, b) =>
936
+ (paragraphIdsMap.get(a.startParagraphId) - paragraphIdsMap.get(b.startParagraphId)) ||
937
+ (a.startParagraphOffset - b.startParagraphOffset)
938
+ );
939
+ return highlights;
686
940
  };
687
- this._highlightsById[highlightId] = newHighlightInfo;
688
-
689
- const detail = {
690
- highlight: newHighlightInfo,
691
- changes: appearanceChanges.concat(boundsChanges),
692
- }
693
941
 
694
- const wrapperChanged = appearanceChanges.some(k => k.startsWith('wrapper'));
695
- if (triggeredByUserAction && !this._activeHighlightId) {
696
- this.activateHighlight(highlightId);
697
- } else if (this._activeHighlightId && highlightId === this._activeHighlightId && appearanceChanges.length > 0) {
698
- this._updateSelectionUi('appearance');
699
- }
700
- if (highlightId !== this._activeHighlightId || wrapperChanged) {
701
- this.drawHighlights([highlightId]);
942
+ // Get highlights, wrappers, and hyperlinks at a given point
943
+ Highlighter.prototype.getTargetsAtPoint = function (clientX, clientY) {
944
+ const highlightIds = new Set();
945
+ const wrappers = [];
946
+ const hyperlinks = [];
947
+ const wrapperElements = new Set();
948
+ const targetElements = document.elementsFromPoint(clientX, clientY);
949
+ for (const element of targetElements) {
950
+ if (element === this._container) break;
951
+ if (element.matches('mark[data-hh-highlight-id]')) {
952
+ highlightIds.add(element.dataset.hhHighlightId);
953
+ }
954
+ const wrapper = element.closest('[data-hh-wrapper]');
955
+ if (wrapper && !wrapperElements.has(wrapper)) {
956
+ wrappers.push({
957
+ position: wrapper.dataset.hhPosition,
958
+ wrapperElement: wrapper,
959
+ highlight: this._highlightsById[wrapper.dataset.hhHighlightId],
960
+ });
961
+ wrapperElements.add(wrapper);
962
+ } else if (element.matches('a') && element.dataset.hhIndex) {
963
+ hyperlinks.push(this._hyperlinksByIndex[element.dataset.hhIndex]);
964
+ }
965
+ }
966
+ this._additionsRect = this._additionsDiv.getBoundingClientRect();
967
+ const x = clientX - this._additionsRect.left;
968
+ const y = clientY - this._additionsRect.top;
969
+ for (const [highlightId, highlightInfo] of Object.entries(this._highlightsById)) {
970
+ if (highlightIds.has(highlightId)) continue;
971
+ const rangeLineRects = highlightInfo.rangeLineRects;
972
+ if (!rangeLineRects) continue;
973
+ for (const rect of rangeLineRects) {
974
+ const padding = rect.height / 8;
975
+ if (_isPointInRect(x, y, rect, padding)) { highlightIds.add(highlightId); break; }
976
+ }
977
+ }
978
+ return {
979
+ targetCount: highlightIds.size + wrappers.length + hyperlinks.length,
980
+ highlights: this.getHighlightInfo(highlightIds),
981
+ wrappers,
982
+ hyperlinks,
983
+ };
702
984
  }
703
985
 
704
- // Redraw highlights after the current highlight if needed
705
- if (wrapperChanged && options.drawingMode === 'svg') {
706
- const highlights = this.getHighlightInfo();
707
- const changedIndex = highlights.findIndex(h => h.highlightId === highlightId);
708
- this.drawHighlights(highlights.slice(changedIndex + 1).map(h => h.highlightId));
986
+ // Update initialized options
987
+ Highlighter.prototype.setOptions = function (optionsToUpdate) {
988
+ const options = this._options;
989
+ for (const key in optionsToUpdate) {
990
+ if (['colorDefs', 'styleDefs', 'wrapperDefs'].includes(key) && optionsToUpdate[key] != null) {
991
+ options[key] = { ..._defaultOptions[key], ...options[key], ...optionsToUpdate[key] };
992
+ } else if (!Object.hasOwn(_defaultOptions, key)) {
993
+ console.warn(`Options key "${key}" is not recognized.`);
994
+ } else {
995
+ options[key] = optionsToUpdate[key] ?? options[key] ?? _defaultOptions[key];
996
+ }
997
+ }
998
+ if ('drawingMode' in optionsToUpdate || 'colorDefs' in optionsToUpdate) {
999
+ this._updateColorStylesheet();
1000
+ }
1001
+ if ('drawingMode' in optionsToUpdate || 'styleDefs' in optionsToUpdate) {
1002
+ this.drawHighlights();
1003
+ }
1004
+ if ('dragHandles' in optionsToUpdate) {
1005
+ for (const handle of this._dragHandles) {
1006
+ handle.children[1].innerHTML = options.dragHandles[handle.dataset.hhSide] ?? '';
1007
+ }
1008
+ }
1009
+ if ('enableHover' in optionsToUpdate && !optionsToUpdate.enableHover) this._hoverHash = null;
709
1010
  }
710
1011
 
711
- if (isNewHighlight) {
712
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightcreate', { detail: detail }));
713
- } else {
714
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightupdate', { detail: detail }));
715
- }
716
- }
717
-
718
- // Activate a highlight by ID
719
- Highlighter.prototype.activateHighlight = function (highlightId) {
720
- const highlightToActivate = this._highlightsById[highlightId];
721
- if (highlightToActivate.readOnly) {
722
- // If the highlight is read-only, return events, but don't actually activate it
723
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
724
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: { highlight: highlightToActivate } }));
725
- return;
726
- }
727
- const selection = globalThis.getSelection();
728
- const highlightRange = highlightToActivate.rangeObj.cloneRange();
729
- this._activeHighlightId = highlightId;
730
- this._updateSelectionUi('appearance');
731
- this._updateSelectionUi('bounds');
732
-
733
- // Update the selection range if needed
734
- // 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.
735
- let selectionRange = selection.type === 'Range' ? selection.getRangeAt(0) : null;
736
- if (!selectionRange || !(
737
- selectionRange.startContainer === highlightRange.startContainer &&
738
- selectionRange.startOffset === highlightRange.startOffset &&
739
- selectionRange.endContainer === highlightRange.endContainer &&
740
- selectionRange.endOffset === highlightRange.endOffset)
741
- ) {
742
- selection.setBaseAndExtent(highlightRange.startContainer, highlightRange.startOffset, highlightRange.endContainer, highlightRange.endOffset);
1012
+ // Get all of the initialized options
1013
+ Highlighter.prototype.getOptions = function () {
1014
+ return { ...this._options };
743
1015
  }
744
1016
 
745
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
746
- }
747
-
748
- // Activate a link by position
749
- Highlighter.prototype.activateHyperlink = function (position) {
750
- this.deactivateHighlights();
751
- this._allowHyperlinkClick = true;
752
- this._hyperlinksByPosition[position].hyperlinkElement.click();
753
- this._allowHyperlinkClick = false;
754
- }
755
-
756
- // Deactivate any highlights that are currently active/selected
757
- Highlighter.prototype.deactivateHighlights = function (removeSelectionRanges = true) {
758
- const deactivatedHighlight = this._highlightsById[this._activeHighlightId];
759
- this._activeHighlightId = null;
760
- this._previousSelectionRange = null;
761
- const selection = globalThis.getSelection();
762
- if (removeSelectionRanges && selection.anchorNode && this._annotatableContainer.contains(selection.anchorNode)) {
763
- selection.collapseToStart();
764
- }
765
- this._updateSelectionUi('appearance');
766
- if (deactivatedHighlight) {
767
- this.drawHighlights([deactivatedHighlight.highlightId]);
768
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: {
769
- highlight: deactivatedHighlight,
770
- }}));
1017
+ // Get the current selection state. Returns the same object that arrives in the most recent hh:selectionchange event's detail.
1018
+ Highlighter.prototype.getSelectionState = function () {
1019
+ return { ...this._selectionState };
771
1020
  }
772
- }
773
1021
 
774
- // Remove the specified highlights, or all highlights on the page
775
- Highlighter.prototype.removeHighlights = function (highlightIds = Object.keys(this._highlightsById)) {
776
- this.deactivateHighlights();
777
- for (const highlightId of highlightIds) {
778
- const highlightInfo = this._highlightsById[highlightId];
779
- if (highlightInfo) {
1022
+ // Remove the specified highlights, or all highlights on the page
1023
+ Highlighter.prototype.removeHighlights = function (highlightIds = Object.keys(this._highlightsById)) {
1024
+ this.deactivateHighlights();
1025
+ this._undrawHighlights(highlightIds);
1026
+ for (const highlightId of highlightIds) {
780
1027
  delete this._highlightsById[highlightId];
781
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightremove', { detail: {
1028
+ this._container.dispatchEvent(new CustomEvent('hh:highlightremove', { detail: {
782
1029
  highlightId: highlightId,
783
1030
  }}));
784
- this._undrawHighlight(highlightInfo);
785
1031
  }
1032
+ this._rebuildHighlightStylesheet();
786
1033
  }
787
- }
788
1034
 
789
- // Get the active highlight ID (if there is one)
790
- Highlighter.prototype.getActiveHighlightId = function () {
791
- return this._activeHighlightId;
792
- }
1035
+ // Remove this Highlighter instance and its highlights
1036
+ Highlighter.prototype.removeHighlighter = function () {
1037
+ this.loadHighlights([]);
1038
+ this._additionsDiv.remove();
1039
+ _removeStylesheets(this._stylesheets);
1040
+ this._resizeObserver.disconnect();
1041
+ this._controller.abort();
793
1042
 
794
- // Get info for specified highlights, or all highlights on the page
795
- Highlighter.prototype.getHighlightInfo = function (highlightIds = Object.keys(this._highlightsById), paragraphId = null) {
796
- let filteredHighlights = []
797
- for (const highlightId of highlightIds) {
798
- const highlightInfo = this._highlightsById[highlightId];
799
- if (!paragraphId || paragraphId === highlightInfo.startParagraphId) {
800
- filteredHighlights.push(highlightInfo);
801
- }
802
- }
803
- // Sort highlights based on their order on the page
804
- if (filteredHighlights.length > 0) {
805
- filteredHighlights.sort((a, b) => {
806
- return (this._annotatableParagraphIds.indexOf(a.startParagraphId) - this._annotatableParagraphIds.indexOf(b.startParagraphId)) || (a.startParagraphOffset - b.startParagraphOffset);
807
- });
1043
+ delete this._container.dataset.hhContainer;
1044
+ this._container.highlighter = undefined;
808
1045
  }
809
- return filteredHighlights;
810
- }
811
-
812
- // Update initialized options
813
- Highlighter.prototype.setOptions = function (optionsToUpdate) {
814
- const options = this._options;
815
- for (const key in optionsToUpdate) {
816
- options[key] = optionsToUpdate[key] ?? options[key];
1046
+
1047
+
1048
+ /********************** Private Methods **********************/
1049
+
1050
+ // Check if the tap hits any highlights, wrappers, or links
1051
+ Highlighter.prototype._checkForTapTargets = function (pointerEvent) {
1052
+ if (!pointerEvent) return;
1053
+ const targets = this.getTargetsAtPoint(pointerEvent.clientX, pointerEvent.clientY);
1054
+ return {
1055
+ ...targets,
1056
+ isLongPress: null,
1057
+ selectionType: globalThis.getSelection().type,
1058
+ activeHighlightId: this._activeHighlightId,
1059
+ nativeEvent: pointerEvent,
1060
+ };
817
1061
  }
818
- if ('containerSelector' in optionsToUpdate) {
819
- this.removeHighlighter();
820
- this._initializeHighlighter();
821
- this._loadStyles();
822
- this._loadEventListeners();
823
- this._updateAppearanceStylesheet();
824
- this._updateSelectionUi('appearance');
825
- for (const selectionHandle of this._selectionHandles) {
826
- selectionHandle.children[1].innerHTML = options.selectionHandles[selectionHandle.dataset.side] ?? '';
827
- }
828
- } else {
829
- if ('paragraphSelector' in optionsToUpdate) {
830
- this._initializeHighlighter();
831
- }
832
- if ('drawingMode' in optionsToUpdate || 'styles' in optionsToUpdate || 'colors' in optionsToUpdate) {
833
- this._updateAppearanceStylesheet();
834
- }
835
- if ('drawingMode' in optionsToUpdate || 'styles' in optionsToUpdate) {
836
- if (supportsHighlightApi) CSS.highlights.clear();
837
- this.drawHighlights();
838
- }
839
- if ('selectionHandles' in optionsToUpdate) {
840
- for (const selectionHandle of this._selectionHandles) {
841
- selectionHandle.children[1].innerHTML = options.selectionHandles[selectionHandle.dataset.side] ?? '';
1062
+
1063
+ // Compare new highlight information to old highlight information, returning an object with the properties that changed
1064
+ Highlighter.prototype._diffHighlight = function (newHighlightInfo, oldHighlightInfo) {
1065
+ if (!oldHighlightInfo) return newHighlightInfo;
1066
+ const changedHighlightInfo = { highlightId: newHighlightInfo.highlightId };
1067
+ let hasChanges = false;
1068
+ for (const key of Object.keys(newHighlightInfo)) {
1069
+ const newVal = newHighlightInfo[key], oldVal = oldHighlightInfo[key];
1070
+ const changed = (newVal !== null && typeof newVal === 'object') ? !_shallowEqual(newVal, oldVal ?? {}) : newVal != oldVal;
1071
+ if (key !== 'highlightId' && Object.hasOwn(oldHighlightInfo, key) && changed) {
1072
+ changedHighlightInfo[key] = newVal;
1073
+ hasChanges = true;
842
1074
  }
843
1075
  }
1076
+ return hasChanges ? changedHighlightInfo : null;
844
1077
  }
845
- }
846
-
847
- /** @deprecated Use setOptions({ key: value }) instead */
848
- Highlighter.prototype.setOption = function (key, value) {
849
- console.warn('setOption() is deprecated. Use setOptions({ key: value }) instead.');
850
- this.setOptions({ [key]: value });
851
- }
852
-
853
- // Get all of the initialized options
854
- Highlighter.prototype.getOptions = function () {
855
- return this._options;
856
- }
857
-
858
- // Remove this Highlighter instance and its highlights
859
- Highlighter.prototype.removeHighlighter = function () {
860
- this.loadHighlights([]);
861
- this._annotatableContainer.querySelectorAll('.hh-svg-background, .hh-selection-handle').forEach(el => el.remove())
862
- removeStylesheets(this._stylesheets);
863
- this._resizeObserver.disconnect();
864
- this._controller.abort();
865
-
866
- this._annotatableContainer.highlighter = undefined;
867
- _highlighters = _highlighters.filter(h => h._annotatableContainer !== this._annotatableContainer);
868
- }
869
-
870
-
871
- /********************** Private Methods **********************/
872
-
873
- // Check if the tap hits any highlights, wrappers, or links
874
- Highlighter.prototype._checkForTapTargets = function (pointerEvent) {
875
- if (!pointerEvent) return;
876
-
877
- // Check for tapped highlights
878
- const tappedHighlightIds = [];
879
- if (!pointerEvent.target.closest('[data-hh-ignore]')) {
880
- for (const highlightId of Object.keys(this._highlightsById)) {
1078
+
1079
+ // Undraw the specified highlight
1080
+ Highlighter.prototype._undrawHighlights = function (highlightIds = Object.keys(this._highlightsById)) {
1081
+ const highlightIdsToNormalize = new Set(highlightIds);
1082
+ const paragraphsToNormalize = new Set();
1083
+
1084
+ // Remove <mark> highlights and HTML wrappers; remove SVG highlights; collect paragraphs to normalize
1085
+ for (const highlightId of highlightIds) {
881
1086
  const highlightInfo = this._highlightsById[highlightId];
882
- const highlightRange = highlightInfo.rangeObj;
883
- for (const rangeRect of highlightRange.getClientRects()) {
884
- if (this._isPointInRect(pointerEvent.clientX, pointerEvent.clientY, rangeRect, 5)) {
885
- tappedHighlightIds.push(highlightId);
886
- break;
1087
+ if (!highlightInfo) continue;
1088
+ // If removing a mark-elements highlight that's currently active, deactivate first to avoid an invalid selection range
1089
+ if (highlightId === this._activeHighlightId && highlightInfo.resolvedDrawingMode === 'mark-elements') {
1090
+ this.deactivateHighlights(true, false);
1091
+ }
1092
+ for (const element of this._container.querySelectorAll(`[data-hh-highlight-id="${highlightId}"]`)) {
1093
+ if (element.matches('g')) {
1094
+ element.remove();
1095
+ continue;
1096
+ }
1097
+ if (element.parentElement?.dataset?.hhHighlightId) {
1098
+ highlightIdsToNormalize.add(element.parentElement.dataset.hhHighlightId);
1099
+ }
1100
+ for (const childHighlight of element.querySelectorAll('[data-hh-highlight-id]')) {
1101
+ highlightIdsToNormalize.add(childHighlight.dataset.hhHighlightId);
1102
+ }
1103
+ if (element.matches('mark')) {
1104
+ element.replaceWith(...element.childNodes);
1105
+ } else {
1106
+ if (element.matches('[data-hh-wrapper]')) {
1107
+ this._wrapperElements.delete(`${element.dataset.hhHighlightId}:${element.dataset.hhPosition}`);
1108
+ }
1109
+ element.remove();
887
1110
  }
888
1111
  }
1112
+
1113
+ // Collect paragraphs to normalize
1114
+ paragraphsToNormalize.add(highlightInfo.startParagraphId);
1115
+ paragraphsToNormalize.add(highlightInfo.endParagraphId);
889
1116
  }
890
- }
891
- const tappedHighlights = this.getHighlightInfo(tappedHighlightIds);
892
-
893
- // Check for tapped wrappers and hyperlinks
894
- const tappedWrappers = [];
895
- const tappedHyperlinks = [];
896
- const targetElements = document.elementsFromPoint(pointerEvent.clientX, pointerEvent.clientY);
897
- for (const element of targetElements) {
898
- const wrapper = element.closest('.hh-wrapper-start, .hh-wrapper-end');
899
- if (wrapper) {
900
- tappedWrappers.push({
901
- position: wrapper.className.includes('hh-wrapper-start') ? 'start' : 'end',
902
- highlightInfo: this._highlightsById[wrapper.dataset.highlightId],
903
- });
904
- } else if (element.matches('a') && element.dataset.hhPosition) {
905
- const hyperlinkInfo = this._hyperlinksByPosition[element.dataset.hhPosition];
906
- tappedHyperlinks.push(hyperlinkInfo);
1117
+
1118
+ // Normalize paragraphs, then recalculate ranges for all highlights
1119
+ if (paragraphsToNormalize.size > 0) {
1120
+ for (const paragraphId of paragraphsToNormalize) {
1121
+ const paragraph = document.getElementById(paragraphId);
1122
+ if (paragraph) paragraph.normalize();
1123
+ }
1124
+ // After normalizing, the highlight range is valid, but not correct. Passing true forces the range to be recalculated.
1125
+ for (const highlightId of highlightIdsToNormalize) {
1126
+ this._getCorrectedRangeObj(highlightId, true);
1127
+ }
907
1128
  }
908
- }
909
1129
 
910
- const targetCount = tappedHighlights.length + tappedWrappers.length + tappedHyperlinks.length;
911
- return {
912
- 'targetCount': targetCount,
913
- 'targetFound': targetCount > 0,
914
- 'tapRange': this._getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY),
915
- 'pointerEvent': pointerEvent,
916
- 'highlights': tappedHighlights,
917
- 'wrappers': tappedWrappers,
918
- 'hyperlinks': tappedHyperlinks,
1130
+ // Remove Highlight API highlights
1131
+ // Instead of a global CSS.highlights.clear(), only clear CSS highlights in this Highlighter instance
1132
+ if (supportsHighlightApi) {
1133
+ for (const highlightId of highlightIds) {
1134
+ CSS.highlights.delete(highlightId);
1135
+ }
1136
+ }
919
1137
  }
920
- }
921
1138
 
922
- // Compare new highlight information to old highlight information, returning an object with the properties that changed
923
- Highlighter.prototype._diffHighlight = function (newHighlightInfo, oldHighlightInfo) {
924
- if (!oldHighlightInfo) return newHighlightInfo;
925
- const changedHighlightInfo = {}
926
- for (const key of Object.keys(newHighlightInfo)) {
927
- if (Object.hasOwn(oldHighlightInfo, key) && oldHighlightInfo[key] !== newHighlightInfo[key]) {
928
- changedHighlightInfo[key] = newHighlightInfo[key];
1139
+ // Rebuild the per-highlight stylesheet from scratch using all active highlights
1140
+ Highlighter.prototype._rebuildHighlightStylesheet = function () {
1141
+ let cssText = '';
1142
+ for (const highlightInfo of Object.values(this._highlightsById)) {
1143
+ const resolvedMode = highlightInfo.resolvedDrawingMode;
1144
+ if (resolvedMode === 'highlight-api' || resolvedMode === 'mark-elements') {
1145
+ const styleString = this._getStyleString(highlightInfo.style, 'css', false, highlightInfo.variables, highlightInfo.rangeRect);
1146
+ if (resolvedMode === 'highlight-api') {
1147
+ const colorString = this._options.colorDefs[highlightInfo.color];
1148
+ cssText += `
1149
+ ${this._containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
1150
+ ${this._containerSelector} :is(${this._paragraphSelector})::highlight(${highlightInfo.escapedHighlightId}),
1151
+ ${this._containerSelector} :is(${this._paragraphSelector}) *::highlight(${highlightInfo.escapedHighlightId}) { --hh-color: ${colorString}; ${styleString} }
1152
+ ${this._containerSelector} :is(rt, img)::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }
1153
+ `;
1154
+ } else {
1155
+ cssText += `mark[data-hh-highlight-id=${highlightInfo.escapedHighlightId}][data-hh-style] { ${styleString} }\n`;
1156
+ }
1157
+ }
929
1158
  }
1159
+ this._highlightStylesheet.replaceSync(cssText);
930
1160
  }
931
- return changedHighlightInfo;
932
- }
933
1161
 
934
- // Undraw the specified highlight
935
- Highlighter.prototype._undrawHighlight = function (highlightInfo) {
936
- const highlightId = highlightInfo.highlightId;
1162
+ // Update selection background, handles, and state object
1163
+ Highlighter.prototype._updateSelectionState = function () {
1164
+ const options = this._options;
1165
+ const selection = globalThis.getSelection();
937
1166
 
938
- // Remove <mark> highlights and HTML wrappers
939
- const overlappingHighlightIds = new Set();
940
- this._annotatableContainer.querySelectorAll(`[data-highlight-id="${highlightId}"]:not(g)`).forEach(element => {
941
- if (element.parentElement.dataset.highlightId) {
942
- overlappingHighlightIds.add(element.parentElement.dataset.highlightId);
943
- }
944
- for (const childHighlight of element.querySelectorAll('[data-highlight-id]')) {
945
- overlappingHighlightIds.add(childHighlight.dataset.highlightId);
946
- }
947
- if (element.matches('mark')) {
948
- element.outerHTML = element.innerHTML;
949
- } else {
950
- element.remove();
1167
+ // If the selection starts in another container, let the other container handle it
1168
+ const selectionContainer = this._getSelectionContainer();
1169
+ if (selectionContainer && selectionContainer !== this._container) return;
1170
+ this._containerRect = this._container.getBoundingClientRect();
1171
+ this._additionsRect = this._additionsDiv.getBoundingClientRect();
1172
+
1173
+ // Get bounds hash
1174
+ let selectionRange, boundsHash;
1175
+ if (selection.type !== 'None') {
1176
+ selectionRange = selection.getRangeAt(0);
1177
+ const clientRects = selectionRange.getClientRects();
1178
+ const firstRect = clientRects[0];
1179
+ const lastRect = clientRects[clientRects.length - 1];
1180
+ if (firstRect && lastRect) {
1181
+ boundsHash = `${selection.type}-${Math.round(firstRect.top)},${Math.round(firstRect.left)},${Math.round(firstRect.right)}-${Math.round(lastRect.bottom)},${Math.round(lastRect.left)},${Math.round(lastRect.right)}`;
1182
+ }
951
1183
  }
952
- });
953
1184
 
954
- // Redraw overlapping highlights
955
- overlappingHighlightIds.delete(highlightId);
956
- if (overlappingHighlightIds.size > 0) {
957
- for (const overlappingHighlightId of overlappingHighlightIds) {
958
- this._undrawHighlight(this._highlightsById[overlappingHighlightId]);
1185
+ // Get active highlight properties
1186
+ const activeHighlightId = this._activeHighlightId;
1187
+ const highlightInfo = this._highlightsById[activeHighlightId] ?? null;
1188
+ const isSvgActive = activeHighlightId && highlightInfo?.resolvedDrawingMode === 'svg';
1189
+ const color = highlightInfo?.color ?? null;
1190
+ const style = highlightInfo?.style ?? null;
1191
+ const variables = highlightInfo?.variables ?? {};
1192
+
1193
+ // Check which properties changed
1194
+ const isResizing = this._isResizingSelection;
1195
+ const pointerType = this._pointerType;
1196
+ const prev = this._selectionState;
1197
+ const changes = [];
1198
+ if (activeHighlightId !== prev.activeHighlightId) changes.push('activeHighlightId');
1199
+ if (color !== prev.color) changes.push('color');
1200
+ if (style !== prev.style) changes.push('style');
1201
+ if (!_shallowEqual(variables, prev.variables ?? {})) changes.push('variables');
1202
+ if (isResizing !== prev.isResizing) changes.push('isResizing');
1203
+ if (pointerType !== prev.pointerType) changes.push('pointerType');
1204
+ if (boundsHash != this._previousBoundsHash) changes.push('selection');
1205
+ if (changes.length === 0) return;
1206
+
1207
+ // Get range metadata
1208
+ let rangeText = null, rangeHtml = null, rangeParagraphIds = null, rangeObj = null;
1209
+ let rangeRect = null, rangeParagraphs = [], rangeLineRects = [];
1210
+ if (selectionRange) {
1211
+ const startParagraph = this._closestValidParagraph(selectionRange.startContainer.parentElement);
1212
+ const endParagraph = this._closestValidParagraph(selectionRange.endContainer.parentElement) ?? startParagraph;
1213
+ if (startParagraph && endParagraph) {
1214
+ if (startParagraph === endParagraph) {
1215
+ rangeParagraphs = [startParagraph];
1216
+ } else {
1217
+ const startIdx = this._paragraphs.indexOf(startParagraph);
1218
+ const endIdx = this._paragraphs.indexOf(endParagraph);
1219
+ rangeParagraphs = this._paragraphs.slice(startIdx, endIdx + 1);
1220
+ }
1221
+ ([rangeRect, rangeLineRects] = this._getRangeRects(selectionRange, rangeParagraphs));
1222
+ }
1223
+ rangeParagraphIds = rangeParagraphs.map(p => p.id);
1224
+ rangeObj = selectionRange;
1225
+ ({ rangeHtml, rangeText } = this._getRangeContent(selectionRange, true, false));
959
1226
  }
960
- this.drawHighlights(overlappingHighlightIds);
961
- }
962
1227
 
963
- // Normalize text nodes
964
- const rangeParagraphs = this._annotatableContainer.querySelectorAll(`#${highlightInfo.startParagraphId}, #${highlightInfo.endParagraphId}`);
965
- rangeParagraphs.forEach(p => p.normalize());
966
- this._getCorrectedRangeObj(highlightId);
1228
+ // Update selection state
1229
+ this._selectionState = { activeHighlightId, color, style, variables, isResizing, pointerType, selection, changes, rangeText, rangeHtml, rangeRect: rangeRect, rangeLineRects, rangeParagraphIds, rangeObj };
1230
+ this._previousBoundsHash = boundsHash;
967
1231
 
968
- // Remove SVG highlights
969
- this._svgBackground.querySelectorAll(`g[data-highlight-id="${highlightId}"]`).forEach(element => {
970
- element.remove();
971
- });
1232
+ // Draw SVG selection rects (SVG drawing mode only)
1233
+ this._svgActiveOverlay.innerHTML = '';
1234
+ if (isSvgActive) {
1235
+ this._svgActiveOverlay.dataset.hhColor = color;
1236
+ this._svgActiveOverlay.dataset.hhStyle = style;
1237
+ let svgContent = '';
1238
+ for (const lineRect of rangeLineRects) {
1239
+ svgContent += this._getStyleString(highlightInfo.style, 'svg', true, highlightInfo.variables, highlightInfo.rangeRect, lineRect);
1240
+ }
1241
+ this._svgActiveOverlay.innerHTML = svgContent;
972
1242
 
973
- // Remove Highlight API highlights
974
- if (supportsHighlightApi && CSS.highlights.has(highlightId)) {
975
- const ruleIndexesToDelete = [];
976
- for (let r = 0; r < this._highlightApiStylesheet.cssRules.length; r++) {
977
- if (this._highlightApiStylesheet.cssRules[r].selectorText.includes(`::highlight(${highlightInfo.escapedHighlightId})`)) ruleIndexesToDelete.push(r);
1243
+ // Bring active highlight to the front
1244
+ const svgHighlight = this._svgBackground.querySelector(`g[data-hh-highlight-id="${activeHighlightId}"]`);
1245
+ if (svgHighlight) this._svgBackground.appendChild(svgHighlight);
1246
+ this._svgBackground.appendChild(this._svgActiveOverlay);
978
1247
  }
979
- for (const index of ruleIndexesToDelete.toReversed()) this._highlightApiStylesheet.deleteRule(index);
980
- CSS.highlights.delete(highlightId);
981
- }
982
- }
983
-
984
- // Update selection background and handles
985
- Highlighter.prototype._updateSelectionUi = function (changeType = 'appearance') {
986
- const options = this._options;
987
- const selection = globalThis.getSelection();
988
- const selectionRange = selection.type === 'None' ? null : selection.getRangeAt(0);
989
-
990
- // If the selection starts in another annotatable container, let the other container handle it
991
- if (selection.anchorNode?.parentElement?.closest('[data-hh-container]') && !this._annotatableContainer.contains(selection.anchorNode)) return;
992
-
993
- const highlightInfo = this._highlightsById[this._activeHighlightId] ?? null;
994
- const color = highlightInfo?.color ?? null;
995
- const style = highlightInfo?.style ?? null;
996
- const colorString = options.colors[color] ?? (CSS.supports('color', 'AccentColor') ? 'AccentColor' : 'dodgerblue');
997
-
998
- // Draw SVG selection rects (SVG drawing mode only)
999
- this._svgActiveOverlay.innerHTML = '';
1000
- if (this._activeHighlightId && options.drawingMode === 'svg') {
1001
- let range = this._getCorrectedRangeObj(this._activeHighlightId);
1002
- range = this._snapRangeToBoundaries(range);
1003
- const rangeParagraphs = this._annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
1004
- const clientRects = this._getMergedClientRects(range, rangeParagraphs);
1005
- this._svgActiveOverlay.dataset.color = color;
1006
- this._svgActiveOverlay.dataset.style = style;
1007
- let svgContent = '';
1008
- for (const clientRect of clientRects) {
1009
- svgContent += this._getStyleTemplate(highlightInfo.style, 'svg', clientRect, true);
1010
- }
1011
- this._svgActiveOverlay.innerHTML = svgContent;
1012
-
1013
- // Bring active highlight to the front
1014
- const svgHighlight = this._svgBackground.querySelector(`g[data-highlight-id="${this._activeHighlightId}"]`);
1015
- if (svgHighlight) this._svgBackground.appendChild(svgHighlight);
1016
- this._svgBackground.appendChild(this._svgActiveOverlay);
1017
- }
1018
1248
 
1019
- if (changeType === 'appearance') {
1020
- this._annotatableContainer.style.setProperty('--hh-color', colorString);
1021
-
1022
- // 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).
1023
- if (this._activeHighlightId && options.drawingMode === 'svg') {
1024
- this._selectionStylesheet.replaceSync(`
1025
- ${options.containerSelector} g[data-highlight-id="${this._activeHighlightId}"][data-style] { display: none; }
1026
- ${options.containerSelector} .hh-wrapper-start[data-highlight-id="${this._activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${this._activeHighlightId}"] { visibility: hidden; }
1027
- ${options.containerSelector} ::selection { background-color: transparent; }
1028
- `);
1029
- } else if (this._activeHighlightId) {
1030
- const styleTemplate = this._getStyleTemplate(style, 'css', null, true);
1031
- this._selectionStylesheet.replaceSync(`
1032
- ${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
1033
- ${options.containerSelector} mark[data-highlight-id="${this._activeHighlightId}"][data-style] { all: unset; }
1034
- ${options.containerSelector} .hh-wrapper-start[data-highlight-id="${this._activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${this._activeHighlightId}"] { visibility: hidden; }
1035
- ${options.containerSelector} ::selection { --hh-color: ${colorString}; ${styleTemplate} }
1036
- ${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
1037
- `);
1038
-
1039
- // No active highlight (show the regular selection UI)
1040
- } else {
1041
- this._selectionStylesheet.replaceSync(`
1042
- ${options.containerSelector} ::selection { background-color: Highlight; color: inherit; }
1043
- ${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
1044
- `);
1249
+ // Update selection stylesheet
1250
+ if (['activeHighlightId', 'color', 'style', 'variables'].some(ch => changes.includes(ch))) {
1251
+ const colorString = options.colorDefs[color] ?? (supportsAccentColor ? 'AccentColor' : 'dodgerblue');
1252
+ this._container.style.setProperty('--hh-color', colorString);
1253
+
1254
+ // 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).
1255
+ if (isSvgActive) {
1256
+ this._selectionStylesheet.replaceSync(`
1257
+ ${this._containerSelector} g[data-hh-highlight-id="${activeHighlightId}"][data-hh-style] { display: none; }
1258
+ ${this._containerSelector} [data-hh-wrapper][data-hh-highlight-id="${activeHighlightId}"] { visibility: hidden; }
1259
+ ${this._containerSelector} ::selection { background-color: transparent; }
1260
+ `);
1261
+ } else if (activeHighlightId) {
1262
+ // Remove stale SVG highlight if a highlight switches from SVG to a different resolvedDrawingMode
1263
+ for (const svgHighlight of this._svgBackground.querySelectorAll(`g[data-hh-highlight-id="${activeHighlightId}"]`)) {
1264
+ svgHighlight.remove();
1265
+ }
1266
+ const styleString = this._getStyleString(style, 'css', true, highlightInfo.variables, highlightInfo.rangeRect);
1267
+ this._selectionStylesheet.replaceSync(`
1268
+ ${this._containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
1269
+ ${this._containerSelector} mark[data-hh-highlight-id="${activeHighlightId}"][data-hh-style] { all: unset; }
1270
+ ${this._containerSelector} [data-hh-wrapper][data-hh-highlight-id="${activeHighlightId}"] { visibility: hidden; }
1271
+ ${this._containerSelector} ::selection { background-color: transparent; }
1272
+ ${this._containerSelector} :is(${this._paragraphSelector})::selection,
1273
+ ${this._containerSelector} :is(${this._paragraphSelector}) *::selection { --hh-color: ${colorString}; ${styleString} }
1274
+ ${this._containerSelector} :is(rt, img)::selection { background-color: transparent; }
1275
+ `);
1276
+
1277
+ // No active highlight (show the regular selection UI)
1278
+ } else {
1279
+ this._selectionStylesheet.replaceSync(`
1280
+ ${this._containerSelector} ::selection { background-color: Highlight; color: inherit; }
1281
+ ${this._containerSelector} rt::selection, ${this._containerSelector} img::selection { background-color: transparent; }
1282
+ `);
1283
+ }
1045
1284
  }
1046
1285
 
1047
- // Send event
1048
- this._annotatableContainer.dispatchEvent(new CustomEvent('hh:selectionupdate', { detail: { color: color, style: style, }}));
1049
-
1050
- } else if (changeType === 'bounds') {
1051
1286
  // Update selection handle location and visibility
1052
- if (selection.type === 'Range') {
1053
- const selectionRangeRects = selectionRange.getClientRects();
1054
- const startRect = selectionRangeRects[0];
1055
- const endRect = selectionRangeRects[selectionRangeRects.length - 1];
1056
- const relativeAncestorClientRect = this._relativeAncestorElement.getBoundingClientRect();
1287
+ const showDragHandles =
1288
+ (options.showDragHandles.includes('touch') || pointerType === 'mouse')
1289
+ && (options.showDragHandles.includes('highlights') && activeHighlightId
1290
+ || options.showDragHandles.includes('selection') && !activeHighlightId);
1291
+ if (selection.type === 'Range' && showDragHandles && rangeLineRects.length > 0) {
1057
1292
  const startNodeIsRtl = globalThis.getComputedStyle(selectionRange.startContainer.parentElement).direction === 'rtl';
1058
1293
  const endNodeIsRtl = globalThis.getComputedStyle(selectionRange.endContainer.parentElement).direction === 'rtl';
1059
-
1060
- if (
1061
- (options.showCustomSelectionHandlesOnTouch || this._pointerType === 'mouse')
1062
- && ((options.showCustomSelectionHandlesForActiveHighlights && this._activeHighlightId)
1063
- || (options.showCustomSelectionHandlesForTextSelection && !this._activeHighlightId))
1064
- ) {
1065
- for (const selectionHandle of this._selectionHandles) {
1066
- let side;
1067
- if (selectionHandle.dataset.position === 'start') {
1068
- side = startNodeIsRtl ? 'right' : 'left';
1069
- selectionHandle.style.left = startRect[side] - relativeAncestorClientRect.left + 'px';
1070
- selectionHandle.style.height = startRect.height + 'px';
1071
- selectionHandle.style.top = startRect.top - relativeAncestorClientRect.top + 'px';
1072
- } else {
1073
- side = endNodeIsRtl ? 'left' : 'right';
1074
- selectionHandle.style.left = endRect[side] - relativeAncestorClientRect.left + 'px';
1075
- selectionHandle.style.height = endRect.height + 'px';
1076
- selectionHandle.style.top = endRect.top - relativeAncestorClientRect.top + 'px';
1077
- }
1078
- if (selectionHandle.dataset.side !== side) {
1079
- selectionHandle.dataset.side = side;
1080
- this.setOptions({ selectionHandles: options.selectionHandles });
1081
- }
1294
+ for (const handle of this._dragHandles) {
1295
+ let side;
1296
+ const startRect = rangeLineRects[0];
1297
+ const endRect = rangeLineRects.at(-1);
1298
+ if (handle.dataset.hhPosition === 'start') {
1299
+ side = startNodeIsRtl ? 'right' : 'left';
1300
+ handle.style.left = startRect[side] + 'px';
1301
+ handle.style.height = startRect.height + 'px';
1302
+ handle.style.top = startRect.top + 'px';
1303
+ } else {
1304
+ side = endNodeIsRtl ? 'left' : 'right';
1305
+ handle.style.left = endRect[side] + 'px';
1306
+ handle.style.height = endRect.height + 'px';
1307
+ handle.style.top = endRect.top + 'px';
1308
+ }
1309
+ if (handle.dataset.hhSide !== side) {
1310
+ handle.dataset.hhSide = side;
1311
+ this.setOptions({ dragHandles: options.dragHandles });
1082
1312
  }
1083
- this._setCustomSelectionHandleVisibility(true);
1084
1313
  }
1314
+ this._setDragHandleVisibility(true);
1085
1315
  } else {
1086
- this._setCustomSelectionHandleVisibility(false);
1316
+ this._setDragHandleVisibility(false);
1087
1317
  }
1088
- }
1089
- }
1090
1318
 
1091
- Highlighter.prototype._setCustomSelectionHandleVisibility = function (visible = this._selectionHandles[0].style.visibility === 'hidden') {
1092
- for (const selectionHandle of this._selectionHandles) {
1093
- selectionHandle.style.visibility = visible ? 'visible' : 'hidden';
1319
+ // Send event
1320
+ this._container.dispatchEvent(new CustomEvent('hh:selectionchange', { detail: this._selectionState }));
1094
1321
  }
1095
- }
1096
-
1097
- // Update the selection or highlight range to stay within the annotatable container
1098
- Highlighter.prototype._snapRangeToBoundaries = function (range, anchorNode = null) {
1099
- const options = this._options;
1100
- let startNode = range.startContainer;
1101
- let endNode = range.endContainer;
1102
- let startOffset = range.startOffset;
1103
- let endOffset = range.endOffset;
1104
-
1105
- // Prevent the range from going outside of the annotatable container
1106
- if (!this._annotatableContainer.contains(range.commonAncestorContainer)) {
1107
- if (anchorNode && !this._annotatableContainer.contains(anchorNode)) {
1108
- // Range is from a selection, and the selection anchor is outside of the container
1109
- return range.cloneRange().collapse(true);
1110
- } else if (anchorNode === startNode || this._annotatableContainer.contains(startNode)) {
1111
- // Range starts in the container but ends outside
1112
- endNode = this._getLastTextNode(this._annotatableParagraphs[this._annotatableParagraphs.length - 1]);
1113
- endOffset = endNode.length;
1114
- } else if (anchorNode === endNode || this._annotatableContainer.contains(endNode)) {
1115
- // Range starts outside of the container but ends inside
1116
- startNode = this._getFirstTextNode(this._annotatableParagraphs[0]);
1117
- startOffset = 0;
1322
+
1323
+ Highlighter.prototype._setDragHandleVisibility = function (visible = this._dragHandles[0].style.visibility === 'hidden') {
1324
+ for (const handle of this._dragHandles) {
1325
+ handle.style.visibility = visible ? 'visible' : 'hidden';
1118
1326
  }
1119
1327
  }
1120
1328
 
1121
- // 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.
1122
- if (startOffset === startNode.textContent.length) {
1123
- const walker = this._getTextNodeWalker(range.commonAncestorContainer);
1124
- walker.currentNode = startNode;
1125
- const nextTextNode = walker.nextNode();
1126
- if (nextTextNode) {
1127
- startNode = nextTextNode;
1128
- startOffset = 0;
1329
+ Highlighter.prototype._getSelectionContainer = function () {
1330
+ const selection = globalThis.getSelection();
1331
+ return selection.anchorNode?.parentElement?.closest('[data-hh-container]') ?? null;
1332
+ }
1333
+
1334
+ // Update the selection or highlight range to stay within the container
1335
+ Highlighter.prototype._snapRangeToBoundaries = function (range, anchorNode = null) {
1336
+ const options = this._options;
1337
+ let startNode = range.startContainer;
1338
+ let endNode = range.endContainer;
1339
+ let startOffset = range.startOffset;
1340
+ let endOffset = range.endOffset;
1341
+
1342
+ // Prevent the range from going outside of the container
1343
+ if (!this._container.contains(range.commonAncestorContainer)) {
1344
+ if (anchorNode && !this._container.contains(anchorNode)) {
1345
+ // Range is from a selection, and the selection anchor is outside of the container
1346
+ return range.cloneRange().collapse(true);
1347
+ } else if (anchorNode === startNode || this._container.contains(startNode)) {
1348
+ // Range starts in the container but ends outside
1349
+ endNode = this._getLastTextNode(this._paragraphs[this._paragraphs.length - 1]);
1350
+ endOffset = endNode.length;
1351
+ } else if (anchorNode === endNode || this._container.contains(endNode)) {
1352
+ // Range starts outside of the container but ends inside
1353
+ startNode = this._getFirstTextNode(this._paragraphs[0]);
1354
+ startOffset = 0;
1355
+ }
1129
1356
  }
1130
- }
1131
1357
 
1132
- // Prevent the range from starting or ending in an invalid text node
1133
- if (this._annotatableContainer.contains(range.commonAncestorContainer) && (this._shouldSkipTextNode(startNode) || this._shouldSkipTextNode(endNode))) {
1134
- if (this._shouldSkipTextNode(startNode)) {
1135
- startNode = this._getNextValidTextNode(startNode);
1136
- startOffset = 0;
1358
+ // If the range starts at the end of a text node, move it to start at the beginning of the following text node
1359
+ if (startOffset === startNode.textContent.length && this._activeHighlightId) {
1360
+ const walker = this._getTextNodeWalker(range.commonAncestorContainer);
1361
+ walker.currentNode = startNode;
1362
+ const nextTextNode = walker.nextNode();
1363
+ if (nextTextNode) {
1364
+ startNode = nextTextNode;
1365
+ startOffset = 0;
1366
+ }
1137
1367
  }
1138
- if (this._shouldSkipTextNode(endNode)) {
1139
- endNode = this._getPreviousValidTextNode(endNode);
1140
- endOffset = endNode.length;
1368
+
1369
+ // Prevent the range from starting or ending in an invalid text node
1370
+ const skipStart = this._shouldSkipTextNode(startNode);
1371
+ const skipEnd = this._shouldSkipTextNode(endNode);
1372
+ if (this._container.contains(range.commonAncestorContainer) && (skipStart || skipEnd)) {
1373
+ if (skipStart) {
1374
+ startNode = this._getNextValidTextNode(startNode);
1375
+ startOffset = 0;
1376
+ }
1377
+ if (skipEnd) {
1378
+ endNode = this._getPreviousValidTextNode(endNode);
1379
+ endOffset = endNode?.length;
1380
+ }
1381
+ if (!startNode || !endNode || (startNode === endNode && startOffset === endOffset)) {
1382
+ return range.cloneRange().collapse(true);
1383
+ } else if (startNode === endNode && endOffset < startOffset) {
1384
+ [startOffset, endOffset] = [endOffset, startOffset];
1385
+ }
1141
1386
  }
1142
- if (!startNode || !endNode || (startNode === endNode && startOffset === endOffset)) {
1143
- return range.cloneRange().collapse(true);
1144
- } else if (startNode === endNode && endOffset < startOffset) {
1145
- [startOffset, endOffset] = [endOffset, startOffset];
1387
+
1388
+ // Snap to the nearest word
1389
+ if (options.snapToWord) {
1390
+ const startText = startNode.data;
1391
+ const endText = endNode.data;
1392
+ // Trim whitespace and dashes at range start and end
1393
+ while (_reWhitespaceDash.test(startOffset < startText.length && startText[startOffset])) startOffset += 1;
1394
+ while (endOffset > 0 && _reWhitespaceDash.test(endText[endOffset - 1])) endOffset -= 1;
1395
+
1396
+ // Expand range to word boundaries
1397
+ while (startOffset > 0 && _reNonWhitespaceDash.test(startText[startOffset - 1])) startOffset -= 1;
1398
+ while (endOffset < endText.length && _reNonWhitespaceDash.test(endText[endOffset])) endOffset += 1;
1146
1399
  }
1400
+
1401
+ const newRange = document.createRange();
1402
+ newRange.setStart(startNode, startOffset);
1403
+ newRange.setEnd(endNode, endOffset);
1404
+ return newRange;
1147
1405
  }
1148
1406
 
1149
- // Snap to the nearest word
1150
- if (options.snapToWord) {
1151
- // Trim whitespace and dashes at range start and end
1152
- while (_reWhitespaceDash.test(startOffset < startNode.textContent.length && startNode.textContent[startOffset])) startOffset += 1;
1153
- while (endOffset > 0 && _reWhitespaceDash.test(endNode.textContent[endOffset - 1])) endOffset -= 1;
1407
+ // Get a character offset relative to the paragraph
1408
+ Highlighter.prototype._getParagraphOffset = function (referenceTextNode, referenceTextNodeOffset) {
1409
+ const paragraph = this._closestValidParagraph(referenceTextNode.parentElement);
1410
+ const walker = this._getTextNodeWalker(paragraph);
1411
+ let currentOffset = 0;
1412
+ let textNode;
1413
+ while (textNode = walker.nextNode()) {
1414
+ if (textNode === referenceTextNode) {
1415
+ currentOffset += referenceTextNodeOffset;
1416
+ break;
1417
+ }
1418
+ currentOffset += textNode.length;
1419
+ }
1420
+ return [ paragraph.id, currentOffset ];
1421
+ }
1154
1422
 
1155
- // Expand range to word boundaries
1156
- while (startOffset > 0 && _reNonWhitespaceDash.test(startNode.textContent[startOffset - 1])) startOffset -= 1;
1157
- while (endOffset < endNode.textContent.length && _reNonWhitespaceDash.test(endNode.textContent[endOffset])) endOffset += 1;
1423
+ // Get the character offset relative to the deepest relevant text node
1424
+ Highlighter.prototype._getTextNodeAndOffset = function (parentElement, targetOffset, position) {
1425
+ let textNode, firstTextNode, currentOffset = 0;
1426
+ const walker = this._getTextNodeWalker(parentElement);
1427
+ while (textNode = walker.nextNode()) {
1428
+ if (!firstTextNode) firstTextNode = textNode;
1429
+ const nodeLength = textNode.length;
1430
+ currentOffset += nodeLength;
1431
+ if (currentOffset >= targetOffset) {
1432
+ const relativeOffset = nodeLength - currentOffset + targetOffset;
1433
+ // If the start offset is at the end of a text node, move it to the beginning of the next text node
1434
+ if (position === 'start' && relativeOffset === nodeLength) {
1435
+ const nextTextNode = walker.nextNode();
1436
+ if (nextTextNode) return [ nextTextNode, 0 ];
1437
+ }
1438
+ return [ textNode, relativeOffset ];
1439
+ }
1440
+ }
1441
+ // TODO: Direction isn't always accurate (maybe it resets when selection is cleared and set to a new range programmatically?)
1442
+ const direction = globalThis.getSelection().direction;
1443
+ if (direction == 'backward') {
1444
+ return [ firstTextNode, 0 ];
1445
+ } else {
1446
+ const lastTextNode = walker.currentNode;
1447
+ return [ lastTextNode, lastTextNode.length ];
1448
+ }
1158
1449
  }
1159
1450
 
1160
- const newRange = document.createRange();
1161
- newRange.setStart(startNode, startOffset);
1162
- newRange.setEnd(endNode, endOffset);
1163
- return newRange;
1164
- }
1165
-
1166
- // Get the character offset relative to the annotatable paragraph
1167
- Highlighter.prototype._getParagraphOffset = function (referenceTextNode, referenceTextNodeOffset) {
1168
- const paragraph = referenceTextNode.parentElement.closest(this._options.paragraphSelector);
1169
- const walker = this._getTextNodeWalker(paragraph);
1170
- let currentOffset = 0;
1171
- let textNode;
1172
- while (textNode = walker.nextNode()) {
1173
- if (textNode === referenceTextNode) {
1174
- currentOffset += referenceTextNodeOffset;
1175
- break;
1176
- }
1177
- currentOffset += textNode.textContent.length;
1451
+ // Get the first valid text node in an element
1452
+ Highlighter.prototype._getFirstTextNode = function (element) {
1453
+ const walker = this._getTextNodeWalker(element);
1454
+ return walker.nextNode();
1178
1455
  }
1179
- return [ paragraph.id, currentOffset ];
1180
- }
1181
-
1182
- // Get the character offset relative to the deepest relevant text node
1183
- Highlighter.prototype._getTextNodeAndOffset = function (parentElement, targetOffset) {
1184
- let textNode, firstTextNode, currentOffset = 0;
1185
- const walker = this._getTextNodeWalker(parentElement);
1186
- while (textNode = walker.nextNode()) {
1187
- if (!firstTextNode) firstTextNode = walker.currentNode;
1188
- currentOffset += textNode.textContent.length;
1189
- if (currentOffset >= targetOffset) {
1190
- const relativeOffset = textNode.textContent.length - currentOffset + targetOffset
1191
- return [ textNode, relativeOffset ];
1192
- }
1456
+
1457
+ // Get the last valid text node in an element
1458
+ Highlighter.prototype._getLastTextNode = function (element) {
1459
+ const walker = this._getTextNodeWalker(element);
1460
+ let lastNode = null, node;
1461
+ while (node = walker.nextNode()) lastNode = node;
1462
+ return lastNode;
1193
1463
  }
1194
- // TODO: Direction isn't always accurate (maybe it resets when selection is cleared and set to a new range programmatically?)
1195
- const direction = globalThis.getSelection().direction;
1196
- if (direction == 'backward') {
1197
- return [ firstTextNode, 0 ];
1198
- } else {
1199
- const lastTextNode = walker.currentNode;
1200
- return [ lastTextNode, lastTextNode.textContent.length ];
1464
+
1465
+ // Get the next valid text node in the container
1466
+ Highlighter.prototype._getNextValidTextNode = function (currentNode) {
1467
+ const walker = this._getTextNodeWalker();
1468
+ walker.currentNode = currentNode;
1469
+ return walker.nextNode();
1201
1470
  }
1202
- }
1203
-
1204
- // Get the first valid text node in an element
1205
- Highlighter.prototype._getFirstTextNode = function (element) {
1206
- const walker = this._getTextNodeWalker(element);
1207
- return walker.nextNode();
1208
- }
1209
-
1210
- // Get the last valid text node in an element
1211
- Highlighter.prototype._getLastTextNode = function (element) {
1212
- const walker = this._getTextNodeWalker(element);
1213
- let lastNode = null, node;
1214
- while (node = walker.nextNode()) lastNode = node;
1215
- return lastNode;
1216
- }
1217
-
1218
- // Get the previous valid text node in the annotatable container
1219
- Highlighter.prototype._getNextValidTextNode = function (currentNode) {
1220
- const walker = this._getTextNodeWalker(this._annotatableContainer);
1221
- walker.currentNode = currentNode;
1222
- return walker.nextNode();
1223
- }
1224
-
1225
- // Get the next valid text node in the annotatable container
1226
- Highlighter.prototype._getPreviousValidTextNode = function (currentNode) {
1227
- const walker = this._getTextNodeWalker(this._annotatableContainer);
1228
- walker.currentNode = currentNode;
1229
- return walker.previousNode();
1230
- }
1231
-
1232
- // Determine if text node should be skipped when snapping to word or calculating character offsets
1233
- Highlighter.prototype._shouldSkipTextNode = function (textNode) {
1234
- const parentParagraph = textNode.parentNode.closest(this._options.paragraphSelector);
1235
- const hhIgnore = textNode.parentNode.closest('[data-hh-ignore]');
1236
- if (!parentParagraph || hhIgnore || textNode.textContent === '') return true;
1237
- return false;
1238
- }
1239
-
1240
- // Get text node walker
1241
- Highlighter.prototype._getTextNodeWalker = function (root) {
1242
- return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, (node) => this._shouldSkipTextNode(node) ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT);
1243
- }
1244
-
1245
- // Update appearance stylesheet (user-defined colors and styles)
1246
- Highlighter.prototype._updateAppearanceStylesheet = function () {
1247
- const options = this._options;
1248
- this._appearanceStylesheet.replaceSync('');
1249
- for (const color of Object.keys(options.colors)) {
1250
- this._appearanceStylesheet.insertRule(`[data-color="${color}"] { --hh-color: ${options.colors[color]}; }`);
1471
+
1472
+ // Get the previous valid text node in the container
1473
+ Highlighter.prototype._getPreviousValidTextNode = function (currentNode) {
1474
+ const walker = this._getTextNodeWalker();
1475
+ walker.currentNode = currentNode;
1476
+ return walker.previousNode();
1251
1477
  }
1252
- for (const style of Object.keys(options.styles)) {
1253
- const styleTemplate = this._getStyleTemplate(style, 'css', null);
1254
- this._appearanceStylesheet.insertRule(`mark[data-highlight-id][data-style="${style}"] { ${styleTemplate} }`);
1478
+
1479
+ // Determine if text node should be skipped when snapping to word or calculating character offsets
1480
+ Highlighter.prototype._shouldSkipTextNode = function (textNode) {
1481
+ const parentParagraph = this._closestValidParagraph(textNode.parentNode);
1482
+ const ignoreParent = textNode.parentNode.closest(`${this._containerSelector} [data-hh-ignore]`);
1483
+ if (!parentParagraph || ignoreParent || textNode.textContent === '') return true;
1484
+ return false;
1255
1485
  }
1256
- }
1257
-
1258
- // Get style template for a given highlight style
1259
- Highlighter.prototype._getStyleTemplate = function (style, type, clientRect = null, active = false) {
1260
- const options = this._options;
1261
- style = Object.hasOwn(options.styles, style) ? style : options.defaultStyle;
1262
- let styleTemplate = options.styles[style]?.[type] ?? '';
1263
- if (active) {
1264
- styleTemplate = options.styles[style]?.[`${type}-active`] ?? styleTemplate;
1486
+
1487
+ // Get text node walker
1488
+ Highlighter.prototype._getTextNodeWalker = function (root = this._container) {
1489
+ if (!root) root = this._container;
1490
+ return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, (node) => this._shouldSkipTextNode(node) ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT);
1265
1491
  }
1266
- if (!styleTemplate) {
1267
- console.warn(`Highlight style "${style}" in options does not have a defined "${type}" value.`);
1268
- return;
1492
+
1493
+ // Get the closest valid paragraph
1494
+ Highlighter.prototype._closestValidParagraph = function (element) {
1495
+ const parentParagraph = element ? element.closest(this._paragraphSelector) : null;
1496
+ if (parentParagraph && this._container.contains(parentParagraph)) return parentParagraph;
1269
1497
  }
1270
- if (type === 'svg' && clientRect) {
1271
- const relativeAncestorClientRect = this._relativeAncestorElement.getBoundingClientRect();
1272
- styleTemplate = styleTemplate
1273
- .replaceAll('{x}', clientRect.x - relativeAncestorClientRect.x)
1274
- .replaceAll('{y}', clientRect.y - relativeAncestorClientRect.y)
1275
- .replaceAll('{width}', clientRect.width)
1276
- .replaceAll('{height}', clientRect.height)
1277
- .replaceAll('{top}', clientRect.top - relativeAncestorClientRect.top)
1278
- .replaceAll('{right}', clientRect.right - relativeAncestorClientRect.right)
1279
- .replaceAll('{bottom}', clientRect.bottom - relativeAncestorClientRect.bottom)
1280
- .replaceAll('{left}', clientRect.left - relativeAncestorClientRect.left);
1498
+
1499
+ // Update color stylesheet
1500
+ Highlighter.prototype._updateColorStylesheet = function () {
1501
+ const options = this._options;
1502
+ let css = '';
1503
+ for (const color of Object.keys(options.colorDefs)) {
1504
+ css += `${this._containerSelector} [data-hh-color="${color}"] { --hh-color: ${options.colorDefs[color]}; }\n`;
1505
+ }
1506
+ this._colorStylesheet.replaceSync(css);
1281
1507
  }
1282
- return styleTemplate;
1283
- }
1284
-
1285
- // Restore the previous selection range in case the browser clears the selection
1286
- Highlighter.prototype._getRestoredSelectionOrCaret = function (selection, pointerEvent = null) {
1287
- if (selection.type === 'None') {
1288
- if (this._previousSelectionRange) {
1289
- // iOS Safari deselects text when a button is tapped. This restores the selection.
1290
- selection.addRange(this._previousSelectionRange);
1291
- } else if (pointerEvent) {
1292
- // 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.
1293
- let range = this._getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY);
1294
- selection.addRange(range);
1508
+
1509
+ // Get the resolved drawing mode for a highlight
1510
+ Highlighter.prototype._getResolvedDrawingMode = function (highlightInfo) {
1511
+ const options = this._options;
1512
+ if (options.drawingMode === 'mark-elements') return 'mark-elements';
1513
+ const resolvedStyle = Object.hasOwn(options.styleDefs, highlightInfo.style) ? highlightInfo.style : _defaultStyle;
1514
+ if ((options.drawingMode === 'svg' && !options.styleDefs[resolvedStyle]?.svg)
1515
+ || (options.drawingMode === 'highlight-api' && !supportsHighlightApi)) {
1516
+ return 'mark-elements';
1295
1517
  }
1518
+ return options.drawingMode;
1296
1519
  }
1297
- return selection;
1298
- }
1299
-
1300
- // Fix highlight range if the DOM changed and made the previous highlight range invalid
1301
- Highlighter.prototype._getCorrectedRangeObj = function (highlightId) {
1302
- const highlightInfo = this._highlightsById[highlightId];
1303
- const highlightRange = highlightInfo?.rangeObj;
1304
- if (highlightRange && (
1305
- highlightRange.startContainer.nodeType !== Node.TEXT_NODE || highlightRange.endContainer.nodeType !== Node.TEXT_NODE ||
1306
- highlightRange.startOffset > highlightRange.startContainer.textContent.length - 1 ||
1307
- highlightRange.endOffset > highlightRange.endContainer.textContent.length
1308
- )) {
1309
- const [ startNode, startOffset ] = this._getTextNodeAndOffset(document.getElementById(highlightInfo.startParagraphId), highlightInfo.startParagraphOffset);
1310
- const [ endNode, endOffset ] = this._getTextNodeAndOffset(document.getElementById(highlightInfo.endParagraphId), highlightInfo.endParagraphOffset);
1311
- highlightRange.setStart(startNode, startOffset);
1312
- highlightRange.setEnd(endNode, endOffset);
1520
+
1521
+ Highlighter.prototype._getWrapperTemplateHash = function (highlightInfo) {
1522
+ const wrapperDef = this._options.wrapperDefs[highlightInfo.wrapper];
1523
+ const str = (highlightInfo.wrapper ?? '') + (wrapperDef?.start ?? '') + (wrapperDef?.end ?? '');
1524
+ let h = 0;
1525
+ for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
1526
+ return String(h);
1313
1527
  }
1314
- return highlightRange;
1315
- }
1316
-
1317
- // Convert tap or click to a selection range
1318
- // Adapted from https://stackoverflow.com/a/12924488/1349044
1319
- Highlighter.prototype._getCaretFromCoordinates = function (clientX, clientY, checkAnnotatable = false, checkXDistance = false, checkYDistance = false) {
1320
- let range;
1321
- if (supportsCaretPositionFromPoint) {
1322
- let caretPosition = document.caretPositionFromPoint(clientX, clientY);
1323
- range = document.createRange();
1324
- range.setStart(caretPosition.offsetNode, caretPosition.offset);
1325
- range.collapse(true);
1326
- } else if (supportsCaretRangeFromPoint) {
1327
- range = document.caretRangeFromPoint(clientX, clientY);
1528
+
1529
+ Highlighter.prototype._getWrapperHash = function (highlightInfo, templateHash = null) {
1530
+ const wrapperDef = this._options.wrapperDefs[highlightInfo.wrapper];
1531
+ const wrapperTemplate = (wrapperDef?.start ?? '') + (wrapperDef?.end ?? '');
1532
+ let hash = templateHash ?? this._getWrapperTemplateHash(highlightInfo);
1533
+ for (const key of Object.keys(highlightInfo.variables).sort()) {
1534
+ hash += '\u0000' + key + '\u0001' + highlightInfo.variables[key];
1535
+ }
1536
+ if (highlightInfo.rangeRect && wrapperTemplate.includes('{range.')) {
1537
+ const r = highlightInfo.rangeRect;
1538
+ hash += '\u0002' + Math.round(r.x) + ',' + Math.round(r.y) + ',' + Math.round(r.width) + ',' + Math.round(r.height);
1539
+ }
1540
+ if (highlightInfo.rangeRect?.columnRect && wrapperTemplate.includes('{column.')) {
1541
+ const c = highlightInfo.rangeRect.columnRect;
1542
+ hash += '\u0004' + Math.round(c.x) + ',' + Math.round(c.y) + ',' + Math.round(c.width) + ',' + Math.round(c.height);
1543
+ }
1544
+ if (this._containerRect && wrapperTemplate.includes('{container.')) {
1545
+ const c = this._containerRect;
1546
+ hash += '\u0003' + Math.round(c.x) + ',' + Math.round(c.y) + ',' + Math.round(c.width) + ',' + Math.round(c.height);
1547
+ }
1548
+ return hash;
1328
1549
  }
1329
- if (!range) return;
1330
- if (checkXDistance || checkYDistance) {
1331
- const maxDistance = 30;
1332
- const caretClientRect = range.getBoundingClientRect();
1333
- if (checkXDistance && Math.abs(clientX - caretClientRect.x) > maxDistance) return;
1334
- if (checkYDistance && Math.abs(clientY - caretClientRect.top) > maxDistance && Math.abs(clientY - caretClientRect.bottom) > maxDistance) return;
1550
+
1551
+ // Update wrapper children in-place (attributes and leaf text), preserving element identity for CSS transitions
1552
+ Highlighter.prototype._updateWrapperInPlace = function (wrapper, newHtmlString) {
1553
+ if (wrapper.children.length === 0) {
1554
+ wrapper.innerHTML = newHtmlString;
1555
+ return;
1556
+ }
1557
+ if (wrapper.innerHTML === newHtmlString) return;
1558
+ const temp = document.createElement('div');
1559
+ temp.innerHTML = newHtmlString;
1560
+ const existingAll = wrapper.querySelectorAll('*');
1561
+ const updatedAll = temp.querySelectorAll('*');
1562
+ for (let i = 0; i < existingAll.length && i < updatedAll.length; i++) {
1563
+ const existing = existingAll[i];
1564
+ const updated = updatedAll[i];
1565
+ for (const attr of updated.attributes) {
1566
+ if (existing.getAttribute(attr.name) !== attr.value) existing.setAttribute(attr.name, attr.value);
1567
+ }
1568
+ if (!updated.children.length && existing.textContent !== updated.textContent) {
1569
+ existing.textContent = updated.textContent;
1570
+ }
1571
+ }
1335
1572
  }
1336
- if (checkAnnotatable && !range.startContainer.parentElement.closest(this._options.paragraphSelector)) return;
1337
- return range;
1338
- }
1339
-
1340
- // Check if a point is in a DOMRect
1341
- Highlighter.prototype._isPointInRect = function (x, y, rect, padding = 0) {
1342
- return (
1343
- x >= rect.x - padding && x <= rect.x + rect.width + padding &&
1344
- y >= rect.y - padding && y <= rect.y + rect.height + padding
1345
- )
1346
- }
1347
-
1348
- // Get merged client rects from the highlight range
1349
- Highlighter.prototype._getMergedClientRects = function (range, paragraphs) {
1350
- const unmergedRects = Array.from(range.getClientRects());
1351
- const mergedRects = [];
1352
-
1353
- // Remove element rects (only text node rects are needed)
1354
- const ancestorElementsInRange = new Set();
1355
- for (const paragraph of paragraphs) {
1356
- let element = paragraph;
1357
- while (range.commonAncestorContainer.contains(element)) {
1358
- ancestorElementsInRange.add(element);
1359
- element = element.parentElement;
1573
+
1574
+ // Apply variable substitutions
1575
+ Highlighter.prototype._applySubstitutions = function (str, variables = {}, rangeRect = null, lineRect = null) {
1576
+ if (!str) return str;
1577
+ const map = {};
1578
+ const rectKeys = ['x', 'y', 'width', 'height', 'top', 'right', 'bottom', 'left'];
1579
+ for (const key of Object.keys(variables)) map[`{${key}}`] = variables[key];
1580
+ if (lineRect) for (const k of rectKeys) map[`{line.${k}}`] = lineRect[k];
1581
+ if (rangeRect) for (const k of rectKeys) map[`{range.${k}}`] = rangeRect[k];
1582
+ if (rangeRect?.columnRect) for (const k of rectKeys) map[`{column.${k}}`] = rangeRect.columnRect[k];
1583
+ if (this._containerRect) for (const k of rectKeys) map[`{container.${k}}`] = this._containerRect[k];
1584
+ return str.replace(/\{[^}]+\}/g, match => map[match] ?? match);
1585
+ }
1586
+
1587
+ // Get style string for a given highlight style
1588
+ Highlighter.prototype._getStyleString = function (style, type, active = false, variables = {}, rangeRect = null, lineRect = null) {
1589
+ const options = this._options;
1590
+ style = Object.hasOwn(options.styleDefs, style) ? style : _defaultStyle;
1591
+ let styleString = options.styleDefs[style]?.[type] ?? '';
1592
+ if (active) {
1593
+ styleString = options.styleDefs[style]?.[`${type}Active`] ?? styleString;
1594
+ }
1595
+ if (styleString) {
1596
+ styleString = this._applySubstitutions(styleString, variables, rangeRect, lineRect);
1360
1597
  }
1598
+ return styleString;
1361
1599
  }
1362
- for (let ur = unmergedRects.length - 1; ur >= 0; ur--) {
1363
- const rect = unmergedRects[ur];
1364
- for (const element of ancestorElementsInRange) {
1365
- const elementRect = element.getBoundingClientRect();
1366
- 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)) {
1367
- unmergedRects.splice(ur, 1);
1368
- break;
1600
+
1601
+ // Check if the text selection is being resized. If a new selection range is within 2 characters of the previous selection range, the selection is assumed to be resizing. There's not a reliable way to track dragging of a native OS selection handle from JavaScript, but this can be used as an approximation. It will also trigger when dragging a custom handle, and when expanding a selection with arrow keys.
1602
+ Highlighter.prototype._detectSelectionResize = function (prevRange, newRange, selectionType) {
1603
+ this._handleDragTimeoutId = clearTimeout(this._handleDragTimeoutId);
1604
+ const rangesMatch = _rangesEqual(prevRange, newRange);
1605
+ if (rangesMatch) return;
1606
+ if (selectionType === 'Range' && prevRange && newRange) {
1607
+ const expandedPrevRange = document.createRange();
1608
+
1609
+ if (prevRange.startOffset >= 2) {
1610
+ expandedPrevRange.setStart(prevRange.startContainer, prevRange.startOffset - 2);
1611
+ } else {
1612
+ const prevTextNode = this._getPreviousValidTextNode(prevRange.startContainer);
1613
+ expandedPrevRange.setStart(prevTextNode ?? prevRange.startContainer, prevTextNode ? Math.max(0, prevTextNode.length - 2) : prevRange.startOffset);
1614
+ }
1615
+
1616
+ if (prevRange.endOffset <= prevRange.endContainer.length - 2) {
1617
+ expandedPrevRange.setEnd(prevRange.endContainer, prevRange.endOffset + 2);
1618
+ } else {
1619
+ const nextTextNode = this._getNextValidTextNode(prevRange.endContainer);
1620
+ expandedPrevRange.setEnd(nextTextNode ?? prevRange.endContainer, nextTextNode ? Math.min(2, nextTextNode.length) : prevRange.endOffset);
1621
+ }
1622
+
1623
+ this._isResizingSelection =
1624
+ expandedPrevRange.isPointInRange(newRange.startContainer, newRange.startOffset) ||
1625
+ expandedPrevRange.isPointInRange(newRange.endContainer, newRange.endOffset);
1626
+
1627
+ } else {
1628
+ this._isResizingSelection = false;
1629
+ }
1630
+ if (this._isResizingSelection) {
1631
+ // For most browsers: If we know the pointer is down, we can assume that the user is still resizing the selection. this._isResizingSelection will be set to false on pointerup.
1632
+ if (this._container.dataset.hhPointerDown === 'true') return;
1633
+
1634
+ // For Android Chrome: Set this._isResizingSelection to false if it's been more than 400 milliseconds since the last selection change. Android doesn't send a pointerdown event when dragging the built-in text selection handle, so we can't rely on the the pointerup event to set this._isResizingSelection back to false. A side effect of this approach is that this._isResizingSelection may be set to false if a user pauses (finger still on the screen) while dragging the selection handle. If a custom popup menu is triggered based on this flag, it may briefly flicker each time the user pauses.
1635
+ this._handleDragTimeoutId = setTimeout(() => {
1636
+ this._isResizingSelection = false;
1637
+ this._updateSelectionState();
1638
+ }, 400);
1639
+ }
1640
+ }
1641
+
1642
+ // Restore the previous selection range in case the browser clears the selection
1643
+ Highlighter.prototype._getRestoredSelectionOrCaret = function (selection, pointerEvent = null) {
1644
+ if (selection.type === 'None') {
1645
+ if (this._previousSelectionRange) {
1646
+ // iOS Safari deselects text when a button is tapped. This restores the selection.
1647
+ selection.addRange(this._previousSelectionRange);
1648
+ } else if (pointerEvent) {
1649
+ // 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.
1650
+ let range = this._getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY);
1651
+ selection.addRange(range);
1369
1652
  }
1370
1653
  }
1654
+ return selection;
1371
1655
  }
1372
1656
 
1373
- // Loop through the highlight's paragraphs
1374
- for (const paragraph of paragraphs) {
1657
+ Highlighter.prototype._restorePreviousSelectionRange = function () {
1658
+ const selection = globalThis.getSelection();
1659
+ if (this._previousSelectionDirection === 'forward') {
1660
+ selection.setBaseAndExtent(this._previousSelectionRange.startContainer, this._previousSelectionRange.startOffset, this._previousSelectionRange.endContainer, this._previousSelectionRange.endOffset);
1661
+ } else {
1662
+ selection.setBaseAndExtent(this._previousSelectionRange.endContainer, this._previousSelectionRange.endOffset, this._previousSelectionRange.startContainer, this._previousSelectionRange.startOffset);
1663
+ }
1664
+ }
1665
+
1666
+ // Fix highlight range if the DOM changed and made the previous highlight range invalid
1667
+ Highlighter.prototype._getCorrectedRangeObj = function (highlightId, force = false) {
1668
+ const highlightInfo = this._highlightsById[highlightId];
1669
+ const highlightRange = highlightInfo?.rangeObj;
1670
+ if (highlightRange && (
1671
+ force || highlightRange.collapsed ||
1672
+ highlightRange.startContainer.nodeType !== Node.TEXT_NODE || highlightRange.endContainer.nodeType !== Node.TEXT_NODE ||
1673
+ highlightRange.startOffset >= highlightRange.startContainer.length ||
1674
+ highlightRange.endOffset > highlightRange.endContainer.length
1675
+ )) {
1676
+ const [ startNode, startOffset ] = this._getTextNodeAndOffset(document.getElementById(highlightInfo.startParagraphId), highlightInfo.startParagraphOffset, 'start');
1677
+ const [ endNode, endOffset ] = this._getTextNodeAndOffset(document.getElementById(highlightInfo.endParagraphId), highlightInfo.endParagraphOffset, 'end');
1678
+ highlightRange.setStart(startNode, startOffset);
1679
+ highlightRange.setEnd(endNode, endOffset);
1680
+ }
1681
+ return highlightRange;
1682
+ }
1683
+
1684
+ // Convert tap or click to a selection range
1685
+ // Adapted from https://stackoverflow.com/a/12924488/1349044
1686
+ Highlighter.prototype._getCaretFromCoordinates = function (clientX, clientY, checkAnnotatable = false, checkXDistance = false, checkYDistance = false) {
1687
+ let range;
1688
+ if (supportsCaretPositionFromPoint) {
1689
+ const caretPosition = document.caretPositionFromPoint(clientX, clientY);
1690
+ if (!caretPosition) return;
1691
+ range = document.createRange();
1692
+ range.setStart(caretPosition.offsetNode, caretPosition.offset);
1693
+ range.collapse(true);
1694
+ } else if (supportsCaretRangeFromPoint) {
1695
+ range = document.caretRangeFromPoint(clientX, clientY);
1696
+ }
1697
+ if (!range) return;
1698
+ if (range.startContainer.parentElement?.closest(`${this._containerSelector} [data-hh-ignore]`)) return;
1699
+ if (checkXDistance || checkYDistance) {
1700
+ const maxDistance = 30;
1701
+ const caretClientRect = range.getBoundingClientRect();
1702
+ if (checkXDistance && Math.abs(clientX - caretClientRect.x) > maxDistance) return;
1703
+ if (checkYDistance && Math.abs(clientY - caretClientRect.top) > maxDistance && Math.abs(clientY - caretClientRect.bottom) > maxDistance) return;
1704
+ }
1705
+ if (checkAnnotatable && !this._closestValidParagraph(range.startContainer.parentElement)) return;
1706
+ return range;
1707
+ }
1708
+
1709
+ // Get line rectangles for a given paragraph
1710
+ Highlighter.prototype._getParagraphLineRects = function (paragraph) {
1375
1711
  const paragraphRect = paragraph.getBoundingClientRect();
1376
1712
  const paragraphStyle = getComputedStyle(paragraph);
1377
- const snapTolerance = Math.max(Number.parseInt(paragraphStyle.fontSize) / 2, 4);
1378
- const topPadding = Number.parseInt(paragraphStyle.paddingTop);
1379
- const textIndent = Number.parseInt(paragraphStyle.textIndent);
1713
+ const paragraphFontSize = Number.parseFloat(paragraphStyle.fontSize);
1714
+ const snapTolerance = Math.max(paragraphFontSize / 2, 4);
1380
1715
 
1381
- // Build line positions from paragraph text nodes
1382
1716
  const linePositions = {};
1383
1717
  const lineBottomKeys = [];
1384
- const walker = this._getTextNodeWalker(paragraph);
1385
1718
  const textNodeRange = document.createRange();
1719
+ const walker = this._getTextNodeWalker(paragraph);
1386
1720
  let textNode;
1387
1721
  while (textNode = walker.nextNode()) {
1388
1722
  // Skip text nodes in elements that are higher or lower than surrounding text
1389
1723
  if (textNode.parentElement.closest('sup, sub, rt')) continue;
1724
+ const textNodeStyle = textNode.parentElement === paragraph ? paragraphStyle : getComputedStyle(textNode.parentElement);
1725
+ const fontSize = Number.parseFloat(textNodeStyle.fontSize);
1726
+ const lineHeight = Number.parseFloat(textNodeStyle.lineHeight) || (fontSize * 1.2);
1390
1727
  textNodeRange.selectNode(textNode);
1391
1728
  for (const rect of textNodeRange.getClientRects()) {
1392
- // Round to a nearby line position if close
1393
- const lineBottom = lineBottomKeys.find(b => Math.abs(b - rect.bottom) < snapTolerance) ?? rect.bottom;
1729
+ const lineBottom = lineBottomKeys.find(b => {
1730
+ if (Math.abs(b - rect.bottom) >= snapTolerance) return false;
1731
+ const pos = linePositions[b];
1732
+ return rect.left <= pos.maxRight + snapTolerance && rect.right >= pos.minLeft - snapTolerance;
1733
+ }) ?? rect.bottom;
1394
1734
  if (!linePositions[lineBottom]) lineBottomKeys.push(lineBottom);
1395
- const pos = linePositions[lineBottom] ??= {
1396
- minLeft: rect.left, maxRight: rect.right,
1397
- highlightLeft: null, highlightRight: null,
1398
- };
1735
+ const pos = linePositions[lineBottom] ??= { minLeft: rect.left, maxRight: rect.right, maxFontSize: fontSize, maxLineHeight: lineHeight };
1399
1736
  pos.minLeft = Math.min(pos.minLeft, rect.left);
1400
1737
  pos.maxRight = Math.max(pos.maxRight, rect.right);
1738
+ pos.maxFontSize = Math.max(pos.maxFontSize, fontSize);
1739
+ pos.maxLineHeight = Math.max(pos.maxLineHeight, lineHeight);
1401
1740
  }
1402
1741
  }
1403
1742
 
1404
- const lineBottoms = lineBottomKeys.sort((a, b) => a - b);
1743
+ // Expand line rects to include pseudoelements, and snap to the paragraph's leading edge
1744
+ const pseudoElemRects = _gatherPseudoElemRects(paragraph);
1745
+ lineBottomKeys.sort((a, b) => a - b);
1746
+ const lines = lineBottomKeys.map((lineBottom) => {
1747
+ const pos = linePositions[lineBottom];
1748
+ const centerY = lineBottom - pos.maxLineHeight / 2;
1749
+ if (paragraphStyle.direction === 'ltr') {
1750
+ pos.minLeft = this._snapToEdge(pseudoElemRects, pos.minLeft, 'left', centerY, paragraphRect.left, snapTolerance);
1751
+ } else {
1752
+ pos.maxRight = this._snapToEdge(pseudoElemRects, pos.maxRight, 'right', centerY, paragraphRect.right, snapTolerance);
1753
+ }
1754
+ return new DOMRect(pos.minLeft, lineBottom - pos.maxLineHeight, pos.maxRight - pos.minLeft, pos.maxLineHeight);
1755
+ });
1756
+
1757
+ return { lines, snapTolerance, pseudoElemRects };
1758
+ }
1405
1759
 
1406
- // Assign highlight range rects to matching line positions
1407
- for (const rect of unmergedRects) {
1408
- const centerY = rect.y + rect.height / 2;
1409
- for (let i = 0; i < lineBottoms.length; i++) {
1410
- const pos = linePositions[lineBottoms[i]];
1411
- // Skip rects that extend outside the line of text (such as absolutely-positioned elements)
1412
- if (rect.left >= pos.minLeft && rect.right <= pos.maxRight
1413
- && centerY > (lineBottoms[i - 1] ?? (paragraphRect.top + topPadding)) && centerY <= lineBottoms[i]) {
1414
- pos.highlightLeft = Math.min(pos.highlightLeft ?? rect.left, rect.left);
1415
- pos.highlightRight = Math.max(pos.highlightRight ?? rect.right, rect.right);
1416
- break;
1760
+ Highlighter.prototype._updateHighlightRects = function (highlightIds = Object.keys(this._highlightsById), paragraphLineRectsCache = new Map(), columnGeometryCache = new Map()) {
1761
+ for (const highlightId of highlightIds) {
1762
+ const highlightInfo = this._highlightsById[highlightId];
1763
+ const rangeParagraphs = highlightInfo.rangeParagraphIds.map(id => document.getElementById(id));
1764
+ const [rangeRect, rangeLineRects] = this._getRangeRects(highlightInfo.rangeObj, rangeParagraphs, paragraphLineRectsCache, columnGeometryCache);
1765
+ highlightInfo.rangeRect = rangeRect;
1766
+ highlightInfo.rangeLineRects = rangeLineRects;
1767
+ }
1768
+ }
1769
+
1770
+ // Get merged client rects from the highlight range
1771
+ Highlighter.prototype._getRangeRects = function (range, paragraphs, paragraphLineRectsCache = new Map(), columnGeometryCache = new Map()) {
1772
+ let rangeRectCoordinates = { top: Number.POSITIVE_INFINITY, bottom: Number.NEGATIVE_INFINITY, left: Number.POSITIVE_INFINITY, right: Number.NEGATIVE_INFINITY };
1773
+ const rangeLineRects = [];
1774
+ let columnRect = null;
1775
+ const textNodeRange = document.createRange();
1776
+ let prevParagraphBottom = Number.NEGATIVE_INFINITY;
1777
+ for (const paragraph of paragraphs) {
1778
+ let paragraphLineRects = paragraphLineRectsCache.get(paragraph.id);
1779
+ if (!paragraphLineRects) {
1780
+ paragraphLineRects = this._getParagraphLineRects(paragraph);
1781
+ paragraphLineRectsCache.set(paragraph.id, paragraphLineRects);
1782
+ }
1783
+ const { lines, snapTolerance, pseudoElemRects } = paragraphLineRects;
1784
+ // If the previous paragraph is below the current paragraph (such as in multi-column layouts), reset prevParagraphBottom
1785
+ if (lines.length === 0) continue;
1786
+ if (prevParagraphBottom > lines[0].bottom) prevParagraphBottom = Number.NEGATIVE_INFINITY;
1787
+ const lineStates = lines.map((lineRect, i) => ({
1788
+ topY: i === 0 ? prevParagraphBottom : lines[i - 1].bottom,
1789
+ left: Infinity, right: -Infinity,
1790
+ }));
1791
+
1792
+ // Per-highlight accumulators
1793
+ const walker = this._getTextNodeWalker(paragraph);
1794
+ let textNode;
1795
+ while (textNode = walker.nextNode()) {
1796
+ if (!range.intersectsNode(textNode)) {
1797
+ if (range.comparePoint(textNode, 0) > 0) break;
1798
+ continue;
1799
+ }
1800
+ const start = textNode === range.startContainer ? range.startOffset : 0;
1801
+ const end = textNode === range.endContainer ? range.endOffset : textNode.length;
1802
+ if (start > end) continue;
1803
+ textNodeRange.setStart(textNode, start);
1804
+ textNodeRange.setEnd(textNode, end);
1805
+ const clientRects = textNodeRange.getClientRects();
1806
+ let previousTextNodeLine = 0;
1807
+ for (const rect of clientRects) {
1808
+ const centerY = rect.y + rect.height / 2;
1809
+ for (let i = previousTextNodeLine; i < lines.length; i++) {
1810
+ const lineRect = lines[i];
1811
+ // Skip rects that extend outside the line of text (such as absolutely-positioned elements).
1812
+ // The 1px tolerance fixes an issue on Android Chrome where range rect coordinates aren't exact (sub-pixel precision)
1813
+ if (rect.left >= lineRect.left - 1 && rect.right <= lineRect.right + 1
1814
+ && centerY > lineStates[i].topY && centerY <= lineRect.bottom) {
1815
+ const s = lineStates[i];
1816
+ s.left = Math.min(s.left, rect.left);
1817
+ s.right = Math.max(s.right, rect.right);
1818
+ previousTextNodeLine = i;
1819
+ break;
1820
+ }
1821
+ }
1822
+ }
1823
+ }
1824
+
1825
+ // Build a merged rect for each line
1826
+ for (let i = 0; i < lines.length; i++) {
1827
+ const s = lineStates[i];
1828
+ if (s.left === Infinity) continue;
1829
+ const lineRect = lines[i];
1830
+ const centerY = lineRect.top + lineRect.height / 2;
1831
+ s.left = this._snapToEdge(pseudoElemRects, s.left, 'left', centerY, lineRect.left, snapTolerance);
1832
+ s.right = this._snapToEdge(pseudoElemRects, s.right, 'right', centerY, lineRect.right, snapTolerance);
1833
+ const top = lineRect.top - this._additionsRect.top;
1834
+ const bottom = lineRect.bottom - this._additionsRect.top;
1835
+ const left = s.left - this._additionsRect.left;
1836
+ const right = s.right - this._additionsRect.left;
1837
+ rangeRectCoordinates.left = Math.min(left, rangeRectCoordinates.left);
1838
+ rangeRectCoordinates.right = Math.max(right, rangeRectCoordinates.right);
1839
+ rangeRectCoordinates.top = Math.min(top, rangeRectCoordinates.top);
1840
+ rangeRectCoordinates.bottom = Math.max(bottom, rangeRectCoordinates.bottom);
1841
+ rangeLineRects.push(new DOMRect(left, top, right - left, lineRect.height));
1842
+ if (!columnRect) {
1843
+ columnRect = _getColumnRect(paragraph, (s.left + s.right) / 2, columnGeometryCache, this._additionsRect);
1844
+ }
1845
+ }
1846
+
1847
+ if (lines.length > 0) prevParagraphBottom = lines[lines.length - 1].bottom;
1848
+ }
1849
+
1850
+ // In a multi-column layout, clip rangeRect to the start column bounds
1851
+ let rectTop = rangeRectCoordinates.top, rectBottom = rangeRectCoordinates.bottom;
1852
+ if (columnRect && rangeLineRects.length > 0) {
1853
+ let colTop = Infinity, colBottom = -Infinity;
1854
+ for (const lr of rangeLineRects) {
1855
+ const centerX = lr.left + lr.width / 2;
1856
+ if (centerX >= columnRect.left && centerX <= columnRect.right) {
1857
+ colTop = Math.min(colTop, lr.top);
1858
+ colBottom = Math.max(colBottom, lr.bottom);
1417
1859
  }
1418
1860
  }
1861
+ if (colTop !== Infinity) { rectTop = colTop; rectBottom = colBottom; }
1419
1862
  }
1420
1863
 
1421
- // Create a merged rect for each relevant line
1422
- for (let i = 0; i < lineBottoms.length; i++) {
1423
- const bottom = lineBottoms[i];
1424
- const pos = linePositions[bottom];
1425
- if (pos.highlightLeft == null) continue;
1426
- const nearbyBottom = lineBottoms[i - 1] ?? lineBottoms[i + 1] ?? (paragraphRect.top + topPadding);
1427
- const height = Math.abs(bottom - nearbyBottom);
1428
- const top = bottom - height;
1429
- let left = Math.max(pos.minLeft, pos.highlightLeft);
1430
- let right = Math.min(pos.maxRight, pos.highlightRight);
1864
+ const rangeRect = new DOMRect(rangeRectCoordinates.left, rectTop, rangeRectCoordinates.right - rangeRectCoordinates.left, rectBottom - rectTop);
1865
+ rangeRect.columnRect = columnRect;
1866
+ return [rangeRect, rangeLineRects];
1867
+ }
1868
+
1869
+ // Find the left and right bounds of the nearest multi-column ancestor (or fall back to the nearest block-level ancestor)
1870
+ function _getColumnRect(paragraph, probeX, columnGeometryCache = new Map(), additionsRect = null) {
1871
+ let el = paragraph.parentElement;
1872
+ let nearestMultiColumnBlock, nearestBlock, geometry;
1431
1873
 
1432
- // Final adjustment to left and right bounds
1433
- if (paragraphStyle.direction === 'ltr' && paragraphStyle.textAlign === 'start') {
1434
- if (left - paragraphRect.left < snapTolerance) left = paragraphRect.left;
1435
- if (textIndent < 0) left += textIndent;
1436
- } else if (paragraphStyle.direction === 'rtl' && paragraphStyle.textAlign === 'start') {
1437
- if (paragraphRect.right - right < snapTolerance) right = paragraphRect.right;
1438
- if (textIndent < 0) right += textIndent;
1874
+ while (el) {
1875
+ if (columnGeometryCache.has(el)) {
1876
+ geometry = columnGeometryCache.get(el);
1877
+ break;
1878
+ }
1879
+ const style = getComputedStyle(el);
1880
+ if (!nearestBlock && !style.display.startsWith('inline')) nearestBlock = el;
1881
+ if (!nearestMultiColumnBlock && (style.columnWidth !== 'auto' || (style.columnCount !== 'auto' && parseInt(style.columnCount) > 1))) { nearestMultiColumnBlock = el; break; }
1882
+ if (el.hasAttribute('data-hh-container')) break;
1883
+ el = el.parentElement;
1884
+ }
1885
+
1886
+ el = nearestMultiColumnBlock ?? nearestBlock ?? el;
1887
+ if (!geometry) {
1888
+ const rect = el.getBoundingClientRect();
1889
+ const style = getComputedStyle(el);
1890
+ const insetLeft = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.borderLeftWidth) || 0);
1891
+ const insetRight = (parseFloat(style.paddingRight) || 0) + (parseFloat(style.borderRightWidth) || 0);
1892
+ const insetTop = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.borderTopWidth) || 0);
1893
+ const insetBottom = (parseFloat(style.paddingBottom) || 0) + (parseFloat(style.borderBottomWidth) || 0);
1894
+ const contentLeft = rect.left + insetLeft;
1895
+ const contentWidth = rect.width - insetLeft - insetRight;
1896
+ const contentTop = rect.top + insetTop;
1897
+ const contentHeight = rect.height - insetTop - insetBottom;
1898
+ const gap = parseFloat(style.columnGap) || 0;
1899
+ let cols = parseInt(style.columnCount);
1900
+ if (isNaN(cols) || cols < 1) {
1901
+ const cw = parseFloat(style.columnWidth);
1902
+ cols = (!isNaN(cw) && cw > 0) ? Math.max(1, Math.floor((contentWidth + gap) / (cw + gap))) : 1;
1439
1903
  }
1440
1904
 
1441
- const width = right - left;
1442
- mergedRects.push(new DOMRect(left, top, width, height));
1905
+ geometry = {
1906
+ contentLeft, contentTop, contentHeight, cols, gap,
1907
+ colWidth: (contentWidth - (cols - 1) * gap) / cols
1908
+ };
1909
+ columnGeometryCache.set(el, geometry);
1443
1910
  }
1911
+
1912
+ const { contentLeft, contentTop, contentHeight, cols, colWidth, gap } = geometry;
1913
+ const unit = colWidth + gap;
1914
+ const idx = Math.max(0, Math.min(cols - 1, Math.floor((probeX - contentLeft) / unit)));
1915
+ const left = contentLeft + idx * unit;
1916
+ return new DOMRect(left - (additionsRect?.left ?? 0), contentTop - (additionsRect?.top ?? 0), colWidth, contentHeight);
1444
1917
  }
1445
1918
 
1446
- return mergedRects;
1447
- }
1919
+ function _gatherPseudoElemRects(paragraph) {
1920
+ const hasPseudoContent = (elem, pseudo) => { const c = getComputedStyle(elem, pseudo).content; return c !== 'none' && c !== 'normal'; };
1921
+ const rects = [];
1922
+ for (const el of paragraph.querySelectorAll(':empty:not([data-hh-ignore]):not([data-hh-ignore] *)')) {
1923
+ if (!hasPseudoContent(el, '::before') && !hasPseudoContent(el, '::after')) continue;
1924
+ const rect = el.getBoundingClientRect();
1925
+ if (rect.width > 0 && rect.height > 0) rects.push(rect);
1926
+ }
1927
+ return rects;
1928
+ }
1929
+
1930
+ Highlighter.prototype._snapToEdge = function (pseudoElemRects, initialValue, side, centerY, snapValue, snapTolerance = null) {
1931
+ const snap = (v) => snapTolerance !== null && Math.abs(snapValue - v) < snapTolerance ? snapValue : v;
1932
+ let edge = initialValue;
1933
+ const probeX = side === 'left' ? edge - 1 : edge + 1;
1934
+ const pseudoRect = pseudoElemRects.find(r => probeX >= r.left && probeX <= r.right && centerY >= r.top && centerY <= r.bottom);
1935
+ if (pseudoRect) {
1936
+ edge = snap(side === 'left' ? Math.min(edge, pseudoRect.left) : Math.max(edge, pseudoRect.right));
1937
+ }
1938
+ return edge;
1939
+ };
1940
+
1941
+ // Get the HTML and text content of a range
1942
+ Highlighter.prototype._getRangeContent = function (range, unwrapHighlights = false, unwrapHyperlinks = false) {
1943
+ const tempDiv = document.createElement('div');
1944
+ tempDiv.appendChild(range.cloneContents());
1945
+ const unwrapSelectors = [];
1946
+ if (unwrapHighlights) unwrapSelectors.push('[data-hh-highlight-id]');
1947
+ if (unwrapHyperlinks) unwrapSelectors.push('a');
1948
+ for (const element of tempDiv.querySelectorAll(unwrapSelectors.join(', '))) {
1949
+ while (element.firstChild) element.before(element.firstChild);
1950
+ element.remove();
1951
+ }
1952
+ const removeSelectors = ['[data-hh-ignore]', '.sr-only'];
1953
+ for (const element of tempDiv.querySelectorAll(removeSelectors.join(','))) {
1954
+ element.remove();
1955
+ }
1956
+ return { rangeHtml: tempDiv.innerHTML, rangeText: tempDiv.textContent };
1957
+ };
1448
1958
 
1449
1959
 
1450
- /********************** Utilities **********************/
1960
+ /********************** Utilities **********************/
1451
1961
 
1452
- // Add a CSS stylesheet to the document
1453
- function addStylesheet(stylesheets, stylesheetKey) {
1454
- let stylesheet = stylesheets[stylesheetKey];
1455
- if (!stylesheet) {
1456
- if (supportsCssStylesheetApi) {
1457
- stylesheet = new CSSStyleSheet();
1458
- document.adoptedStyleSheets.push(stylesheet);
1459
- } else {
1460
- // For browsers that don't fully support the CSSStyleSheet API, such as Safari < 16.4.
1461
- // See https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#browser_compatibility
1462
- stylesheet = document.createElement('style');
1463
- stylesheet.appendChild(document.createTextNode(''));
1464
- stylesheet.replaceSync = (newContent) => {
1465
- stylesheet.textContent = newContent;
1466
- }
1467
- stylesheet.insertRule = (newContent) => {
1468
- stylesheet.textContent += newContent;
1962
+ // Add a CSS stylesheet to the document
1963
+ function _addStylesheet(stylesheets, stylesheetKey) {
1964
+ let stylesheet = stylesheets[stylesheetKey];
1965
+ if (!stylesheet) {
1966
+ if (supportsCssStylesheetApi) {
1967
+ stylesheet = new CSSStyleSheet();
1968
+ document.adoptedStyleSheets.push(stylesheet);
1969
+ } else {
1970
+ // For browsers that don't fully support the CSSStyleSheet API, such as Safari < 16.4.
1971
+ // See https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#browser_compatibility
1972
+ stylesheet = document.createElement('style');
1973
+ stylesheet.replaceSync = (newContent) => {
1974
+ stylesheet.textContent = newContent;
1975
+ }
1976
+ stylesheet.insertRule = (newContent) => {
1977
+ stylesheet.textContent += newContent;
1978
+ }
1979
+ document.head.appendChild(stylesheet);
1469
1980
  }
1470
- document.head.appendChild(stylesheet);
1981
+ stylesheets[stylesheetKey] = stylesheet;
1471
1982
  }
1472
- stylesheets[stylesheetKey] = stylesheet;
1983
+ return stylesheet;
1473
1984
  }
1474
- return stylesheet;
1475
- }
1476
1985
 
1477
- // Remove CSS stylesheets (only those from the current Highlighter instance)
1478
- function removeStylesheets(stylesheets) {
1479
- for (const stylesheet of Object.values(stylesheets)) {
1986
+ // Remove CSS stylesheets (only those from the current Highlighter instance)
1987
+ function _removeStylesheets(stylesheets) {
1988
+ const sheets = Object.values(stylesheets);
1480
1989
  if (supportsCssStylesheetApi) {
1481
- const adoptedStylesheetIndex = document.adoptedStyleSheets.indexOf(stylesheet);
1482
- document.adoptedStyleSheets.splice(adoptedStylesheetIndex, 1);
1990
+ const sheetsSet = new Set(sheets);
1991
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(s => !sheetsSet.has(s));
1483
1992
  } else {
1484
- stylesheet.remove();
1993
+ for (const stylesheet of sheets) stylesheet.remove();
1485
1994
  }
1486
1995
  }
1487
- }
1488
-
1489
- // Debounce a function to prevent it from being executed too frequently
1490
- // Adapted from https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086
1491
- function debounce(func, wait) {
1492
- let timeout;
1493
- return function executedFunction(...args) {
1494
- const later = () => {
1996
+
1997
+ // Debounce a function to prevent it from being executed too frequently
1998
+ // Adapted from https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086
1999
+ function _debounce(func, wait) {
2000
+ let timeout;
2001
+ return function executedFunction(...args) {
2002
+ const later = () => {
2003
+ clearTimeout(timeout);
2004
+ func(...args);
2005
+ };
1495
2006
  clearTimeout(timeout);
1496
- func(...args);
2007
+ timeout = setTimeout(later, wait);
1497
2008
  };
1498
- clearTimeout(timeout);
1499
- timeout = setTimeout(later, wait);
1500
- };
1501
- }
1502
-
1503
- // Default function for generating a highlight ID
1504
- const _getNewHighlightId = () => {
1505
- return 'hh-' + Date.now().toString();
1506
- }
1507
-
1508
-
1509
- /********************** Constants **********************/
1510
-
1511
- // Keep track of all Highlighter instances
1512
- let _highlighters = [];
1513
-
1514
- // Check browser type
1515
- const isTouchDevice = navigator.maxTouchPoints > 0;
1516
- const isWebKit = /^((?!Chrome|Firefox|Android|Samsung).)*AppleWebKit/i.test(navigator.userAgent);
1517
- const isWKWebView = isWebKit && globalThis.webkit?.messageHandlers;
1518
- const supportsCaretPositionFromPoint = document.caretPositionFromPoint;
1519
- const supportsCaretRangeFromPoint = document.caretRangeFromPoint;
1520
- const supportsCssStylesheetApi = CSSStyleSheet?.prototype?.replaceSync;
1521
- const supportsHighlightApi = CSS.highlights;
1522
-
1523
- // Regex patterns for word boundary detection
1524
- const _reWhitespaceDash = /\s|\p{Pd}/u;
1525
- const _reNonWhitespaceDash = /[^\s|\p{Pd}]/u;
1526
-
1527
- // Default options
1528
- const _defaultOptions = {
1529
- containerSelector: 'body',
1530
- paragraphSelector: 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id], p[id], ol[id], ul[id], dl[id], tr[id]',
1531
- colors: {
1532
- 'red': 'hsl(360, 100%, 70%)',
1533
- 'orange': 'hsl(30, 100%, 60%)',
1534
- 'yellow': 'hsl(50, 100%, 60%)',
1535
- 'green': 'hsl(80, 100%, 45%)',
1536
- 'blue': 'hsl(180, 100%, 45%)',
1537
- },
1538
- styles: {
1539
- 'fill': {
1540
- 'css': 'background-color: hsl(from var(--hh-color) h s l / 50%);',
1541
- 'svg': '<rect x="{x}" y="{y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 50%); width: calc({width}px + ({height}px / 6)); height: calc({height}px * 0.85); transform: translateX(calc({height}px / -12)) translateY(calc({height}px * 0.14));" />',
1542
- 'css-active': 'background-color: hsl(from var(--hh-color) h s l / 80%);',
1543
- 'svg-active': `
1544
- <rect x="{x}" y="{y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 80%); width: calc({width}px + ({height}px / 6)); height: calc({height}px * 0.85); transform: translateX(calc({height}px / -12)) translateY(calc({height}px * 0.14));" />
1545
- `,
1546
- },
1547
- 'single-underline': {
1548
- 'css': 'text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-thickness: 0.15em; text-underline-offset: 0.15em; text-decoration-skip-ink: none;',
1549
- 'svg': '<rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 12); transform: translateY(calc({height}px * 0.9));" />',
1550
- 'css-active': 'background-color: hsl(from var(--hh-color) h s l / 25%); text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-thickness: 0.15em; text-underline-offset: 0.15em; text-decoration-skip-ink: none;',
1551
- 'svg-active': `
1552
- <rect x="{x}" y="{y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 25%); width: calc({width}px + ({height}px / 6)); height: calc({height}px * 0.85); transform: translateX(calc({height}px / -12)) translateY(calc({height}px * 0.14));" />
1553
- <rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 12); transform: translateY(calc({height}px * 0.9));" />
1554
- `,
2009
+ }
2010
+
2011
+ // Default function for generating a highlight ID
2012
+ function _getNewHighlightId() {
2013
+ return 'hh-' + Date.now().toString();
2014
+ }
2015
+
2016
+ // Check if two objects are roughly the same
2017
+ function _shallowEqual(a, b) {
2018
+ const keysA = Object.keys(a);
2019
+ return keysA.length === Object.keys(b).length && keysA.every(k => a[k] == b[k]);
2020
+ }
2021
+
2022
+ // Check if two rects are roughly the same
2023
+ function _rectsEqual(rect1, rect2, epsilon = 0.001) {
2024
+ return Math.abs(rect1.x - rect2.x) < epsilon &&
2025
+ Math.abs(rect1.y - rect2.y) < epsilon &&
2026
+ Math.abs(rect1.width - rect2.width) < epsilon &&
2027
+ Math.abs(rect1.height - rect2.height) < epsilon;
2028
+ }
2029
+
2030
+ // Check if two ranges are roughly the same
2031
+ function _rangesEqual(range1, range2) {
2032
+ return range1 && range2 && range1.compareBoundaryPoints(Range.START_TO_START, range2) === 0 && range1.compareBoundaryPoints(Range.END_TO_END, range2) === 0;
2033
+ }
2034
+
2035
+ // Check if a point is in a DOMRect
2036
+ function _isPointInRect(x, y, rect, padding = 0) {
2037
+ return (
2038
+ x >= rect.x - padding && x <= rect.x + rect.width + padding &&
2039
+ y >= rect.y - padding && y <= rect.y + rect.height + padding
2040
+ );
2041
+ }
2042
+
2043
+
2044
+ /********************** Constants **********************/
2045
+
2046
+ // Check browser type
2047
+ const isTouchDevice = navigator.maxTouchPoints > 0;
2048
+ const isWebKit = /^((?!Chrome|Firefox|Android|Samsung).)*AppleWebKit/i.test(navigator.userAgent);
2049
+ const isWKWebView = isWebKit && globalThis.webkit?.messageHandlers;
2050
+ const supportsCaretPositionFromPoint = document.caretPositionFromPoint;
2051
+ const supportsCaretRangeFromPoint = document.caretRangeFromPoint;
2052
+ const supportsCssStylesheetApi = CSSStyleSheet?.prototype?.replaceSync;
2053
+ const supportsHighlightApi = CSS.highlights;
2054
+ const supportsAccentColor = CSS.supports('color', 'AccentColor');
2055
+
2056
+ // Regex patterns for word boundary detection
2057
+ const _reWhitespaceDash = /\s|\p{Pd}/u;
2058
+ const _reNonWhitespaceDash = /[^\s|\p{Pd}]/u;
2059
+
2060
+ // Workaround to allow programmatic text selection on tap in iOS Safari
2061
+ // See https://stackoverflow.com/a/79261423/1349044
2062
+ if (isTouchDevice && isWebKit) {
2063
+ const tempInput = document.createElement('input');
2064
+ tempInput.style.position = 'fixed';
2065
+ tempInput.style.top = 0;
2066
+ tempInput.style.opacity = 0;
2067
+ tempInput.style.height = 0;
2068
+ tempInput.style.fontSize = '16px'; // Prevent page zoom on input focus
2069
+ tempInput.inputMode = 'none'; // Don't show keyboard
2070
+ tempInput.tabIndex = -1; // Prevent user from tabbing to input
2071
+ tempInput.ariaHidden = 'true'; // Hide from screen readers
2072
+ const initializeSelection = (event) => {
2073
+ if (document.readyState !== 'complete') return setTimeout(initializeSelection, 20);
2074
+ if (!tempInput.parentElement) document.body.append(tempInput);
2075
+ tempInput.focus();
2076
+ setTimeout(() => {
2077
+ tempInput.blur();
2078
+ }, 100);
2079
+ }
2080
+ initializeSelection();
2081
+ document.addEventListener('visibilitychange', (event) => {
2082
+ if (document.visibilityState === 'visible') initializeSelection();
2083
+ });
2084
+ }
2085
+
2086
+ // Default options
2087
+ const _defaultColor = 'yellow';
2088
+ const _defaultStyle = 'fill';
2089
+ const _defaultOptions = {
2090
+ drawingMode: 'svg',
2091
+ snapToWord: false,
2092
+ longPressTimeout: 500,
2093
+ enableHover: false,
2094
+ dragHandles: {
2095
+ left: '<div data-hh-default-handle=""></div>',
2096
+ right: '<div data-hh-default-handle=""></div>',
1555
2097
  },
1556
- 'double-underline': {
1557
- 'css': 'text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-style: double; text-decoration-skip-ink: none;',
1558
- 'svg': '<rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 15); transform: translateY(calc({height}px * 0.9));" /><rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 15); transform: translateY(calc({height}px * 1.05));" />',
1559
- 'css-active': 'background-color: hsl(from var(--hh-color) h s l / 25%); text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-style: double; text-decoration-skip-ink: none;',
1560
- 'svg-active': `
1561
- <rect x="{x}" y="{y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 25%); width: calc({width}px + ({height}px / 6)); height: calc({height}px * 0.85); transform: translateX(calc({height}px / -12)) translateY(calc({height}px * 0.14));" />
1562
- <rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 15); transform: translateY(calc({height}px * 0.9));" />
1563
- <rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 15); transform: translateY(calc({height}px * 1.05));" />
1564
- `,
2098
+ showDragHandles: ['highlights'],
2099
+ colorDefs: {
2100
+ 'red': 'hsl(360, 100%, 70%)',
2101
+ 'orange': 'hsl(30, 100%, 60%)',
2102
+ 'yellow': 'hsl(50, 100%, 60%)',
2103
+ 'green': 'hsl(80, 100%, 45%)',
2104
+ 'blue': 'hsl(180, 100%, 45%)',
1565
2105
  },
1566
- 'colored-text': {
1567
- 'css': 'color: var(--hh-color);',
1568
- 'svg': '',
2106
+ styleDefs: {
2107
+ 'fill': {
2108
+ css: 'background-color: hsl(from var(--hh-color) h s l / 50%);',
2109
+ cssActive: 'background-color: hsl(from var(--hh-color) h s l / 80%);',
2110
+ svg: '<rect x="{line.x}" y="{line.y}" style="fill: hsl(from var(--hh-color) h s l / 50%); width: {line.width}px; height: calc({line.height}px * 0.85); transform: translateY(calc({line.height}px * 0.15));" />',
2111
+ svgActive: `
2112
+ <rect x="{line.x}" y="{line.y}" style="fill: hsl(from var(--hh-color) h s l / 80%); width: {line.width}px; height: calc({line.height}px * 0.85); transform: translateY(calc({line.height}px * 0.15));" />
2113
+ `,
2114
+ },
2115
+ 'underline': {
2116
+ css: 'text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-thickness: 0.15em; text-underline-offset: 0.15em; text-decoration-skip-ink: none;',
2117
+ cssActive: 'background-color: hsl(from var(--hh-color) h s l / 25%); text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-thickness: 0.15em; text-underline-offset: 0.15em; text-decoration-skip-ink: none;',
2118
+ svg: '<rect x="{line.x}" y="{line.y}" style="fill: var(--hh-color); width: {line.width}px; height: calc({line.height}px / 12); transform: translateY(calc({line.height}px * 0.95));" />',
2119
+ svgActive: `
2120
+ <rect x="{line.x}" y="{line.y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 25%); width: calc({line.width}px + ({line.height}px / 6)); height: calc({line.height}px * 0.85); transform: translateX(calc({line.height}px / -12)) translateY(calc({line.height}px * 0.15));" />
2121
+ <rect x="{line.x}" y="{line.y}" style="fill: var(--hh-color); width: {line.width}px; height: calc({line.height}px / 12); transform: translateY(calc({line.height}px * 0.95));" />
2122
+ `,
2123
+ },
1569
2124
  },
1570
- 'redacted': {
1571
- 'css': 'background-color: transparent; color: transparent;',
1572
- 'svg': '',
2125
+ wrapperDefs: {
2126
+ 'screen-reader-label': {
2127
+ start: '<span class="sr-only">{startLabel} </span>',
2128
+ end: '<span class="sr-only"> {endLabel}</span>',
2129
+ },
1573
2130
  },
1574
- },
1575
- wrappers: {},
1576
- selectionHandles: {
1577
- 'left': '<div class="hh-default-handle"></div>',
1578
- 'right': '<div class="hh-default-handle"></div>',
1579
- },
1580
- rememberStyle: true,
1581
- snapToWord: false,
1582
- autoTapToActivate: true,
1583
- longPressTimeout: 500,
1584
- pointerMode: 'auto',
1585
- drawingMode: 'svg',
1586
- defaultColor: 'yellow',
1587
- defaultStyle: 'fill',
1588
- defaultWrapper: 'none',
1589
- highlightIdFunction: _getNewHighlightId,
1590
- showCustomSelectionHandlesForActiveHighlights: true,
1591
- showCustomSelectionHandlesForTextSelection: false,
1592
- showCustomSelectionHandlesOnTouch: false,
1593
- }
1594
-
1595
- // Workaround to allow programmatic text selection on tap in iOS Safari
1596
- // See https://stackoverflow.com/a/79261423/1349044
1597
- if (isTouchDevice && isWebKit) {
1598
- const tempInput = document.createElement('input');
1599
- tempInput.style.position = 'fixed';
1600
- tempInput.style.top = 0;
1601
- tempInput.style.opacity = 0;
1602
- tempInput.style.height = 0;
1603
- tempInput.style.fontSize = '16px'; // Prevent page zoom on input focus
1604
- tempInput.inputMode = 'none'; // Don't show keyboard
1605
- tempInput.tabIndex = -1; // Prevent user from tabbing to input
1606
- tempInput.ariaHidden = 'true'; // Hide from screen readers
1607
- const initializeSelection = (event) => {
1608
- if (document.readyState !== 'complete') return setTimeout(initializeSelection, 20);
1609
- if (!tempInput.parentElement) document.body.append(tempInput);
1610
- tempInput.focus();
1611
- setTimeout(() => {
1612
- tempInput.blur();
1613
- }, 100);
1614
2131
  }
1615
- initializeSelection();
1616
- document.addEventListener('visibilitychange', (event) => {
1617
- if (document.visibilityState === 'visible') initializeSelection();
1618
- });
1619
- }
1620
-
1621
- // Make Highlighter available to JavaScript module (highlight-helper.mjs)
1622
- globalThis.Highlighter = Highlighter;
2132
+
2133
+ // Make Highlighter available to JavaScript module (highlight-helper.mjs)
2134
+ globalThis.Highlighter = Highlighter;
2135
+
2136
+ })();