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