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