@samuelbradshaw/highlight-helper-js 0.9.0 → 1.0.0

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