@samuelbradshaw/highlight-helper-js 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +22 -0
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/demo.html +365 -0
- package/highlight-helper.js +1449 -0
- package/package.json +26 -0
- package/test-load.html +224 -0
|
@@ -0,0 +1,1449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Highlight Helper
|
|
3
|
+
* https://github.com/samuelbradshaw/highlight-helper-js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function Highlighter(options = hhDefaultOptions) {
|
|
7
|
+
for (const key of Object.keys(hhDefaultOptions)) {
|
|
8
|
+
options[key] = options[key] ?? hhDefaultOptions[key];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
this.annotatableContainer, this.relativeAncestorElement, this.annotatableParagraphs, this.stylesheets;
|
|
12
|
+
let generalStylesheet, appearanceStylesheet, highlightApiStylesheet, selectionStylesheet;
|
|
13
|
+
let annotatableParagraphIds, hyperlinkElements;
|
|
14
|
+
let svgBackground, svgActiveOverlay, selectionHandles;
|
|
15
|
+
let highlightsById, hyperlinksByPosition;
|
|
16
|
+
let controller;
|
|
17
|
+
|
|
18
|
+
const initializeHighlighter = (previousContainerSelector = null) => {
|
|
19
|
+
if (!options.paragraphSelector.includes(options.containerSelector)) {
|
|
20
|
+
const paragraphSelectorList = options.paragraphSelector.split(',').map(selector => `${options.containerSelector} ${selector}`);
|
|
21
|
+
options.paragraphSelector = paragraphSelectorList.join(',');
|
|
22
|
+
}
|
|
23
|
+
this.annotatableContainer = document.querySelector(options.containerSelector);
|
|
24
|
+
this.annotatableParagraphs = this.annotatableContainer.querySelectorAll(options.paragraphSelector);
|
|
25
|
+
annotatableParagraphIds = Array.from(this.annotatableParagraphs, paragraph => paragraph.id);
|
|
26
|
+
|
|
27
|
+
// Handle cases where a highlighter already exists for the container, or one of its children or ancestors
|
|
28
|
+
const previousContainer = document.querySelector(previousContainerSelector) ?? this.annotatableContainer;
|
|
29
|
+
if (previousContainer.highlighter) {
|
|
30
|
+
previousContainer.highlighter.removeHighlighter();
|
|
31
|
+
} else if (this.annotatableContainer.closest('[data-hh-container]') || this.annotatableContainer.querySelector('[data-hh-container]')) {
|
|
32
|
+
console.error(`Unable to create Highlighter with container selector “${options.containerSelector}” (annotatable container can’t be an child or ancestor of another annotatable container).`);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get the closest ancestor element with `position: relative`, or the root element (this is used to calculate the position of selection handles and SVG highlights)
|
|
37
|
+
let ancestorElement = this.annotatableContainer;
|
|
38
|
+
while (ancestorElement) {
|
|
39
|
+
if (ancestorElement === document.documentElement || window.getComputedStyle(ancestorElement).position === 'relative') {
|
|
40
|
+
this.relativeAncestorElement = ancestorElement;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
ancestorElement = ancestorElement.parentElement;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Abort controller can be used to cancel event listeners if the highlighter is removed
|
|
47
|
+
controller = new AbortController;
|
|
48
|
+
|
|
49
|
+
// 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.
|
|
50
|
+
document.body.tabIndex = -1;
|
|
51
|
+
|
|
52
|
+
// Set up stylesheets
|
|
53
|
+
this.stylesheets = {}
|
|
54
|
+
generalStylesheet = addStylesheet(this.stylesheets, 'general');
|
|
55
|
+
appearanceStylesheet = addStylesheet(this.stylesheets, 'appearance');
|
|
56
|
+
highlightApiStylesheet = addStylesheet(this.stylesheets, 'highlight-api');
|
|
57
|
+
selectionStylesheet = addStylesheet(this.stylesheets, 'selection');
|
|
58
|
+
generalStylesheet.replaceSync(`
|
|
59
|
+
${options.containerSelector} {
|
|
60
|
+
-webkit-tap-highlight-color: transparent;
|
|
61
|
+
}
|
|
62
|
+
.hh-wrapper-start, .hh-wrapper-end, .hh-selection-handle {
|
|
63
|
+
-webkit-user-select: none;
|
|
64
|
+
user-select: none;
|
|
65
|
+
}
|
|
66
|
+
.hh-selection-handle {
|
|
67
|
+
position: absolute;
|
|
68
|
+
width: 0px;
|
|
69
|
+
visibility: hidden;
|
|
70
|
+
}
|
|
71
|
+
.hh-selection-handle-content {
|
|
72
|
+
position: absolute;
|
|
73
|
+
height: 100%;
|
|
74
|
+
}
|
|
75
|
+
.hh-selection-handle [draggable] {
|
|
76
|
+
position: absolute;
|
|
77
|
+
top: -0.3em;
|
|
78
|
+
width: 3.2em;
|
|
79
|
+
height: calc(100% + 1.3em);
|
|
80
|
+
background-color: transparent;
|
|
81
|
+
z-index: 1;
|
|
82
|
+
}
|
|
83
|
+
.hh-selection-handle[data-side="left"] [draggable] { right: -2em; }
|
|
84
|
+
.hh-selection-handle[data-side="right"] [draggable] { left: -2em; }
|
|
85
|
+
.hh-default-handle {
|
|
86
|
+
position: absolute;
|
|
87
|
+
width: 0.8em;
|
|
88
|
+
height: min(1.2em, 100%);
|
|
89
|
+
background-color: hsl(from var(--hh-color) h 80% 40% / 1);
|
|
90
|
+
outline: 0.1em solid hsla(0, 0%, 100%, 0.8);
|
|
91
|
+
outline-offset: -0.05em;
|
|
92
|
+
bottom: -0.2em;
|
|
93
|
+
}
|
|
94
|
+
.hh-selection-handle[data-side="left"] .hh-default-handle {
|
|
95
|
+
right: 0;
|
|
96
|
+
border-radius: 1em 0 0.6em 0.6em;
|
|
97
|
+
}
|
|
98
|
+
.hh-selection-handle[data-side="right"] .hh-default-handle {
|
|
99
|
+
left: 0;
|
|
100
|
+
border-radius: 0 1em 0.6em 0.6em;
|
|
101
|
+
}
|
|
102
|
+
.hh-svg-background {
|
|
103
|
+
position: absolute;
|
|
104
|
+
top: 0;
|
|
105
|
+
left: 0;
|
|
106
|
+
width: 100%;
|
|
107
|
+
overflow: visible;
|
|
108
|
+
z-index: -1;
|
|
109
|
+
}
|
|
110
|
+
.hh-svg-background g {
|
|
111
|
+
fill: transparent;
|
|
112
|
+
stroke: none;
|
|
113
|
+
}
|
|
114
|
+
mark[data-highlight-id] {
|
|
115
|
+
background-color: transparent;
|
|
116
|
+
color: inherit;
|
|
117
|
+
}
|
|
118
|
+
mark[data-highlight-id][data-style="fill"][data-start] {
|
|
119
|
+
border-top-left-radius: 0.25em;
|
|
120
|
+
border-bottom-left-radius: 0.25em;
|
|
121
|
+
margin-left: -0.13em; padding-left: 0.13em;
|
|
122
|
+
}
|
|
123
|
+
mark[data-highlight-id][data-style="fill"][data-end] {
|
|
124
|
+
border-top-right-radius: 0.25em;
|
|
125
|
+
border-bottom-right-radius: 0.25em;
|
|
126
|
+
margin-right: -0.13em; padding-right: 0.13em;
|
|
127
|
+
}
|
|
128
|
+
mark[data-highlight-id][data-style="fill"] + mark[data-highlight-id][data-style="fill"] {
|
|
129
|
+
margin-left: 0; padding-left: 0;
|
|
130
|
+
}
|
|
131
|
+
`);
|
|
132
|
+
|
|
133
|
+
// Set up SVG background and selection handles
|
|
134
|
+
svgBackground = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
135
|
+
svgActiveOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
136
|
+
svgActiveOverlay.dataset.activeOverlay = '';
|
|
137
|
+
svgBackground.appendChild(svgActiveOverlay);
|
|
138
|
+
svgBackground.classList.add('hh-svg-background');
|
|
139
|
+
this.annotatableContainer.appendChild(svgBackground);
|
|
140
|
+
this.annotatableContainer.insertAdjacentHTML('beforeend', `
|
|
141
|
+
<div class="hh-selection-handle" data-side="left" data-position="start"><div draggable="true"></div><div class="hh-selection-handle-content"></div></div>
|
|
142
|
+
<div class="hh-selection-handle" data-side="right" data-position="end"><div draggable="true"></div><div class="hh-selection-handle-content"></div></div>
|
|
143
|
+
`);
|
|
144
|
+
selectionHandles = this.annotatableContainer.getElementsByClassName('hh-selection-handle');
|
|
145
|
+
|
|
146
|
+
// Check for hyperlinks on the page
|
|
147
|
+
hyperlinkElements = this.annotatableContainer.getElementsByTagName('a');
|
|
148
|
+
hyperlinksByPosition = {}
|
|
149
|
+
for (let hyp = 0; hyp < hyperlinkElements.length; hyp++) {
|
|
150
|
+
hyperlinksByPosition[hyp] = {
|
|
151
|
+
'position': hyp,
|
|
152
|
+
'text': hyperlinkElements[hyp].innerHTML,
|
|
153
|
+
'url': hyperlinkElements[hyp].href,
|
|
154
|
+
'hyperlinkElement': hyperlinkElements[hyp],
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
highlightsById = {};
|
|
159
|
+
this.annotatableContainer.dataset.hhContainer = '';
|
|
160
|
+
this.annotatableContainer.highlighter = this;
|
|
161
|
+
hhHighlighters.push(this);
|
|
162
|
+
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const isInitialized = initializeHighlighter();
|
|
167
|
+
if (!isInitialized) return;
|
|
168
|
+
|
|
169
|
+
let activeHighlightId, previousSelectionRange, activeSelectionHandle, dragAnchorNode, dragAnchorOffset, pointerType, tapResult, doubleTapTimeoutId, longPressTimeoutId;
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
// -------- PUBLIC METHODS --------
|
|
173
|
+
|
|
174
|
+
// Load highlights
|
|
175
|
+
this.loadHighlights = (highlights) => {
|
|
176
|
+
// Don't load highlights until the document is ready (otherwise, highlights may be offset)
|
|
177
|
+
if (document.readyState !== 'complete') return setTimeout(this.loadHighlights, 10, highlights);
|
|
178
|
+
const startTimestamp = Date.now();
|
|
179
|
+
|
|
180
|
+
// Hide container (repeated DOM manipulations are faster if the container is hidden)
|
|
181
|
+
if (highlights.length > 1) (options.drawingMode === 'svg' ? svgBackground : this.annotatableContainer).style.display = 'none';
|
|
182
|
+
|
|
183
|
+
// Load read-only highlights first (read-only highlights change the DOM, affecting other highlights' ranges)
|
|
184
|
+
const sortedHighlights = highlights.sort((a,b) => a.readOnly === b.readOnly ? 0 : a.readOnly ? -1 : 1);
|
|
185
|
+
|
|
186
|
+
const knownHighlightIds = Object.keys(highlightsById);
|
|
187
|
+
let addedCount = 0, updatedCount = 0;
|
|
188
|
+
for (const highlight of sortedHighlights) {
|
|
189
|
+
const highlightInfo = diffHighlight(highlight, highlightsById[highlight.highlightId]);
|
|
190
|
+
highlightInfo.highlightId = highlight.highlightId;
|
|
191
|
+
const knownHighlightIndex = knownHighlightIds.indexOf(highlightInfo.highlightId);
|
|
192
|
+
if (knownHighlightIndex > -1) {
|
|
193
|
+
knownHighlightIds.splice(knownHighlightIndex, 1);
|
|
194
|
+
if (Object.keys(highlightInfo).length > 1) {
|
|
195
|
+
this.createOrUpdateHighlight(highlightInfo, false); updatedCount++;
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
this.createOrUpdateHighlight(highlightInfo, false); addedCount++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (knownHighlightIds.length > 0) this.removeHighlights(knownHighlightIds);
|
|
202
|
+
|
|
203
|
+
(options.drawingMode === 'svg' ? svgBackground : this.annotatableContainer).style.display = '';
|
|
204
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightsload', { detail: {
|
|
205
|
+
addedCount: addedCount, removedCount: knownHighlightIds.length, updatedCount: updatedCount,
|
|
206
|
+
totalCount: Object.keys(highlightsById).length,
|
|
207
|
+
timeToLoad: Date.now() - startTimestamp,
|
|
208
|
+
} }));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Draw (or redraw) specified highlights, or all highlights on the page
|
|
212
|
+
this.drawHighlights = (highlightIds = Object.keys(highlightsById)) => {
|
|
213
|
+
// Hide container (repeated DOM manipulations is faster if the container is hidden)
|
|
214
|
+
if (highlightIds.length > 1) (options.drawingMode === 'svg' ? svgBackground : this.annotatableContainer).style.display = 'none';
|
|
215
|
+
|
|
216
|
+
for (const highlightId of highlightIds) {
|
|
217
|
+
const highlightInfo = highlightsById[highlightId];
|
|
218
|
+
let range = getCorrectedRangeObj(highlightId);
|
|
219
|
+
const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
|
|
220
|
+
const wasDrawnAsReadOnly = this.annotatableContainer.querySelector(`[data-highlight-id="${highlightId}"][data-read-only]`);
|
|
221
|
+
|
|
222
|
+
// Remove old highlight elements and styles
|
|
223
|
+
if (!wasDrawnAsReadOnly || (wasDrawnAsReadOnly && !highlightInfo.readOnly)) undrawHighlight(highlightInfo);
|
|
224
|
+
|
|
225
|
+
if (highlightInfo.readOnly || options.drawingMode === 'mark-elements') {
|
|
226
|
+
// Don't redraw a read-only highlight
|
|
227
|
+
if (wasDrawnAsReadOnly) continue;
|
|
228
|
+
|
|
229
|
+
// Inject HTML <mark> elements
|
|
230
|
+
range.startContainer.splitText(range.startOffset);
|
|
231
|
+
range.endContainer.splitText(range.endOffset);
|
|
232
|
+
const textNodeIter = document.createNodeIterator(range.commonAncestorContainer, NodeFilter.SHOW_TEXT);
|
|
233
|
+
const relevantTextNodes = [];
|
|
234
|
+
while (node = textNodeIter.nextNode()) {
|
|
235
|
+
if (range.intersectsNode(node) && node !== range.startContainer && node.textContent !== '' && !node.parentElement.closest('rt') && node.parentElement.closest(options.paragraphSelector)) relevantTextNodes.push(node);
|
|
236
|
+
if (node === range.endContainer) break;
|
|
237
|
+
}
|
|
238
|
+
const overlappingHighlightIds = new Set();
|
|
239
|
+
for (let tn = 0; tn < relevantTextNodes.length; tn++) {
|
|
240
|
+
const textNode = relevantTextNodes[tn];
|
|
241
|
+
if (textNode.parentElement.dataset.highlightId) {
|
|
242
|
+
overlappingHighlightIds.add(textNode.parentElement.dataset.highlightId)
|
|
243
|
+
}
|
|
244
|
+
const styledMark = document.createElement('mark');
|
|
245
|
+
styledMark.dataset.highlightId = highlightId;
|
|
246
|
+
if (highlightInfo.readOnly) styledMark.dataset.readOnly = '';
|
|
247
|
+
styledMark.dataset.color = highlightInfo.color;
|
|
248
|
+
styledMark.dataset.style = highlightInfo.style;
|
|
249
|
+
if (tn === 0) styledMark.dataset.start = '';
|
|
250
|
+
if (tn === relevantTextNodes.length - 1) styledMark.dataset.end = '';
|
|
251
|
+
textNode.before(styledMark);
|
|
252
|
+
styledMark.appendChild(textNode);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Update highlight ranges that were invalidated by the DOM change
|
|
256
|
+
range = getCorrectedRangeObj(highlightId);
|
|
257
|
+
for (const overlappingHighlightId of overlappingHighlightIds) {
|
|
258
|
+
getCorrectedRangeObj(overlappingHighlightId);
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// Draw highlights with Custom Highlight API
|
|
262
|
+
if (options.drawingMode === 'highlight-api' && supportsHighlightApi) {
|
|
263
|
+
if (CSS.highlights.has(highlightId)) {
|
|
264
|
+
highlightObj = CSS.highlights.get(highlightId);
|
|
265
|
+
highlightObj.clear();
|
|
266
|
+
} else {
|
|
267
|
+
highlightObj = new Highlight();
|
|
268
|
+
CSS.highlights.set(highlightId, highlightObj);
|
|
269
|
+
}
|
|
270
|
+
highlightObj.add(range);
|
|
271
|
+
const styleTemplate = getStyleTemplate(highlightInfo.style, 'css', null);
|
|
272
|
+
const colorString = options.colors[highlightInfo.color];
|
|
273
|
+
highlightApiStylesheet.insertRule(`${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { --hh-color: ${colorString}; ${styleTemplate} }`);
|
|
274
|
+
highlightApiStylesheet.insertRule(`${options.containerSelector} rt::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }`);
|
|
275
|
+
highlightApiStylesheet.insertRule(`${options.containerSelector} img::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }`);
|
|
276
|
+
|
|
277
|
+
// Draw highlights with SVG shapes
|
|
278
|
+
} else if (options.drawingMode === 'svg') {
|
|
279
|
+
const clientRects = getMergedClientRects(range, rangeParagraphs);
|
|
280
|
+
let group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
281
|
+
group.dataset.highlightId = highlightId;
|
|
282
|
+
group.dataset.color = highlightInfo.color;
|
|
283
|
+
group.dataset.style = highlightInfo.style;
|
|
284
|
+
let svgContent = '';
|
|
285
|
+
for (const clientRect of clientRects) {
|
|
286
|
+
svgContent += getStyleTemplate(highlightInfo.style, 'svg', clientRect);
|
|
287
|
+
}
|
|
288
|
+
group.innerHTML = svgContent;
|
|
289
|
+
svgBackground.appendChild(group);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Update wrapper (for read-only highlights only)
|
|
294
|
+
if (highlightInfo.readOnly && !wasDrawnAsReadOnly) {
|
|
295
|
+
if (highlightInfo.wrapper && (options.wrappers[highlightInfo.wrapper]?.start || options.wrappers[highlightInfo.wrapper]?.end)) {
|
|
296
|
+
const addWrapper = (edge, range, htmlString) => {
|
|
297
|
+
htmlString = `<span class="hh-wrapper-${edge}" data-highlight-id="${highlightId}" data-color="${highlightInfo.color}" data-style="${highlightInfo.style}">${htmlString}</span>`
|
|
298
|
+
for (const key of Object.keys(highlightInfo.wrapperVariables)) {
|
|
299
|
+
htmlString = htmlString.replaceAll(`{${key}}`, highlightInfo.wrapperVariables[key]);
|
|
300
|
+
}
|
|
301
|
+
const template = document.createElement('template');
|
|
302
|
+
template.innerHTML = htmlString;
|
|
303
|
+
let htmlElement = template.content.firstChild;
|
|
304
|
+
|
|
305
|
+
const textNodeIter = document.createNodeIterator(htmlElement, NodeFilter.SHOW_TEXT);
|
|
306
|
+
while (node = textNodeIter.nextNode()) node.parentNode.removeChild(node);
|
|
307
|
+
range.insertNode(htmlElement);
|
|
308
|
+
}
|
|
309
|
+
const startRange = highlightInfo.rangeObj;
|
|
310
|
+
const endRange = document.createRange(); endRange.setStart(highlightInfo.rangeObj.endContainer, highlightInfo.rangeObj.endOffset);
|
|
311
|
+
const wrapperInfo = options.wrappers[highlightInfo.wrapper];
|
|
312
|
+
addWrapper('start', startRange, wrapperInfo.start);
|
|
313
|
+
addWrapper('end', endRange, wrapperInfo.end);
|
|
314
|
+
rangeParagraphs.forEach(p => { p.normalize(); });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Show container
|
|
320
|
+
(options.drawingMode === 'svg' ? svgBackground : this.annotatableContainer).style.display = '';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Create a new highlight, or update an existing highlight when it changes
|
|
324
|
+
this.createOrUpdateHighlight = (attributes = {}, triggeredByUserAction = true) => {
|
|
325
|
+
let highlightId = attributes.highlightId ?? activeHighlightId ?? options.highlightIdFunction();
|
|
326
|
+
appearanceChanges = [];
|
|
327
|
+
boundsChanges = [];
|
|
328
|
+
|
|
329
|
+
let isNewHighlight, oldHighlightInfo;
|
|
330
|
+
if (highlightsById.hasOwnProperty(highlightId)) {
|
|
331
|
+
oldHighlightInfo = highlightsById[highlightId];
|
|
332
|
+
} else {
|
|
333
|
+
isNewHighlight = true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// If a different highlight is active, deactivate it
|
|
337
|
+
if (activeHighlightId && highlightId !== activeHighlightId && triggeredByUserAction === true) {
|
|
338
|
+
this.deactivateHighlights();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// If the highlight is currently activate, ignore bounds changes that weren't initiated by the user
|
|
342
|
+
if (highlightId === activeHighlightId && triggeredByUserAction === false) {
|
|
343
|
+
attributes.startParagraphId = null;
|
|
344
|
+
attributes.startParagraphOffset = null;
|
|
345
|
+
attributes.endParagraphId = null;
|
|
346
|
+
attributes.endParagraphOffset = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Warn if color, style, or wrapper attributes are invalid
|
|
350
|
+
if (attributes.color && !options.colors.hasOwnProperty(attributes.color)) {
|
|
351
|
+
console.warn(`Highlight color "${attributes.color}" is not defined in options (highlightId: ${highlightId}).`);
|
|
352
|
+
}
|
|
353
|
+
if (attributes.style && !options.styles.hasOwnProperty(attributes.style)) {
|
|
354
|
+
console.warn(`Highlight style "${attributes.style}" is not defined in options (highlightId: ${highlightId}).`);
|
|
355
|
+
}
|
|
356
|
+
if (attributes.wrapper && !options.wrappers.hasOwnProperty(attributes.wrapper)) {
|
|
357
|
+
console.warn(`Highlight wrapper "${attributes.wrapper}" is not defined in options (highlightId: ${highlightId}).`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Update defaults
|
|
361
|
+
if (options.rememberStyle && triggeredByUserAction) {
|
|
362
|
+
if (attributes.color) options.defaultColor = attributes.color;
|
|
363
|
+
if (attributes.style) options.defaultStyle = attributes.style;
|
|
364
|
+
if (attributes.wrapper) options.defaultWrapper = attributes.wrapper;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Check which appearance properties changed
|
|
368
|
+
for (const key of ['color', 'style', 'wrapper', 'wrapperVariables', 'readOnly']) {
|
|
369
|
+
if (isNewHighlight || (attributes[key] != null && attributes[key] !== oldHighlightInfo[key])) appearanceChanges.push(key);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// If the highlight was and still is read-only, return
|
|
373
|
+
if (oldHighlightInfo?.readOnly && (attributes.readOnly == null || attributes.readOnly === true)) return this.deactivateHighlights();
|
|
374
|
+
|
|
375
|
+
// Calculate the bounds of the highlight range, if it's changed
|
|
376
|
+
let adjustedSelectionRange, highlightRange;
|
|
377
|
+
let rangeText, rangeHtml, rangeParagraphIds;
|
|
378
|
+
let startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset;
|
|
379
|
+
const selection = getRestoredSelectionOrCaret(window.getSelection());
|
|
380
|
+
if (selection.type === 'Range') adjustedSelectionRange = snapRangeToBoundaries(selection.getRangeAt(0));
|
|
381
|
+
if ((attributes.startParagraphId ?? attributes.startParagraphOffset ?? attributes.endParagraphId ?? attributes.endParagraphOffset != null) || (adjustedSelectionRange && !adjustedSelectionRange.collapsed)) {
|
|
382
|
+
let startNode, startOffset, endNode, endOffset;
|
|
383
|
+
if (attributes.startParagraphId ?? attributes.startParagraphOffset ?? attributes.endParagraphId ?? attributes.endParagraphOffset != null) {
|
|
384
|
+
startParagraphId = attributes.startParagraphId ?? oldHighlightInfo?.startParagraphId;
|
|
385
|
+
startParagraphOffset = parseInt(attributes.startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset);
|
|
386
|
+
endParagraphId = attributes.endParagraphId ?? oldHighlightInfo?.endParagraphId;
|
|
387
|
+
endParagraphOffset = parseInt(attributes.endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset);
|
|
388
|
+
([ startNode, startOffset ] = getTextNodeAndOffset(document.getElementById(startParagraphId), startParagraphOffset));
|
|
389
|
+
([ endNode, endOffset ] = getTextNodeAndOffset(document.getElementById(endParagraphId), endParagraphOffset));
|
|
390
|
+
} else if (adjustedSelectionRange) {
|
|
391
|
+
startNode = adjustedSelectionRange.startContainer;
|
|
392
|
+
startOffset = adjustedSelectionRange.startOffset;
|
|
393
|
+
endNode = adjustedSelectionRange.endContainer;
|
|
394
|
+
endOffset = adjustedSelectionRange.endOffset;
|
|
395
|
+
([ startParagraphId, startParagraphOffset ] = getParagraphOffset(startNode, startOffset));
|
|
396
|
+
([ endParagraphId, endParagraphOffset ] = getParagraphOffset(endNode, endOffset));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Create a new highlight range
|
|
400
|
+
highlightRange = document.createRange();
|
|
401
|
+
highlightRange.setStart(startNode, startOffset);
|
|
402
|
+
highlightRange.setEnd(endNode, endOffset);
|
|
403
|
+
|
|
404
|
+
// Check which bounds properties changed
|
|
405
|
+
for (const key of ['startParagraphId', 'startParagraphOffset', 'endParagraphId', 'endParagraphOffset']) {
|
|
406
|
+
if (isNewHighlight || eval(key) !== oldHighlightInfo[key]) boundsChanges.push(key);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Set variables that depend on the range
|
|
410
|
+
const temporaryHtmlElement = document.createElement('div');
|
|
411
|
+
temporaryHtmlElement.appendChild(highlightRange.cloneContents());
|
|
412
|
+
for (const element of temporaryHtmlElement.querySelectorAll(`a, [data-highlight-id]:not([data-highlight-id="${highlightId}"])`)) element.outerHTML = element.innerHTML;
|
|
413
|
+
rangeText = highlightRange.toString();
|
|
414
|
+
rangeHtml = temporaryHtmlElement.innerHTML;
|
|
415
|
+
let startParagraphIndex = annotatableParagraphIds.indexOf(startParagraphId);
|
|
416
|
+
let endParagraphIndex = annotatableParagraphIds.indexOf(endParagraphId);
|
|
417
|
+
if (startParagraphIndex === -1) startParagraphIndex = 0;
|
|
418
|
+
if (endParagraphIndex === -1) endParagraphIndex = annotatableParagraphIds.length - 1;
|
|
419
|
+
rangeParagraphIds = annotatableParagraphIds.slice(startParagraphIndex, endParagraphIndex + 1);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// If there are no valid changes, return
|
|
423
|
+
if (!highlightRange || highlightRange.toString() === '' || appearanceChanges.length + boundsChanges.length === 0) return;
|
|
424
|
+
|
|
425
|
+
// Update saved highlight info
|
|
426
|
+
const newHighlightInfo = {
|
|
427
|
+
highlightId: highlightId,
|
|
428
|
+
color: attributes?.color ?? oldHighlightInfo?.color ?? options.defaultColor,
|
|
429
|
+
style: attributes?.style ?? oldHighlightInfo?.style ?? options.defaultStyle,
|
|
430
|
+
wrapper: attributes?.wrapper ?? oldHighlightInfo?.wrapper ?? options.defaultWrapper,
|
|
431
|
+
wrapperVariables: attributes?.wrapperVariables ?? oldHighlightInfo?.wrapperVariables ?? {},
|
|
432
|
+
readOnly: attributes?.readOnly ?? oldHighlightInfo?.readOnly ?? false,
|
|
433
|
+
startParagraphId: startParagraphId ?? oldHighlightInfo?.startParagraphId,
|
|
434
|
+
startParagraphOffset: startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset,
|
|
435
|
+
endParagraphId: endParagraphId ?? oldHighlightInfo?.endParagraphId,
|
|
436
|
+
endParagraphOffset: endParagraphOffset ?? oldHighlightInfo?.endParagraphOffset,
|
|
437
|
+
// Read-only properties
|
|
438
|
+
escapedHighlightId: CSS.escape(highlightId),
|
|
439
|
+
rangeText: rangeText ?? oldHighlightInfo?.rangeText,
|
|
440
|
+
rangeHtml: rangeHtml ?? oldHighlightInfo?.rangeHtml,
|
|
441
|
+
rangeParagraphIds: rangeParagraphIds ?? oldHighlightInfo?.rangeParagraphIds,
|
|
442
|
+
rangeObj: highlightRange ?? oldHighlightInfo?.rangeObj,
|
|
443
|
+
};
|
|
444
|
+
highlightsById[highlightId] = newHighlightInfo;
|
|
445
|
+
|
|
446
|
+
const detail = {
|
|
447
|
+
highlight: newHighlightInfo,
|
|
448
|
+
changes: appearanceChanges.concat(boundsChanges),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (highlightId !== activeHighlightId) this.drawHighlights([highlightId]);
|
|
452
|
+
if (highlightId === activeHighlightId && appearanceChanges.length > 0) {
|
|
453
|
+
updateSelectionUi('appearance');
|
|
454
|
+
} else if (triggeredByUserAction && highlightId !== activeHighlightId) {
|
|
455
|
+
this.activateHighlight(highlightId);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (isNewHighlight) {
|
|
459
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightcreate', { detail: detail }));
|
|
460
|
+
} else {
|
|
461
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightupdate', { detail: detail }));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Activate a highlight by ID
|
|
466
|
+
this.activateHighlight = (highlightId) => {
|
|
467
|
+
const highlightToActivate = highlightsById[highlightId];
|
|
468
|
+
if (highlightToActivate.readOnly) {
|
|
469
|
+
// If the highlight is read-only, return events, but don't actually activate it
|
|
470
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
|
|
471
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: { highlight: highlightToActivate } }));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const selection = window.getSelection();
|
|
475
|
+
const highlightRange = highlightToActivate.rangeObj.cloneRange();
|
|
476
|
+
activeHighlightId = highlightId;
|
|
477
|
+
updateSelectionUi('appearance');
|
|
478
|
+
|
|
479
|
+
// Update the selection range if needed
|
|
480
|
+
// 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.
|
|
481
|
+
let selectionRange = selection.type === 'Range' ? selection.getRangeAt(0) : null;
|
|
482
|
+
if (!selectionRange || !(
|
|
483
|
+
selectionRange.startContainer === highlightRange.startContainer &&
|
|
484
|
+
selectionRange.startOffset === highlightRange.startOffset &&
|
|
485
|
+
selectionRange.endContainer === highlightRange.endContainer &&
|
|
486
|
+
selectionRange.endOffset === highlightRange.endOffset)
|
|
487
|
+
) {
|
|
488
|
+
selection.setBaseAndExtent(highlightRange.startContainer, highlightRange.startOffset, highlightRange.endContainer, highlightRange.endOffset);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Activate a link by position
|
|
495
|
+
let allowHyperlinkClick = false;
|
|
496
|
+
this.activateHyperlink = (position) => {
|
|
497
|
+
this.deactivateHighlights();
|
|
498
|
+
allowHyperlinkClick = true;
|
|
499
|
+
hyperlinksByPosition[position].hyperlinkElement.click();
|
|
500
|
+
allowHyperlinkClick = false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Deactivate any highlights that are currently active/selected
|
|
504
|
+
this.deactivateHighlights = (removeSelectionRanges = true) => {
|
|
505
|
+
const deactivatedHighlight = highlightsById[activeHighlightId];
|
|
506
|
+
activeHighlightId = null;
|
|
507
|
+
previousSelectionRange = null;
|
|
508
|
+
const selection = window.getSelection();
|
|
509
|
+
if (removeSelectionRanges && selection.anchorNode && this.annotatableContainer.contains(selection.anchorNode)) {
|
|
510
|
+
selection.collapseToStart();
|
|
511
|
+
}
|
|
512
|
+
updateSelectionUi('appearance');
|
|
513
|
+
if (deactivatedHighlight) {
|
|
514
|
+
this.drawHighlights([deactivatedHighlight.highlightId]);
|
|
515
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: {
|
|
516
|
+
highlight: deactivatedHighlight,
|
|
517
|
+
}}));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Remove the specified highlights, or all highlights on the page
|
|
522
|
+
this.removeHighlights = (highlightIds = Object.keys(highlightsById)) => {
|
|
523
|
+
this.deactivateHighlights();
|
|
524
|
+
for (const highlightId of highlightIds) {
|
|
525
|
+
const highlightInfo = highlightsById[highlightId];
|
|
526
|
+
if (highlightInfo) {
|
|
527
|
+
delete highlightsById[highlightId];
|
|
528
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightremove', { detail: {
|
|
529
|
+
highlightId: highlightId,
|
|
530
|
+
}}));
|
|
531
|
+
undrawHighlight(highlightInfo);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Get the active highlight ID (if there is one)
|
|
537
|
+
this.getActiveHighlightId = () => {
|
|
538
|
+
return activeHighlightId;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Get info for specified highlights, or all highlights on the page
|
|
542
|
+
this.getHighlightInfo = (highlightIds = Object.keys(highlightsById), paragraphId = null) => {
|
|
543
|
+
let filteredHighlights = []
|
|
544
|
+
for (highlightId of highlightIds) {
|
|
545
|
+
const highlightInfo = highlightsById[highlightId];
|
|
546
|
+
if (!paragraphId || paragraphId === highlightInfo.startParagraphId) {
|
|
547
|
+
filteredHighlights.push(highlightInfo);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Sort highlights based on their order on the page
|
|
551
|
+
if (filteredHighlights.length > 0) {
|
|
552
|
+
filteredHighlights.sort((a, b) => {
|
|
553
|
+
return (annotatableParagraphIds.indexOf(a.startParagraphId) - annotatableParagraphIds.indexOf(b.startParagraphId)) || (a.startParagraphOffset - b.startParagraphOffset);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
return filteredHighlights;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Update one of the initialized options
|
|
560
|
+
this.setOption = (key, value) => {
|
|
561
|
+
const containerSelector = options.containerSelector;
|
|
562
|
+
options[key] = value ?? options[key];
|
|
563
|
+
if (key === 'drawingMode' || key === 'styles') {
|
|
564
|
+
updateAppearanceStylesheet();
|
|
565
|
+
if (supportsHighlightApi) CSS.highlights.clear();
|
|
566
|
+
this.drawHighlights();
|
|
567
|
+
} else if (key === 'colors') {
|
|
568
|
+
updateAppearanceStylesheet();
|
|
569
|
+
} else if (key === 'containerSelector') {
|
|
570
|
+
this.removeHighlighter();
|
|
571
|
+
initializeHighlighter();
|
|
572
|
+
} else if (key === 'paragraphSelector') {
|
|
573
|
+
initializeHighlighter();
|
|
574
|
+
} else if (key === 'selectionHandles') {
|
|
575
|
+
for (const selectionHandle of selectionHandles) {
|
|
576
|
+
selectionHandle.children[1].innerHTML = options.selectionHandles[selectionHandle.dataset.side] ?? '';
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Get all of the initialized options
|
|
582
|
+
this.getOptions = () => {
|
|
583
|
+
return options;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Remove this Highlighter instance and its highlights
|
|
587
|
+
this.removeHighlighter = () => {
|
|
588
|
+
this.loadHighlights([]);
|
|
589
|
+
this.annotatableContainer.querySelectorAll('.hh-svg-background, .hh-selection-handle').forEach(el => el.remove())
|
|
590
|
+
removeStylesheets(this.stylesheets);
|
|
591
|
+
resizeObserver.disconnect();
|
|
592
|
+
controller.abort();
|
|
593
|
+
|
|
594
|
+
this.annotatableContainer.highlighter = undefined;
|
|
595
|
+
hhHighlighters = hhHighlighters.filter(hhHighlighter => hhHighlighter.annotatableContainer !== this.annotatableContainer);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
// -------- EVENT LISTENERS --------
|
|
600
|
+
|
|
601
|
+
// Selection change in document (new selection, change in selection range, or selection collapsing to a caret)
|
|
602
|
+
document.addEventListener('selectionchange', (event) => respondToSelectionChange(event), { signal: controller.signal });
|
|
603
|
+
const respondToSelectionChange = (event) => {
|
|
604
|
+
const selection = getRestoredSelectionOrCaret(window.getSelection());
|
|
605
|
+
const selectionRange = selection.type === 'None' ? null : selection.getRangeAt(0);
|
|
606
|
+
|
|
607
|
+
// In "Mac (Designed for iPad)" apps (iPad app running on macOS – most recently tested with macOS Sequoia 15.3.1), in-app webviews have several quirks related to text selection. One of these is text selection collapsing to a caret more often than expected. This code attempts to restore the previous selection range if it unexpectedly collapses to a caret in these scenarios:
|
|
608
|
+
// 1. While dragging custom selection handles (happens randomly). TODO: Dragging custom selection handles in this environment is still sometimes a little jumpy.
|
|
609
|
+
// 2. Just after clicking to activate a highlight (happens if it's the first click after the page loads).
|
|
610
|
+
if (isWKWebView && !isTouchDevice && selection.type !== 'Range' && previousSelectionRange && (activeSelectionHandle || (previousSelectionRange.compareBoundaryPoints(Range.END_TO_START, selectionRange) <= 0 && previousSelectionRange.compareBoundaryPoints(Range.END_TO_END, selectionRange) >= 0))) {
|
|
611
|
+
selection.setBaseAndExtent(previousSelectionRange.startContainer, previousSelectionRange.startOffset, previousSelectionRange.endContainer, previousSelectionRange.endOffset);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Deactivate highlights when tapping or creating a selection outside of the previous selection range
|
|
615
|
+
if (!activeSelectionHandle && previousSelectionRange && (selection.type !== 'Range' || previousSelectionRange.comparePoint(selectionRange.startContainer, selectionRange.startOffset) === 1 || previousSelectionRange.comparePoint(selectionRange.endContainer, selectionRange.endOffset) === -1)) {
|
|
616
|
+
this.deactivateHighlights(false);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (selection.type === 'Range') {
|
|
620
|
+
// Clear tap result (prevents hh:tap event from being sent when long-pressing or dragging to select text)
|
|
621
|
+
tapResult = null;
|
|
622
|
+
|
|
623
|
+
if (this.annotatableContainer.contains(selection.anchorNode)) {
|
|
624
|
+
if (activeHighlightId || (options.pointerMode === 'live' || (options.pointerMode === 'auto' && pointerType === 'pen'))) {
|
|
625
|
+
this.createOrUpdateHighlight({ highlightId: activeHighlightId, });
|
|
626
|
+
}
|
|
627
|
+
previousSelectionRange = selectionRange.cloneRange();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
updateSelectionUi('bounds');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Pointer down in annotatable container
|
|
635
|
+
this.annotatableContainer.addEventListener('pointerdown', (event) => respondToPointerDown(event), { signal: controller.signal });
|
|
636
|
+
const respondToPointerDown = (event) => {
|
|
637
|
+
const isSecondaryClick = (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey);
|
|
638
|
+
pointerType = event.pointerType;
|
|
639
|
+
|
|
640
|
+
// Pointer down on a selection handle
|
|
641
|
+
if (event.target?.closest('.hh-selection-handle')) {
|
|
642
|
+
if (!isSecondaryClick) {
|
|
643
|
+
activeSelectionHandle = event.target.parentElement.closest('.hh-selection-handle');
|
|
644
|
+
this.annotatableContainer.dataset.hhDragging = 'true';
|
|
645
|
+
const selectionHandleClientRect = activeSelectionHandle.getBoundingClientRect();
|
|
646
|
+
const lineHeight = selectionHandleClientRect.bottom - selectionHandleClientRect.top;
|
|
647
|
+
activeSelectionHandle.dataset.dragYOffset = Math.max(0, event.clientY - selectionHandleClientRect.bottom + (lineHeight / 6));
|
|
648
|
+
const selectionRange = window.getSelection().getRangeAt(0);
|
|
649
|
+
dragAnchorNode = activeSelectionHandle.dataset.position === 'start' ? selectionRange.endContainer : selectionRange.startContainer;
|
|
650
|
+
dragAnchorOffset = activeSelectionHandle.dataset.position === 'start' ? selectionRange.endOffset : selectionRange.startOffset;
|
|
651
|
+
this.annotatableContainer.addEventListener('pointermove', respondToSelectionHandleDrag, { signal: controller.signal });
|
|
652
|
+
updateSelectionUi('bounds');
|
|
653
|
+
}
|
|
654
|
+
// Prevent default drag interaction (which would show a thumbnail and drag selected text)
|
|
655
|
+
return event.preventDefault();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 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.
|
|
659
|
+
if (doubleTapTimeoutId) {
|
|
660
|
+
return this.deactivateHighlights();
|
|
661
|
+
} else {
|
|
662
|
+
doubleTapTimeoutId = setTimeout(() => doubleTapTimeoutId = clearTimeout(doubleTapTimeoutId), 500);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Return if it's not a regular click, or if the user is tapping away from an existing selection
|
|
666
|
+
if (previousSelectionRange || isSecondaryClick) return;
|
|
667
|
+
|
|
668
|
+
// Trigger a long-press event if the user doesn't lift their finger within the specified time
|
|
669
|
+
if (options.longPressTimeout) longPressTimeoutId = setTimeout(() => respondToLongPress(event), options.longPressTimeout);
|
|
670
|
+
|
|
671
|
+
tapResult = checkForTapTargets(event);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Selection handle drag (this function is added as an event listener on pointerdown, and removed on pointerup)
|
|
675
|
+
const respondToSelectionHandleDrag = (event) => {
|
|
676
|
+
activeSelectionHandle.dataset.pointerXPosition = event.clientX;
|
|
677
|
+
activeSelectionHandle.dataset.pointerYPosition = event.clientY;
|
|
678
|
+
const selection = window.getSelection();
|
|
679
|
+
const selectionRange = selection.getRangeAt(0);
|
|
680
|
+
const dragCaret = getCaretFromCoordinates(event.clientX, event.clientY - activeSelectionHandle.dataset.dragYOffset, true, false, true);
|
|
681
|
+
|
|
682
|
+
// 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
|
|
683
|
+
if (!dragCaret || dragCaret.startContainer.nodeType !== Node.TEXT_NODE || dragCaret.endContainer.nodeType !== Node.TEXT_NODE || (dragAnchorNode === dragCaret.endContainer && dragAnchorOffset === dragCaret.endOffset)) return;
|
|
684
|
+
|
|
685
|
+
// Check if start and end selection handles switched positions
|
|
686
|
+
const dragPositionRelativeToSelectionStart = dragCaret.compareBoundaryPoints(Range.START_TO_END, selectionRange);
|
|
687
|
+
const dragPositionRelativeToSelectionEnd = dragCaret.compareBoundaryPoints(Range.END_TO_END, selectionRange);
|
|
688
|
+
if (activeSelectionHandle.dataset.position === 'start' && dragPositionRelativeToSelectionEnd === 1 || activeSelectionHandle.dataset.position === 'end' && dragPositionRelativeToSelectionStart === -1) {
|
|
689
|
+
for (const selectionHandle of selectionHandles) {
|
|
690
|
+
selectionHandle.dataset.position = selectionHandle.dataset.position === 'start' ? 'end' : 'start';
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Update selection
|
|
695
|
+
selection.setBaseAndExtent(dragAnchorNode, dragAnchorOffset, dragCaret.endContainer, dragCaret.endOffset);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Long press in annotatable container (triggered by setTimeout() in pointerdown event)
|
|
699
|
+
const respondToLongPress = (event) => {
|
|
700
|
+
respondToPointerUp(event, isLongPress = true);
|
|
701
|
+
tapResult = null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Pointer up in annotatable container
|
|
705
|
+
this.annotatableContainer.addEventListener('pointerup', (event) => respondToPointerUp(event), { signal: controller.signal });
|
|
706
|
+
const respondToPointerUp = (event, isLongPress = false) => {
|
|
707
|
+
if (tapResult) {
|
|
708
|
+
tapResult.isLongPress = isLongPress;
|
|
709
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:tap', { detail: tapResult, }));
|
|
710
|
+
if (options.autoTapToActivate && tapResult?.targetFound && !isLongPress) {
|
|
711
|
+
if (tapResult.highlights.length === 1 && tapResult.hyperlinks.length === 0) {
|
|
712
|
+
return this.activateHighlight(tapResult.highlights[0].highlightId);
|
|
713
|
+
} else if (tapResult.highlights.length === 0 && tapResult.hyperlinks.length === 1) {
|
|
714
|
+
return this.activateHyperlink(tapResult.hyperlinks[0].position);
|
|
715
|
+
} else if (tapResult.highlights.length + tapResult.hyperlinks.length > 1) {
|
|
716
|
+
return this.annotatableContainer.dispatchEvent(new CustomEvent('hh:ambiguousaction', { detail: tapResult, }));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Pointer up or cancel in window
|
|
723
|
+
window.addEventListener('pointerup', (event) => respondToWindowPointerUp(event), { signal: controller.signal });
|
|
724
|
+
window.addEventListener('pointercancel', (event) => respondToWindowPointerUp(event), { signal: controller.signal });
|
|
725
|
+
const respondToWindowPointerUp = (event) => {
|
|
726
|
+
const selection = window.getSelection();
|
|
727
|
+
if (selection.type === 'Range' && activeHighlightId && this.annotatableContainer.contains(selection.anchorNode)) {
|
|
728
|
+
const adjustedSelectionRange = snapRangeToBoundaries(selection.getRangeAt(0), selection.anchorNode);
|
|
729
|
+
|
|
730
|
+
// Update the selection range if needed
|
|
731
|
+
// 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.
|
|
732
|
+
const selectionRange = selection.getRangeAt(0);
|
|
733
|
+
if (!(
|
|
734
|
+
adjustedSelectionRange.startContainer === selectionRange.startContainer &&
|
|
735
|
+
adjustedSelectionRange.startOffset === selectionRange.startOffset &&
|
|
736
|
+
adjustedSelectionRange.endContainer === selectionRange.endContainer &&
|
|
737
|
+
adjustedSelectionRange.endOffset === selectionRange.endOffset)
|
|
738
|
+
) {
|
|
739
|
+
selection.setBaseAndExtent(adjustedSelectionRange.startContainer, adjustedSelectionRange.startOffset, adjustedSelectionRange.endContainer, adjustedSelectionRange.endOffset);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
tapResult = null;
|
|
743
|
+
longPressTimeoutId = clearTimeout(longPressTimeoutId);
|
|
744
|
+
if (activeSelectionHandle) {
|
|
745
|
+
activeSelectionHandle = null;
|
|
746
|
+
updateSelectionUi('bounds');
|
|
747
|
+
this.annotatableContainer.dataset.hhDragging = 'false';
|
|
748
|
+
this.annotatableContainer.removeEventListener('pointermove', respondToSelectionHandleDrag);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Hyperlink click (for each hyperlink in annotatable container)
|
|
753
|
+
for (const hyperlinkElement of hyperlinkElements) {
|
|
754
|
+
hyperlinkElement.addEventListener('click', (event) => {
|
|
755
|
+
this.deactivateHighlights();
|
|
756
|
+
if (!allowHyperlinkClick) event.preventDefault();
|
|
757
|
+
}, { signal: controller.signal });
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Annotatable container resize (debounced)
|
|
761
|
+
let computedStyle = window.getComputedStyle(this.annotatableContainer);
|
|
762
|
+
let previousWidth = Math.round(this.annotatableContainer.clientWidth - parseInt(computedStyle.getPropertyValue('padding-left')) - parseInt(computedStyle.getPropertyValue('padding-right')));
|
|
763
|
+
const resizeObserver = new ResizeObserver(debounce((entries) => {
|
|
764
|
+
for (const entry of entries) {
|
|
765
|
+
const width = Math.round(entry.contentBoxSize[0].inlineSize);
|
|
766
|
+
// Only respond if the annotatable content width changed
|
|
767
|
+
if (width !== previousWidth) {
|
|
768
|
+
if (options.drawingMode === 'svg') this.drawHighlights();
|
|
769
|
+
if (previousSelectionRange) updateSelectionUi('bounds');
|
|
770
|
+
previousWidth = width;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}, Math.floor(Object.keys(highlightsById).length / 20)));
|
|
774
|
+
resizeObserver.observe(this.annotatableContainer);
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
// -------- UTILITY FUNCTIONS --------
|
|
778
|
+
|
|
779
|
+
// Check if the tap is in the range of an existing highlight or link
|
|
780
|
+
const checkForTapTargets = (pointerEvent) => {
|
|
781
|
+
if (!pointerEvent) return;
|
|
782
|
+
|
|
783
|
+
// Check for tapped highlights and hyperlinks
|
|
784
|
+
const tappedHighlights = [];
|
|
785
|
+
for (const highlightId of Object.keys(highlightsById)) {
|
|
786
|
+
const highlightInfo = highlightsById[highlightId];
|
|
787
|
+
const highlightRange = highlightInfo.rangeObj;
|
|
788
|
+
for (const rangeRect of highlightRange.getClientRects()) {
|
|
789
|
+
if (isPointInRect(pointerEvent.clientX, pointerEvent.clientY, rangeRect, 5)) {
|
|
790
|
+
tappedHighlights.push(highlightInfo);
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const tappedHyperlinks = [];
|
|
796
|
+
for (const hyperlinkInfo of Object.values(hyperlinksByPosition)) {
|
|
797
|
+
if (pointerEvent.target.closest('a') === hyperlinkInfo.hyperlinkElement) {
|
|
798
|
+
tappedHyperlinks.push(hyperlinkInfo);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Sort highlights (hyperlinks should already be sorted)
|
|
803
|
+
const tappedHighlightIds = [];
|
|
804
|
+
for (const highlightInfo of tappedHighlights) tappedHighlightIds.push(highlightInfo.highlightId);
|
|
805
|
+
const sortedTappedHighlights = this.getHighlightInfo(tappedHighlightIds);
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
'targetFound': sortedTappedHighlights.length > 0 || tappedHyperlinks.length > 0,
|
|
809
|
+
'tapRange': getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY),
|
|
810
|
+
'pointerEvent': pointerEvent,
|
|
811
|
+
'highlights': sortedTappedHighlights,
|
|
812
|
+
'hyperlinks': tappedHyperlinks,
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Compare new highlight information to old highlight information, returning an object with the properties that changed
|
|
817
|
+
const diffHighlight = (newHighlightInfo, oldHighlightInfo) => {
|
|
818
|
+
if (!oldHighlightInfo) return newHighlightInfo;
|
|
819
|
+
const changedHighlightInfo = {}
|
|
820
|
+
for (const key of Object.keys(newHighlightInfo)) {
|
|
821
|
+
if (oldHighlightInfo.hasOwnProperty(key) && oldHighlightInfo[key] !== newHighlightInfo[key]) {
|
|
822
|
+
changedHighlightInfo[key] = newHighlightInfo[key];
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return changedHighlightInfo;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Undraw the specified highlight
|
|
829
|
+
const undrawHighlight = (highlightInfo) => {
|
|
830
|
+
const highlightId = highlightInfo.highlightId;
|
|
831
|
+
|
|
832
|
+
// Remove <mark> highlights and HTML wrappers
|
|
833
|
+
if (this.annotatableContainer.querySelector(`[data-highlight-id="${highlightId}"]:not(g)`)) {
|
|
834
|
+
const overlappingHighlightIds = new Set();
|
|
835
|
+
this.annotatableContainer.querySelectorAll(`[data-highlight-id="${highlightId}"]:not(g)`).forEach(element => {
|
|
836
|
+
if (element.parentElement.dataset.highlightId) {
|
|
837
|
+
overlappingHighlightIds.add(element.parentElement.dataset.highlightId);
|
|
838
|
+
}
|
|
839
|
+
for (const childHighlight of element.querySelectorAll('[data-highlight-id]')) {
|
|
840
|
+
overlappingHighlightIds.add(childHighlight.dataset.highlightId);
|
|
841
|
+
}
|
|
842
|
+
if (element.tagName.toLowerCase() === 'mark') {
|
|
843
|
+
element.outerHTML = element.innerHTML;
|
|
844
|
+
} else {
|
|
845
|
+
element.remove();
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// Redraw overlapping highlights
|
|
850
|
+
overlappingHighlightIds.delete(highlightId);
|
|
851
|
+
if (overlappingHighlightIds.size > 0) {
|
|
852
|
+
for (const overlappingHighlightId of overlappingHighlightIds) {
|
|
853
|
+
undrawHighlight(highlightsById[overlappingHighlightId]);
|
|
854
|
+
}
|
|
855
|
+
this.drawHighlights(overlappingHighlightIds);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Normalize text nodes
|
|
859
|
+
const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.startParagraphId}, #${highlightInfo.endParagraphId}`);
|
|
860
|
+
rangeParagraphs.forEach(p => { p.normalize(); });
|
|
861
|
+
getCorrectedRangeObj(highlightId);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Remove SVG highlights
|
|
865
|
+
if (svgBackground.querySelector(`g[data-highlight-id="${highlightId}"]`)) {
|
|
866
|
+
const overlappingHighlightIds = new Set();
|
|
867
|
+
svgBackground.querySelectorAll(`g[data-highlight-id="${highlightId}"]`).forEach(element => {
|
|
868
|
+
element.remove();
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Remove Highlight API highlights
|
|
873
|
+
if (supportsHighlightApi && CSS.highlights.has(highlightId)) {
|
|
874
|
+
const ruleIndexesToDelete = [];
|
|
875
|
+
for (let r = 0; r < highlightApiStylesheet.cssRules.length; r++) {
|
|
876
|
+
if (highlightApiStylesheet.cssRules[r].selectorText.includes(`::highlight(${highlightInfo.escapedHighlightId})`)) ruleIndexesToDelete.push(r);
|
|
877
|
+
}
|
|
878
|
+
for (const index of ruleIndexesToDelete.reverse()) highlightApiStylesheet.deleteRule(index);
|
|
879
|
+
CSS.highlights.delete(highlightId);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Update selection background and handles
|
|
884
|
+
const updateSelectionUi = (changeType = 'appearance') => {
|
|
885
|
+
const selection = window.getSelection();
|
|
886
|
+
const selectionRange = selection.type === 'None' ? null : selection.getRangeAt(0);
|
|
887
|
+
|
|
888
|
+
// If the selection starts in another annotatable container, let the other container handle it
|
|
889
|
+
if (selection.anchorNode && selection.anchorNode.parentElement.closest('[data-hh-container]') && !this.annotatableContainer.contains(selection.anchorNode)) return;
|
|
890
|
+
|
|
891
|
+
const highlightInfo = highlightsById[activeHighlightId] ?? null;
|
|
892
|
+
const color = highlightInfo?.color ?? null;
|
|
893
|
+
const style = highlightInfo?.style ?? null;
|
|
894
|
+
const colorString = options.colors[color] ?? 'AccentColor';
|
|
895
|
+
|
|
896
|
+
// Draw SVG selection rects (SVG drawing mode only)
|
|
897
|
+
svgActiveOverlay.innerHTML = '';
|
|
898
|
+
if (activeHighlightId && options.drawingMode === 'svg') {
|
|
899
|
+
let range = getCorrectedRangeObj(activeHighlightId);
|
|
900
|
+
const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
|
|
901
|
+
const clientRects = getMergedClientRects(range, rangeParagraphs);
|
|
902
|
+
svgActiveOverlay.dataset.color = color;
|
|
903
|
+
svgActiveOverlay.dataset.style = style;
|
|
904
|
+
let svgContent = '';
|
|
905
|
+
for (const clientRect of clientRects) {
|
|
906
|
+
svgContent += getStyleTemplate(highlightInfo.style, 'svg', clientRect, true);
|
|
907
|
+
}
|
|
908
|
+
svgActiveOverlay.innerHTML = svgContent;
|
|
909
|
+
|
|
910
|
+
// Bring active highlight to the front
|
|
911
|
+
const svgHighlight = svgBackground.querySelector(`g[data-highlight-id="${activeHighlightId}"]`);
|
|
912
|
+
svgBackground.appendChild(svgHighlight);
|
|
913
|
+
svgBackground.appendChild(svgActiveOverlay);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (changeType === 'appearance') {
|
|
917
|
+
this.annotatableContainer.style.setProperty('--hh-color', `${colorString}`);
|
|
918
|
+
|
|
919
|
+
// 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).
|
|
920
|
+
if (activeHighlightId && options.drawingMode === 'svg') {
|
|
921
|
+
selectionStylesheet.replaceSync(`
|
|
922
|
+
${options.containerSelector} g[data-highlight-id="${activeHighlightId}"][data-style] { display: none; }
|
|
923
|
+
${options.containerSelector} .hh-wrapper-start[data-highlight-id="${activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${activeHighlightId}"] { display: none; }
|
|
924
|
+
${options.containerSelector} ::selection { background-color: transparent; }
|
|
925
|
+
`);
|
|
926
|
+
} else if (activeHighlightId) {
|
|
927
|
+
const styleTemplate = getStyleTemplate(style, 'css', null, true);
|
|
928
|
+
selectionStylesheet.replaceSync(`
|
|
929
|
+
${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
|
|
930
|
+
${options.containerSelector} mark[data-highlight-id="${activeHighlightId}"][data-style] { all: unset; }
|
|
931
|
+
${options.containerSelector} .hh-wrapper-start[data-highlight-id="${activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${activeHighlightId}"] { display: none; }
|
|
932
|
+
${options.containerSelector} ::selection { --hh-color: ${colorString}; ${styleTemplate} }
|
|
933
|
+
${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
|
|
934
|
+
`);
|
|
935
|
+
|
|
936
|
+
// No active highlight (show the regular selection UI)
|
|
937
|
+
} else {
|
|
938
|
+
selectionStylesheet.replaceSync(`
|
|
939
|
+
${options.containerSelector} ::selection { background-color: Highlight; color: inherit; }
|
|
940
|
+
${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
|
|
941
|
+
`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Send event
|
|
945
|
+
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:selectionupdate', { detail: { color: color, style: style, }}));
|
|
946
|
+
|
|
947
|
+
} else if (changeType === 'bounds') {
|
|
948
|
+
// Update selection handle location and visibility
|
|
949
|
+
if (selection.type === 'Range' && activeHighlightId && pointerType === 'mouse' && !activeSelectionHandle) {
|
|
950
|
+
const selectionRangeRects = selectionRange.getClientRects();
|
|
951
|
+
const startRect = selectionRangeRects[0];
|
|
952
|
+
const endRect = selectionRangeRects[selectionRangeRects.length-1];
|
|
953
|
+
const relativeAncestorClientRect = this.relativeAncestorElement.getBoundingClientRect();
|
|
954
|
+
const startNodeIsRtl = window.getComputedStyle(selectionRange.startContainer.parentElement).direction === 'rtl';
|
|
955
|
+
const endNodeIsRtl = window.getComputedStyle(selectionRange.endContainer.parentElement).direction === 'rtl';
|
|
956
|
+
|
|
957
|
+
for (const selectionHandle of selectionHandles) {
|
|
958
|
+
selectionHandle.style.visibility = 'visible';
|
|
959
|
+
let side;
|
|
960
|
+
if (selectionHandle.dataset.position === 'start') {
|
|
961
|
+
side = startNodeIsRtl ? 'right' : 'left';
|
|
962
|
+
selectionHandle.style.left = startRect[side] - relativeAncestorClientRect.left + 'px';
|
|
963
|
+
selectionHandle.style.height = startRect.height + 'px';
|
|
964
|
+
selectionHandle.style.top = startRect.top - relativeAncestorClientRect.top + 'px';
|
|
965
|
+
} else {
|
|
966
|
+
side = endNodeIsRtl ? 'left' : 'right';
|
|
967
|
+
selectionHandle.style.left = endRect[side] - relativeAncestorClientRect.left + 'px';
|
|
968
|
+
selectionHandle.style.height = endRect.height + 'px';
|
|
969
|
+
selectionHandle.style.top = endRect.top - relativeAncestorClientRect.top + 'px';
|
|
970
|
+
}
|
|
971
|
+
if (selectionHandle.dataset.side !== side) {
|
|
972
|
+
selectionHandle.dataset.side = side;
|
|
973
|
+
this.setOption('selectionHandles', options.selectionHandles);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
} else {
|
|
977
|
+
selectionHandles[0].style.visibility = 'hidden';
|
|
978
|
+
selectionHandles[1].style.visibility = 'hidden';
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Update the selection or highlight range to stay within the annotatable container
|
|
984
|
+
const snapRangeToBoundaries = (range, anchorNode = null) => {
|
|
985
|
+
let startNode = range.startContainer;
|
|
986
|
+
let endNode = range.endContainer;
|
|
987
|
+
let startOffset = range.startOffset;
|
|
988
|
+
let endOffset = range.endOffset;
|
|
989
|
+
|
|
990
|
+
// Prevent the range from going outside of the annotatable container
|
|
991
|
+
if (!this.annotatableContainer.contains(range.commonAncestorContainer)) {
|
|
992
|
+
if (anchorNode && !this.annotatableContainer.contains(anchorNode)) {
|
|
993
|
+
// Range is from a selection, and the selection anchor is outside of the container
|
|
994
|
+
return range.cloneRange().collapse(true);
|
|
995
|
+
} else if (anchorNode === startNode || this.annotatableContainer.contains(startNode)) {
|
|
996
|
+
// Range starts in the container but ends outside
|
|
997
|
+
endNode = getLastTextNode(this.annotatableParagraphs[this.annotatableParagraphs.length - 1]);
|
|
998
|
+
endOffset = endNode.length;
|
|
999
|
+
} else if (anchorNode === endNode || this.annotatableContainer.contains(endNode)) {
|
|
1000
|
+
// Range starts outside of the container but ends inside
|
|
1001
|
+
startNode = getFirstTextNode(this.annotatableParagraphs[0]);
|
|
1002
|
+
startOffset = 0;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Prevent the range from starting or ending in an element that doesn't match the paragraph selector
|
|
1007
|
+
if (this.annotatableContainer.contains(range.commonAncestorContainer) && (!startNode.parentElement.closest(options.paragraphSelector) || !endNode.parentElement.closest(options.paragraphSelector))) {
|
|
1008
|
+
annotatableParagraphsInRange = Array.from(this.annotatableParagraphs).filter((paragraph) => {
|
|
1009
|
+
const relativeStartPosition = startNode.compareDocumentPosition(paragraph);
|
|
1010
|
+
const relativeEndPosition = endNode.compareDocumentPosition(paragraph);
|
|
1011
|
+
return (relativeStartPosition & Node.DOCUMENT_POSITION_FOLLOWING || relativeStartPosition & Node.DOCUMENT_POSITION_CONTAINS) && (relativeEndPosition & Node.DOCUMENT_POSITION_PRECEDING || relativeEndPosition & Node.DOCUMENT_POSITION_CONTAINS);
|
|
1012
|
+
});
|
|
1013
|
+
if (annotatableParagraphsInRange.length === 0) {
|
|
1014
|
+
return range.cloneRange().collapse(true);
|
|
1015
|
+
}
|
|
1016
|
+
if (!startNode.parentElement.closest(options.paragraphSelector)) {
|
|
1017
|
+
startNode = getFirstTextNode(annotatableParagraphsInRange[0]);
|
|
1018
|
+
startOffset = 0;
|
|
1019
|
+
}
|
|
1020
|
+
if (!endNode.parentElement.closest(options.paragraphSelector)) {
|
|
1021
|
+
endNode = getLastTextNode(annotatableParagraphsInRange[annotatableParagraphsInRange.length - 1]);
|
|
1022
|
+
endOffset = endNode.length;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Snap to the nearest word
|
|
1027
|
+
if (options.snapToWord) {
|
|
1028
|
+
// If the range starts at the end of a text node, move it to start at the beginning of the following text node. This prevents the range from jumping across the text node boundary and selecting an extra word.
|
|
1029
|
+
if (startOffset === startNode.textContent.length) {
|
|
1030
|
+
let parentElement = range.commonAncestorContainer;
|
|
1031
|
+
let walker = document.createTreeWalker(parentElement, NodeFilter.SHOW_TEXT, (node) => node.parentNode.closest(options.paragraphSelector) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP);
|
|
1032
|
+
let nextTextNode = walker.nextNode();
|
|
1033
|
+
while (nextTextNode && nextTextNode !== startNode) nextTextNode = walker.nextNode();
|
|
1034
|
+
nextTextNode = walker.nextNode();
|
|
1035
|
+
if (nextTextNode) {
|
|
1036
|
+
startNode = nextTextNode;
|
|
1037
|
+
startOffset = 0;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Trim whitespace and dashes at range start and end
|
|
1042
|
+
while (/\s|\p{Pd}/u.test(startOffset < startNode.textContent.length && startNode.textContent[startOffset])) startOffset += 1;
|
|
1043
|
+
while (endOffset - 1 >= 0 && /\s|\p{Pd}/u.test(endNode.textContent[endOffset - 1])) endOffset -= 1;
|
|
1044
|
+
|
|
1045
|
+
// Expand range to word boundaries
|
|
1046
|
+
while (startOffset > 0 && /[^\s|\p{Pd}]/u.test(startNode.textContent[startOffset - 1])) startOffset -= 1;
|
|
1047
|
+
while (endOffset + 1 <= endNode.textContent.length && /[^\s|\p{Pd}]/u.test(endNode.textContent[endOffset])) endOffset += 1;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
let newRange = document.createRange();
|
|
1051
|
+
newRange.setStart(startNode, startOffset);
|
|
1052
|
+
newRange.setEnd(endNode, endOffset);
|
|
1053
|
+
return newRange;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Get the character offset relative to the annotatable paragraph
|
|
1057
|
+
// Adapted from https://stackoverflow.com/a/4812022/1349044
|
|
1058
|
+
const getParagraphOffset = (referenceTextNode, referenceTextNodeOffset) => {
|
|
1059
|
+
const paragraph = referenceTextNode.parentElement.closest(options.paragraphSelector);
|
|
1060
|
+
const referenceRange = document.createRange();
|
|
1061
|
+
referenceRange.selectNodeContents(paragraph);
|
|
1062
|
+
referenceRange.setEnd(referenceTextNode, referenceTextNodeOffset);
|
|
1063
|
+
const paragraphOffset = referenceRange.toString().length;
|
|
1064
|
+
return [ paragraph.id, paragraphOffset ];
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Get the character offset relative to the deepest relevant text node
|
|
1068
|
+
const getTextNodeAndOffset = (parentElement, targetOffset) => {
|
|
1069
|
+
let textNode, firstTextNode, currentOffset = 0;
|
|
1070
|
+
const walker = document.createTreeWalker(parentElement, NodeFilter.SHOW_TEXT);
|
|
1071
|
+
while (textNode = walker.nextNode()) {
|
|
1072
|
+
if (!firstTextNode) firstTextNode = walker.currentNode;
|
|
1073
|
+
currentOffset += textNode.textContent.length;
|
|
1074
|
+
if (currentOffset >= targetOffset) {
|
|
1075
|
+
const relativeOffset = textNode.textContent.length - currentOffset + targetOffset
|
|
1076
|
+
return [ textNode, relativeOffset ];
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
// TODO: Direction isn't always accurate (maybe it resets when selection is cleared and set to a new range programmatically?)
|
|
1080
|
+
const direction = window.getSelection().direction;
|
|
1081
|
+
if (direction == 'backward') {
|
|
1082
|
+
return [ firstTextNode, 0 ];
|
|
1083
|
+
} else {
|
|
1084
|
+
const lastTextNode = walker.currentNode;
|
|
1085
|
+
return [ lastTextNode, lastTextNode.textContent.length ];
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Get the first text node in an element
|
|
1090
|
+
const getFirstTextNode = (element) => {
|
|
1091
|
+
const firstTextNodeWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
1092
|
+
return firstTextNodeWalker.nextNode();
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Get the last text node in an element
|
|
1096
|
+
const getLastTextNode = (element) => {
|
|
1097
|
+
const lastTextNodeWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
1098
|
+
let nextNode;
|
|
1099
|
+
while (nextNode = lastTextNodeWalker.nextNode());
|
|
1100
|
+
return lastTextNodeWalker.currentNode;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Update appearance stylesheet (user-defined colors and styles)
|
|
1104
|
+
const updateAppearanceStylesheet = () => {
|
|
1105
|
+
appearanceStylesheet.replaceSync('');
|
|
1106
|
+
for (const color of Object.keys(options.colors)) {
|
|
1107
|
+
appearanceStylesheet.insertRule(`[data-color="${color}"] { --hh-color: ${options.colors[color]}; }`);
|
|
1108
|
+
}
|
|
1109
|
+
for (const style of Object.keys(options.styles)) {
|
|
1110
|
+
const styleTemplate = getStyleTemplate(style, 'css', null);
|
|
1111
|
+
appearanceStylesheet.insertRule(`mark[data-highlight-id][data-style="${style}"] { ${styleTemplate} }`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Get style template for a given highlight style
|
|
1116
|
+
const getStyleTemplate = (style, type, clientRect = null, active = false) => {
|
|
1117
|
+
style = options.styles.hasOwnProperty(style) ? style : options.defaultStyle;
|
|
1118
|
+
let styleTemplate = options.styles[style]?.[type] ?? '';
|
|
1119
|
+
if (active) {
|
|
1120
|
+
styleTemplate = options.styles[style]?.[`${type}-active`] ?? styleTemplate;
|
|
1121
|
+
}
|
|
1122
|
+
if (!styleTemplate) {
|
|
1123
|
+
console.warn(`Highlight style "${style}" in options does not have a defined "${type}" value.`);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (type === 'svg' && clientRect) {
|
|
1127
|
+
const relativeAncestorClientRect = this.relativeAncestorElement.getBoundingClientRect();
|
|
1128
|
+
styleTemplate = styleTemplate
|
|
1129
|
+
.replaceAll('{x}', clientRect.x - relativeAncestorClientRect.x)
|
|
1130
|
+
.replaceAll('{y}', clientRect.y - relativeAncestorClientRect.y)
|
|
1131
|
+
.replaceAll('{width}', clientRect.width)
|
|
1132
|
+
.replaceAll('{height}', clientRect.height)
|
|
1133
|
+
.replaceAll('{top}', clientRect.top - relativeAncestorClientRect.top)
|
|
1134
|
+
.replaceAll('{right}', clientRect.right - relativeAncestorClientRect.right)
|
|
1135
|
+
.replaceAll('{bottom}', clientRect.bottom - relativeAncestorClientRect.bottom)
|
|
1136
|
+
.replaceAll('{left}', clientRect.left - relativeAncestorClientRect.left);
|
|
1137
|
+
}
|
|
1138
|
+
return styleTemplate;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Add a CSS stylesheet to the document
|
|
1142
|
+
function addStylesheet(stylesheets, stylesheetKey) {
|
|
1143
|
+
let stylesheet = stylesheets[stylesheetKey];
|
|
1144
|
+
if (!stylesheet) {
|
|
1145
|
+
if (supportsCssStylesheetApi) {
|
|
1146
|
+
stylesheet = new CSSStyleSheet();
|
|
1147
|
+
document.adoptedStyleSheets.push(stylesheet);
|
|
1148
|
+
} else {
|
|
1149
|
+
// For browsers that don't fully support the CSSStyleSheet API, such as Safari < 16.4.
|
|
1150
|
+
// See https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#browser_compatibility
|
|
1151
|
+
stylesheet = document.createElement('style');
|
|
1152
|
+
stylesheet.appendChild(document.createTextNode(''));
|
|
1153
|
+
stylesheet.replaceSync = (newContent) => {
|
|
1154
|
+
stylesheet.textContent = newContent;
|
|
1155
|
+
}
|
|
1156
|
+
stylesheet.insertRule = (newContent) => {
|
|
1157
|
+
stylesheet.textContent += newContent;
|
|
1158
|
+
}
|
|
1159
|
+
document.head.appendChild(stylesheet);
|
|
1160
|
+
}
|
|
1161
|
+
stylesheets[stylesheetKey] = stylesheet;
|
|
1162
|
+
}
|
|
1163
|
+
return stylesheet;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Remove CSS stylesheets (only those from the current Highlighter instance)
|
|
1167
|
+
function removeStylesheets(stylesheets) {
|
|
1168
|
+
for (const stylesheet of Object.values(stylesheets)) {
|
|
1169
|
+
if (supportsCssStylesheetApi) {
|
|
1170
|
+
const adoptedStylesheetIndex = document.adoptedStyleSheets.indexOf(stylesheet);
|
|
1171
|
+
document.adoptedStyleSheets.splice(adoptedStylesheetIndex, 1);
|
|
1172
|
+
} else {
|
|
1173
|
+
stylesheet.remove();
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Restore the previous selection range in case the browser clears the selection
|
|
1179
|
+
const getRestoredSelectionOrCaret = (selection, pointerEvent = null) => {
|
|
1180
|
+
if (selection.type === 'None') {
|
|
1181
|
+
if (previousSelectionRange) {
|
|
1182
|
+
// iOS Safari deselects text when a button is tapped. This restores the selection.
|
|
1183
|
+
selection.addRange(previousSelectionRange);
|
|
1184
|
+
} else if (pointerEvent) {
|
|
1185
|
+
// 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.
|
|
1186
|
+
let range = getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY);
|
|
1187
|
+
selection.addRange(range);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return selection;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Fix highlight range if the DOM changed and made the previous highlight range invalid
|
|
1194
|
+
const getCorrectedRangeObj = (highlightId) => {
|
|
1195
|
+
const highlightInfo = highlightsById[highlightId];
|
|
1196
|
+
const highlightRange = highlightInfo?.rangeObj;
|
|
1197
|
+
if (highlightRange && (
|
|
1198
|
+
highlightRange.startContainer.nodeType !== Node.TEXT_NODE || highlightRange.endContainer.nodeType !== Node.TEXT_NODE ||
|
|
1199
|
+
highlightRange.startOffset > highlightRange.startContainer.textContent.length - 1 ||
|
|
1200
|
+
highlightRange.endOffset > highlightRange.endContainer.textContent.length
|
|
1201
|
+
)) {
|
|
1202
|
+
const [ startNode, startOffset ] = getTextNodeAndOffset(document.getElementById(highlightInfo.startParagraphId), highlightInfo.startParagraphOffset);
|
|
1203
|
+
const [ endNode, endOffset ] = getTextNodeAndOffset(document.getElementById(highlightInfo.endParagraphId), highlightInfo.endParagraphOffset);
|
|
1204
|
+
highlightRange.setStart(startNode, startOffset);
|
|
1205
|
+
highlightRange.setEnd(endNode, endOffset);
|
|
1206
|
+
}
|
|
1207
|
+
return highlightRange;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Convert tap or click to a selection range
|
|
1211
|
+
// Adapted from https://stackoverflow.com/a/12924488/1349044
|
|
1212
|
+
const getCaretFromCoordinates = (clientX, clientY, checkAnnotatable = false, checkXDistance = false, checkYDistance = false) => {
|
|
1213
|
+
let range;
|
|
1214
|
+
if (supportsCaretPositionFromPoint) {
|
|
1215
|
+
let caretPosition = document.caretPositionFromPoint(clientX, clientY);
|
|
1216
|
+
range = document.createRange();
|
|
1217
|
+
range.setStart(caretPosition.offsetNode, caretPosition.offset);
|
|
1218
|
+
range.collapse(true);
|
|
1219
|
+
} else if (supportsCaretRangeFromPoint) {
|
|
1220
|
+
range = document.caretRangeFromPoint(clientX, clientY);
|
|
1221
|
+
}
|
|
1222
|
+
if (!range) return;
|
|
1223
|
+
if (checkXDistance || checkYDistance) {
|
|
1224
|
+
const maxDistance = 30;
|
|
1225
|
+
const caretClientRect = range.getBoundingClientRect();
|
|
1226
|
+
if (checkXDistance && Math.abs(clientX - caretClientRect.x) > maxDistance) return;
|
|
1227
|
+
if (checkYDistance && Math.abs(clientY - caretClientRect.top) > maxDistance && Math.abs(clientY - caretClientRect.bottom) > maxDistance) return;
|
|
1228
|
+
}
|
|
1229
|
+
if (checkAnnotatable && !range.startContainer.parentElement.closest(options.paragraphSelector)) return;
|
|
1230
|
+
return range;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Check if a point is in a DOMRect
|
|
1234
|
+
const isPointInRect = (x, y, rect, padding = 0) => {
|
|
1235
|
+
return (
|
|
1236
|
+
x >= rect.x - padding && x <= rect.x + rect.width + padding &&
|
|
1237
|
+
y >= rect.y - padding && y <= rect.y + rect.height + padding
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Get merged client rects from the highlight range
|
|
1242
|
+
const getMergedClientRects = (range, paragraphs) => {
|
|
1243
|
+
const unmergedRects = Array.from(range.getClientRects());
|
|
1244
|
+
const mergedRects = [];
|
|
1245
|
+
|
|
1246
|
+
// Remove element rects (only text node rects are needed)
|
|
1247
|
+
const ancestorElementsInRange = [];
|
|
1248
|
+
for (const paragraph of paragraphs) {
|
|
1249
|
+
let element = paragraph;
|
|
1250
|
+
while (range.commonAncestorContainer.contains(element)) {
|
|
1251
|
+
if (!ancestorElementsInRange.includes(element)) ancestorElementsInRange.push(element);
|
|
1252
|
+
element = element.parentElement;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
for (let ur = unmergedRects.length - 1; ur >= 0; ur--) {
|
|
1256
|
+
const rect = unmergedRects[ur];
|
|
1257
|
+
for (const element of ancestorElementsInRange) {
|
|
1258
|
+
const elementRect = element.getBoundingClientRect();
|
|
1259
|
+
if (Math.round(elementRect.width) === Math.round(rect.width) && Math.round(elementRect.height) === Math.round(rect.height) && Math.round(elementRect.top) === Math.round(rect.top) && Math.round(elementRect.left) === Math.round(rect.left)) {
|
|
1260
|
+
unmergedRects.splice(ur, 1);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Loop through the highlight's paragraphs
|
|
1266
|
+
for (const paragraph of paragraphs) {
|
|
1267
|
+
const paragraphRect = paragraph.getBoundingClientRect();
|
|
1268
|
+
const computedParagraphStyle = window.getComputedStyle(paragraph);
|
|
1269
|
+
const paragraphLineHeight = parseInt((computedParagraphStyle.lineHeight === 'normal' ? computedParagraphStyle.fontSize : computedParagraphStyle.lineHeight).replace('px', ''));
|
|
1270
|
+
const paragraphTopPadding = parseInt(computedParagraphStyle.getPropertyValue('padding-top').replace('px', ''));
|
|
1271
|
+
|
|
1272
|
+
// Get line positions (bottom edge of each line)
|
|
1273
|
+
let linePositions = new Set();
|
|
1274
|
+
let lineWalker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
|
|
1275
|
+
while (textNode = lineWalker.nextNode()) {
|
|
1276
|
+
const computedStyle = window.getComputedStyle(textNode.parentElement);
|
|
1277
|
+
const lineHeight = parseInt((computedStyle.lineHeight === 'normal' ? computedStyle.fontSize : computedStyle.lineHeight).replace('px', ''));
|
|
1278
|
+
if (lineHeight >= paragraphLineHeight) {
|
|
1279
|
+
const referenceRange = document.createRange();
|
|
1280
|
+
referenceRange.selectNode(textNode);
|
|
1281
|
+
for (const rangeRect of referenceRange.getClientRects()) linePositions.add(rangeRect.bottom);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
linePositions = Array.from(linePositions);
|
|
1285
|
+
|
|
1286
|
+
// Create a merged rect for each line
|
|
1287
|
+
for (let ln = 0; ln < linePositions.length; ln++) {
|
|
1288
|
+
const linePosition = linePositions[ln];
|
|
1289
|
+
const previousLinePosition = ln === 0 ? paragraphRect.top + paragraphTopPadding : linePositions[ln-1];
|
|
1290
|
+
const mergedRect = new DOMRect(-1, previousLinePosition, -1, linePosition - previousLinePosition);
|
|
1291
|
+
if (mergedRects.length > 0 && ln === 1 && mergedRects[mergedRects.length-1].height < mergedRect.height) {
|
|
1292
|
+
// Increase the row height for the first line in the paragraph to match the second line
|
|
1293
|
+
mergedRects[mergedRects.length-1].y += mergedRects[mergedRects.length-1].height - mergedRect.height;
|
|
1294
|
+
mergedRects[mergedRects.length-1].height = mergedRect.height;
|
|
1295
|
+
}
|
|
1296
|
+
for (let r = 0; r < unmergedRects.length; r++) {
|
|
1297
|
+
const rect = unmergedRects[r];
|
|
1298
|
+
const rectVerticalPosition = rect.y + (rect.height / 2);
|
|
1299
|
+
if (rectVerticalPosition > mergedRect.y && rectVerticalPosition < mergedRect.y + mergedRect.height) {
|
|
1300
|
+
if (mergedRect.x === -1) {
|
|
1301
|
+
mergedRect.x = rect.x;
|
|
1302
|
+
mergedRect.width = rect.width;
|
|
1303
|
+
}
|
|
1304
|
+
// Process then remove rects that apply to the current line
|
|
1305
|
+
minLeft = Math.min(mergedRect.x, rect.x);
|
|
1306
|
+
maxRight = Math.max(mergedRect.right, rect.right);
|
|
1307
|
+
mergedRect.width = maxRight - minLeft;
|
|
1308
|
+
mergedRect.x = minLeft;
|
|
1309
|
+
unmergedRects.splice(r, 1); r--;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
if (mergedRect.width > 0) mergedRects.push(mergedRect);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return mergedRects;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Debounce a function to prevent it from being executed too frequently
|
|
1320
|
+
// Adapted from https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086
|
|
1321
|
+
function debounce(func, wait) {
|
|
1322
|
+
let timeout;
|
|
1323
|
+
return function executedFunction(...args) {
|
|
1324
|
+
const later = () => {
|
|
1325
|
+
clearTimeout(timeout);
|
|
1326
|
+
func(...args);
|
|
1327
|
+
};
|
|
1328
|
+
clearTimeout(timeout);
|
|
1329
|
+
timeout = setTimeout(later, wait);
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
updateAppearanceStylesheet();
|
|
1334
|
+
updateSelectionUi('appearance');
|
|
1335
|
+
this.setOption('selectionHandles', options.selectionHandles);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
// -------- DEFAULTS --------
|
|
1340
|
+
|
|
1341
|
+
// Keep track of all Highlighter instances
|
|
1342
|
+
let hhHighlighters = [];
|
|
1343
|
+
|
|
1344
|
+
// Check browser type
|
|
1345
|
+
const isTouchDevice = navigator.maxTouchPoints > 0;
|
|
1346
|
+
const isWebKit = /^((?!Chrome|Firefox|Android|Samsung).)*AppleWebKit/i.test(navigator.userAgent);
|
|
1347
|
+
const isWKWebView = isWebKit && window.webkit?.messageHandlers;
|
|
1348
|
+
const supportsCaretPositionFromPoint = document.caretPositionFromPoint;
|
|
1349
|
+
const supportsCaretRangeFromPoint = document.caretRangeFromPoint;
|
|
1350
|
+
const supportsCssStylesheetApi = CSSStyleSheet?.prototype?.replaceSync;
|
|
1351
|
+
const supportsHighlightApi = CSS.highlights;
|
|
1352
|
+
|
|
1353
|
+
// Workaround to allow programmatic text selection on tap in iOS Safari
|
|
1354
|
+
// See https://stackoverflow.com/a/79261423/1349044
|
|
1355
|
+
if (isTouchDevice && isWebKit) {
|
|
1356
|
+
const tempInput = document.createElement('input');
|
|
1357
|
+
tempInput.style.position = 'fixed';
|
|
1358
|
+
tempInput.style.top = 0;
|
|
1359
|
+
tempInput.style.opacity = 0;
|
|
1360
|
+
tempInput.style.height = 0;
|
|
1361
|
+
tempInput.style.fontSize = '16px'; // Prevent page zoom on input focus
|
|
1362
|
+
tempInput.inputMode = 'none'; // Don't show keyboard
|
|
1363
|
+
tempInput.tabIndex = -1; // Prevent user from tabbing to input
|
|
1364
|
+
tempInput.ariaHidden = 'true'; // Hide from screen readers
|
|
1365
|
+
const initializeSelection = (event) => {
|
|
1366
|
+
if (document.readyState !== 'complete') return setTimeout(initializeSelection, 20);
|
|
1367
|
+
if (!tempInput.parentElement) document.body.append(tempInput);
|
|
1368
|
+
tempInput.focus();
|
|
1369
|
+
setTimeout(() => {
|
|
1370
|
+
tempInput.blur();
|
|
1371
|
+
}, 100);
|
|
1372
|
+
}
|
|
1373
|
+
initializeSelection();
|
|
1374
|
+
document.addEventListener('visibilitychange', (event) => {
|
|
1375
|
+
if (document.visibilityState === 'visible') initializeSelection();
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Default function for generating a highlight ID
|
|
1380
|
+
const hhGetNewHighlightId = () => {
|
|
1381
|
+
return 'hh-' + Date.now().toString();
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Default options
|
|
1385
|
+
let hhDefaultOptions = {
|
|
1386
|
+
containerSelector: 'body',
|
|
1387
|
+
paragraphSelector: 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id], p[id], ol[id], ul[id], dl[id], tr[id]',
|
|
1388
|
+
colors: {
|
|
1389
|
+
'red': 'hsl(360, 100%, 70%)',
|
|
1390
|
+
'orange': 'hsl(30, 100%, 60%)',
|
|
1391
|
+
'yellow': 'hsl(50, 100%, 60%)',
|
|
1392
|
+
'green': 'hsl(80, 100%, 45%)',
|
|
1393
|
+
'blue': 'hsl(180, 100%, 45%)',
|
|
1394
|
+
},
|
|
1395
|
+
styles: {
|
|
1396
|
+
'fill': {
|
|
1397
|
+
'css': 'background-color: hsl(from var(--hh-color) h s l / 50%);',
|
|
1398
|
+
'svg': '<rect x="{x}" y="{y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 50%); width: calc({width}px + ({height}px / 6)); height: calc({height}px * 0.85); transform: translateX(calc({height}px / -12)) translateY(calc({height}px * 0.14));" />',
|
|
1399
|
+
'css-active': 'background-color: hsl(from var(--hh-color) h s l / 80%);',
|
|
1400
|
+
'svg-active': `
|
|
1401
|
+
<rect x="{x}" y="{y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 80%); width: calc({width}px + ({height}px / 6)); height: calc({height}px * 0.85); transform: translateX(calc({height}px / -12)) translateY(calc({height}px * 0.14));" />
|
|
1402
|
+
`,
|
|
1403
|
+
},
|
|
1404
|
+
'single-underline': {
|
|
1405
|
+
'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;',
|
|
1406
|
+
'svg': '<rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 12); transform: translateY(calc({height}px * 0.9));" />',
|
|
1407
|
+
'css-active': 'background-color: hsl(from var(--hh-color) h s l / 25%); text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-thickness: 0.15em; text-underline-offset: 0.15em; text-decoration-skip-ink: none;',
|
|
1408
|
+
'svg-active': `
|
|
1409
|
+
<rect x="{x}" y="{y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 25%); width: calc({width}px + ({height}px / 6)); height: calc({height}px * 0.85); transform: translateX(calc({height}px / -12)) translateY(calc({height}px * 0.14));" />
|
|
1410
|
+
<rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 12); transform: translateY(calc({height}px * 0.9));" />
|
|
1411
|
+
`,
|
|
1412
|
+
},
|
|
1413
|
+
'double-underline': {
|
|
1414
|
+
'css': 'text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-style: double; text-decoration-skip-ink: none;',
|
|
1415
|
+
'svg': '<rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 15); transform: translateY(calc({height}px * 0.9));" /><rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 15); transform: translateY(calc({height}px * 1.05));" />',
|
|
1416
|
+
'css-active': 'background-color: hsl(from var(--hh-color) h s l / 25%); text-decoration: underline; text-decoration-color: var(--hh-color); text-decoration-style: double; text-decoration-skip-ink: none;',
|
|
1417
|
+
'svg-active': `
|
|
1418
|
+
<rect x="{x}" y="{y}" rx="4" style="fill: hsl(from var(--hh-color) h s l / 25%); width: calc({width}px + ({height}px / 6)); height: calc({height}px * 0.85); transform: translateX(calc({height}px / -12)) translateY(calc({height}px * 0.14));" />
|
|
1419
|
+
<rect x="{x}" y="{y}" style="fill: var(--hh-color); width: {width}px; height: calc({height}px / 15); transform: translateY(calc({height}px * 0.9));" />
|
|
1420
|
+
<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));" />
|
|
1421
|
+
`,
|
|
1422
|
+
},
|
|
1423
|
+
'colored-text': {
|
|
1424
|
+
'css': 'color: var(--hh-color);',
|
|
1425
|
+
'svg': '',
|
|
1426
|
+
},
|
|
1427
|
+
'redacted': {
|
|
1428
|
+
'css': 'background-color: transparent; color: transparent;',
|
|
1429
|
+
'svg': '',
|
|
1430
|
+
},
|
|
1431
|
+
},
|
|
1432
|
+
wrappers: {},
|
|
1433
|
+
selectionHandles: {
|
|
1434
|
+
'left': '<div class="hh-default-handle"></div>',
|
|
1435
|
+
'right': '<div class="hh-default-handle"></div>',
|
|
1436
|
+
},
|
|
1437
|
+
rememberStyle: true,
|
|
1438
|
+
snapToWord: false,
|
|
1439
|
+
autoTapToActivate: true,
|
|
1440
|
+
longPressTimeout: 500,
|
|
1441
|
+
pointerMode: 'auto',
|
|
1442
|
+
drawingMode: 'svg',
|
|
1443
|
+
defaultColor: 'yellow',
|
|
1444
|
+
defaultStyle: 'fill',
|
|
1445
|
+
defaultWrapper: 'none',
|
|
1446
|
+
highlightIdFunction: hhGetNewHighlightId,
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
console.info('Highlighter loaded');
|