@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.
- package/README.md +563 -101
- package/demo.html +899 -372
- package/highlight-helper.js +1891 -1408
- package/highlight-helper.mjs +1 -1
- package/package.json +1 -1
- package/test-load.html +252 -216
package/highlight-helper.js
CHANGED
|
@@ -3,1620 +3,2103 @@
|
|
|
3
3
|
* https://github.com/samuelbradshaw/highlight-helper-js
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
7
8
|
|
|
8
|
-
/********************** Highlighter Initialization **********************/
|
|
9
|
+
/********************** Highlighter Initialization **********************/
|
|
9
10
|
|
|
10
|
-
function Highlighter(
|
|
11
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
//
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
//
|
|
293
|
-
|
|
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
|
-
//
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
//
|
|
305
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
//
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
367
|
-
}
|
|
368
|
-
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
369
520
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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.
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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 (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
if (this._activeHighlightId && highlightId !== this._activeHighlightId && triggeredByUserAction) {
|
|
579
|
-
this.deactivateHighlights();
|
|
853
|
+
this._rebuildHighlightStylesheet();
|
|
580
854
|
}
|
|
581
855
|
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
908
|
+
// Get the active highlight ID (if there is one)
|
|
909
|
+
Highlighter.prototype.getActiveHighlightId = function () {
|
|
910
|
+
return this._activeHighlightId;
|
|
611
911
|
}
|
|
612
912
|
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
//
|
|
705
|
-
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
const
|
|
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.
|
|
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
|
-
//
|
|
790
|
-
Highlighter.prototype.
|
|
791
|
-
|
|
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
|
-
|
|
795
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
874
|
-
Highlighter.prototype.
|
|
875
|
-
|
|
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
|
-
|
|
878
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
//
|
|
923
|
-
Highlighter.prototype.
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
//
|
|
935
|
-
Highlighter.prototype.
|
|
936
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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.
|
|
1296
|
+
this._setDragHandleVisibility(false);
|
|
1087
1297
|
}
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
1298
|
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
//
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
.
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
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
|
|
1378
|
-
const
|
|
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
|
-
|
|
1393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
-
|
|
1433
|
-
if (
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
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
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
-
|
|
1950
|
+
stylesheets[stylesheetKey] = stylesheet;
|
|
1471
1951
|
}
|
|
1472
|
-
|
|
1952
|
+
return stylesheet;
|
|
1473
1953
|
}
|
|
1474
|
-
return stylesheet;
|
|
1475
|
-
}
|
|
1476
1954
|
|
|
1477
|
-
// Remove CSS stylesheets (only those from the current Highlighter instance)
|
|
1478
|
-
function
|
|
1479
|
-
|
|
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
|
|
1482
|
-
document.adoptedStyleSheets.
|
|
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
|
-
//
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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
|
-
|
|
1976
|
+
timeout = setTimeout(later, wait);
|
|
1497
1977
|
};
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
//
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
-
'
|
|
1557
|
-
|
|
1558
|
-
'
|
|
1559
|
-
'
|
|
1560
|
-
'
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
-
|
|
1567
|
-
'
|
|
1568
|
-
|
|
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
|
-
|
|
1571
|
-
'
|
|
1572
|
-
|
|
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
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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
|
+
})();
|