@samuelbradshaw/highlight-helper-js 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,20 +91,20 @@ You can also install it using [npm](https://www.npmjs.com/package/@samuelbradsha
91
91
  - **removeHighlights(highlightIds)** – Remove the specified highlights. If `highlightIds` isn’t provided, all highlights will be removed.
92
92
  - **getActiveHighlightId()** – Get the ID of the currently-active highlight (if there is one).
93
93
  - **getHighlightInfo(highlightIds, paragraphId)** – Get highlight information for an array of highlight IDs. If no highlight IDs are passed, information for all relevant highlights will be returned. If `paragraphId` is provided, only highlights that start in the specified paragraph will be returned. Highlights will be sorted based on their position on the page.
94
- - **setOption(key, value)** – Change one of the initialized options. Available options are described below.
94
+ - **setOptions(optionsToUpdate)** – Change one or more options. `optionsToUpdate` is an object with one or more of the option keys described below.
95
95
  - **getOptions()** – Get the initialized options, including defaults for any options that weren’t explicitly set.
96
96
  - **removeHighlighter()** – Removes the current Highlighter instance and all of its highlights from the page. If a new Highlighter instance is created with the same container (as defined by the `containerSelector` option), the previous instance for that container will be removed automatically (there can be multiple Highlighter instances on a page, but each one needs to have a different container).
97
97
 
98
98
 
99
99
  ### <a name="options"></a>Options
100
100
 
101
- Options can be provided when HighlightHelper.js is initialized. They can also be set on demand using `setOption(key, value)` (described above). All of these settings are optional. If not specified, the default value for each option will be used.
101
+ Options can be provided when HighlightHelper.js is initialized. They can be updated after initialization using `setOptions()`.
102
102
 
103
103
  - **containerSelector** – CSS selector for the section of the page that should be annotatable. Default: `body`.
104
104
  - **paragraphSelector** – CSS selector for the paragraphs (or other block elements) on the page that should be annotatable. Each paragraph is expected to have an ID attribute in the HTML, which is used to keep track of where a highlight starts and ends. Default: `h1[id], h2[id], h3[id], h4[id], h5[id], h6[id], p[id], ol[id], ul[id], dl[id], tr[id]`.
105
105
  - **colors** – Object that describes available highlight colors. Keys are color names, and values are [CSS color values](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default: red, orange, yellow, green, blue (see full default values at the bottom of highlight-helper.js).
106
106
  - **styles** – Object that describes available highlight styles. Keys are style names, and there are four properties for each style: `css`, `svg`, `css-active` (optional), and `svg-active` (optional). `css` is a CSS string used for styling highlights in `highlight-api` and `mark-elements` drawing modes, as well as for read-only highlights. `svg` is an SVG string (one or more SVG shapes) to represent the highlight in `svg` drawing mode. `css-active` and `svg-active` are alternative styles to be used when a highlight is active. The CSS custom property `var(--hh-color)` can be used to reference the highlight color value. For SVG highlights, the following variables (if present) will be replaced with relevant values from the highlight’s DOMRect: `{x}`, `{y}`, `{width}`, `{height}`, `{top}`, `{bottom}`, `{left}`, `{right}`. Default: fill, single-underline, double-underline, colored-text, redacted (see full default values at the bottom of highlight-helper.js).
107
- - **wrappers** – Object that describes available highlight wrappers. Keys are wrapper names, and values are objects with two optional properties: `start` (HTML string to be inserted at the beginning of the highlight), and `end` (HTML string to be inserted at the end of the highlight). Only read-only highlights support wrappers. `var(--hh-color)` can be used to reference the highlight color value. Variables surrounded by curly brackets will be replaced with variables stored in the `wrapperVariables` attribute of the highlight, if applicable. See default values at the bottom of highlight-helper.js.
107
+ - **wrappers** – Object that describes available highlight wrappers. Keys are wrapper names, and values are objects with two optional properties: `start` (HTML string to be inserted at the beginning of the highlight), and `end` (HTML string to be inserted at the end of the highlight). `var(--hh-color)` can be used to reference the highlight color value. Variables surrounded by curly brackets will be replaced with variables stored in the `wrapperVariables` attribute of the highlight, if applicable. See default values at the bottom of highlight-helper.js.
108
108
  - **selectionHandles** – Object that describes custom selection handles that a user can drag to resize a selection or highlight. Because most touch devices have built-in selection handles, custom selection handles will only show when using a mouse or trackpad. There are two properties: `left` (HTML string to be used for the left selection handle) and `right` (HTML string to be used for the right selection handle). `var(--hh-color)` can be used to reference the highlight color value. See default values at the bottom of highlight-helper.js.
109
109
  - **rememberStyle** – Whether the most recent color, style, and wrapper should be remembered and used by default the next time the user creates a highlight. Boolean. Default: `true`.
110
110
  - **snapToWord** – Whether highlights should snap to the nearest word boundary. Spaces and dashes are considered word boundaries (this option may not work correctly in all languages). Boolean. Default: `false`.
@@ -129,11 +129,11 @@ HighlightHelper.js sends [custom events](https://developer.mozilla.org/en-US/doc
129
129
  - **hh:highlightcreate** – Sent when a new highlight is created. Includes information about the highlight.
130
130
  - **hh:highlightupdate** – Sent when the highlight changes. Includes information about the highlight, and a list of the attributes that changed.
131
131
  - **hh:highlightremove** – Sent when a highlight is removed. Includes the ID of the removed highlight.
132
- - **hh:tap** – Sent when a user taps in the annotatable container (potentially trying to activate a highlight or link). This will only be sent if there isn’t currently an active highlight. Includes the location of the tap, the highlight(s) that were tapped, and the hyperlink(s) that were tapped, if any.
132
+ - **hh:tap** – Sent when a user taps in the annotatable container (potentially trying to tap a highlight, wrapper, or link). This will only be sent if there isn’t currently an active highlight. Includes the location of the tap, and the highlight, wrapper, or hyperlink that was tapped, if any.
133
133
  - **hh:selectionupdate** – Sent when the selection color or style changes. Can be used to update custom selection UI, such as the color of selection handles. Includes the key of the color and style.
134
134
  - **hh:highlightactivate** – Sent when a highlight is activated. Includes information about the activated highlight.
135
135
  - **hh:highlightdeactivate** – Sent when a highlight is deactivated. Includes the ID of the deactivated highlight.
136
- - **hh:ambiguousaction** – Sent when a user taps on overlapping highlights or an overlapping highlight and link. Includes the location of the tap, the highlight(s) that were tapped, and the hyperlink(s) that were tapped. This event will not be sent if `autoTapToActivate` is set to `false`.
136
+ - **hh:ambiguousaction** – Sent when a user taps on overlapping highlights, wrappers, and/or links. Includes the location of the tap, and the highlight(s), wrapper(s), and hyperlink(s) that were tapped. This event will not be sent if `autoTapToActivate` is set to `false`.
137
137
  - **selectionchange** – This is a [standard JavaScript event](https://developer.mozilla.org/en-US/docs/Web/API/Document/selectionchange_event) (not specific to HighlightHelper.js), but it’s worth mentioning here because it can be useful for adding custom selection UI around the selected text (such as a floating annotation menu). When this sends, you can call `window.getSelection()` to get the current [Selection object](https://developer.mozilla.org/en-US/docs/Web/API/Selection).
138
138
 
139
139
 
package/demo.html CHANGED
@@ -19,19 +19,19 @@
19
19
  --gray-90: hsl(0, 0%, 30%);
20
20
  --gray-50: hsl(0, 0%, 70%);
21
21
  }}
22
-
22
+
23
23
  body {
24
24
  background-color: var(--page-bg);
25
25
  color: var(--text-color);
26
26
  line-height: 1.4;
27
27
  }
28
- img { width: 120px; float: right; }
28
+ main { margin-right: 2em; }
29
29
  a { color: var(--gray-50); }
30
30
  button {
31
31
  cursor: pointer;
32
32
  user-select: none;
33
33
  }
34
-
34
+
35
35
  #annotation-menu {
36
36
  font-family: "Helvetica", sans-serif;
37
37
  font-size: 0.8em;
@@ -54,7 +54,10 @@
54
54
  button[name="hh-color"][value="yellow"] { background-color: hsl(50, 98%, 61%); }
55
55
  button[name="hh-color"][value="green"] { background-color: hsl(75, 70%, 49%); }
56
56
  button[name="hh-color"][value="blue"] { background-color: hsl(182, 86%, 47%); }
57
- button[name="hh-style"], button[name="hh-wrapper"] { color: inherit; border: 2px solid var(--gray-50); }
57
+ button[name="hh-style"], button[name="hh-wrapper"] {
58
+ position: relative;
59
+ color: inherit; border: 2px solid var(--gray-50);
60
+ }
58
61
  button[name="hh-remove"] { background-color: var(--gray-50); color: var(--page-bg); }
59
62
  .highlight-placeholder {
60
63
  background-color: var(--gray-90);
@@ -68,10 +71,25 @@
68
71
  font-size: smaller;
69
72
  line-height: 0.5em;
70
73
  &::before {
71
- content: attr(data-marker) / attr(data-accessibility-label);
74
+ content: attr(data-content) / attr(data-accessibility-label);
75
+ }
76
+ }
77
+ .note-container {
78
+ display: inline-block;
79
+ visibility: visible;
80
+ font-style: normal;
81
+ height: 1em;
82
+ }
83
+ .note {
84
+ position: absolute;
85
+ right: 0.5rem;
86
+ font-size: 1.2rem;
87
+ margin-top: -0.2rem;
88
+ &::before {
89
+ content: attr(data-content) / attr(data-accessibility-label);
72
90
  }
73
91
  }
74
-
92
+
75
93
  #disambiguation-panel {
76
94
  color: inherit;
77
95
  background-color: inherit;
@@ -106,13 +124,12 @@
106
124
  <body>
107
125
  <main>
108
126
  <h1 id="title1">HighlightHelper.js Demo</h1>
109
- <img src="https://upload.wikimedia.org/wikipedia/commons/d/db/CMYK-color_model.png">
110
127
  <p id="p1"><b>HighlightHelper.js</b> enables highlighting or underlining text in an HTML page. Highlights are drawn via <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/SVG">SVG shapes</a>, the <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API">Custom Highlight API</a>, or <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/mark">HTML mark elements</a>. Highlights can be drawn across multiple paragraphs or <b>inline <i>style</i></b> elements—they can even overlap!</p>
111
128
  <p id="p2">Select text, then choose a color and style. Tap away when you’re done. Tap to activate it again for editing. Secondary elements like images (right) and Japanese ruby/furigana (below) are highlighted around.</p>
112
129
  <p id="p3">わたしニーファイは<ruby><rb>善</rb><rt>よ</rt></ruby>い<ruby><rb>両</rb><rt>りょう</rt></ruby><ruby><rb>親</rb><rt>しん</rt></ruby>から<ruby><rb>生</rb><rt>う</rt></ruby>まれたので,<ruby><rb>父</rb><rt>ちち</rt></ruby>が<ruby><rb>学</rb><rt>まな</rt></ruby>んだすべてのことの<ruby><rb>中</rb><rt>なか</rt></ruby>から<ruby><rb>幾</rb><rt>いく</rt></ruby>らかの<ruby><rb>教</rb><rt>おし</rt></ruby>えを<ruby><rb>受</rb><rt>う</rt></ruby>けた。</p>
113
130
  <p id="p4" style="direction: rtl;">فِي الْبَدْءِ خَلَقَ اللهُ السَّمَاوَاتِ وَالأَرْضَ. وَكَانَتِ الأَرْضُ خَرِبَةً وَخَالِيَةً، وَعَلَى وَجْهِ الْغَمْرِ ظُلْمَةٌ، وَرُوحُ اللهِ يَرِفُّ عَلَى وَجْهِ الْمِيَاهِ. وَقَالَ اللهُ: «لِيَكُنْ نُورٌ»، فَكَانَ نُورٌ.</p>
114
131
  </main>
115
-
132
+
116
133
  <hr>
117
134
  <aside id="annotation-menu">
118
135
  <div class="buttons-area">
@@ -127,9 +144,10 @@
127
144
  <button name="hh-style" value="colored-text"> Colored text</button>
128
145
  <button name="hh-style" value="redacted"> Redacted</button>
129
146
  <button name="hh-remove">Remove</button>
130
- <div></div>
131
- <!--button name="hh-wrapper" value="none" title="No wrapper"><span class="highlight-placeholder">…</span></button>
132
- <button name="hh-wrapper" value="footnote" title="Footnote"><span class="footnote-marker" data-marker="a" data-accessibility-label="Footnote"></span><span class="highlight-placeholder">…</span></button-->
147
+ <button name="hh-wrapper" value="none" title="No wrapper"><span class="highlight-placeholder">…</span></button>
148
+ <button name="hh-wrapper" value="footnote" title="Footnote"><span class="footnote-marker" data-content="a" data-accessibility-label="Footnote [a]"></span><span class="highlight-placeholder">…</span></button>
149
+ <button name="hh-wrapper" value="margin-note" title="Margin note"><span class="note-container"><span class="note" data-content="🙂" data-accessibility-label="Smile emoji"></span></span><span class="highlight-placeholder">…</span></button>
150
+ <button name="hh-wrapper" value="margin-note" title="Margin note"><span class="note-container"><span class="note" data-content="✅" data-accessibility-label="Checkmark emoji"></span></span><span class="highlight-placeholder">…</span></button>
133
151
  </div>
134
152
  <p>
135
153
  <label><input type="checkbox" id="hh-remember-style" checked> Remember most recent color and style</label><br>
@@ -167,7 +185,7 @@
167
185
  <p>
168
186
  ← <a href="https://github.com/samuelbradshaw/highlight-helper-js">Back to GitHub</a>
169
187
  </p>
170
-
188
+
171
189
  <!-- Link to the HighlightHelper.js JavaScript file -->
172
190
  <script src="highlight-helper.js"></script>
173
191
  <script>
@@ -181,15 +199,15 @@
181
199
  },
182
200
  {
183
201
  highlightId: 'hh-1729991847300',
184
- color: 'blue', style: 'fill',
202
+ color: 'blue', style: 'fill', wrapper: 'margin-note',
203
+ wrapperVariables: { content: '🙂', accessibilityLabel: 'Smile emoji', },
185
204
  startParagraphId: 'p2', startParagraphOffset: 27,
186
205
  endParagraphId: 'p2', endParagraphOffset: 42,
187
206
  },
188
207
  {
189
208
  highlightId: 'hh-1729991847468',
190
- color: 'red', style: 'colored-text', wrapper: 'footnote',
191
- wrapperVariables: { marker: 'a', accessibilityLabel: 'Footnote 1a', },
192
- readOnly: true,
209
+ color: 'red', style: 'single-underline', wrapper: 'footnote',
210
+ wrapperVariables: { content: 'a', accessibilityLabel: 'Footnote [a]', },
193
211
  startParagraphId: 'p2', startParagraphOffset: 119,
194
212
  endParagraphId: 'p2', endParagraphOffset: 127,
195
213
  },
@@ -218,17 +236,18 @@
218
236
  endParagraphId: 'p4', endParagraphOffset: 207,
219
237
  },
220
238
  ];
221
-
239
+
222
240
  // Initialize a Highlighter instance, then load highlights
223
241
  const highlighter = new Highlighter({
224
242
  containerSelector: 'main',
225
243
  paragraphSelector: 'main [id]',
226
244
  wrappers: {
227
- 'footnote': { start: '<span class="footnote-marker" data-marker="{marker}" data-accessibility-label="{accessibilityLabel}"></span>', end: '', },
245
+ 'footnote': { start: '<span class="footnote-marker" data-content="{content}" data-accessibility-label="{accessibilityLabel}"></span>', end: '', },
246
+ 'margin-note': { start: '<span class="note-container"><span class="note" data-content="{content}" data-accessibility-label="{accessibilityLabel}"></span></span>', end: '', },
228
247
  },
229
248
  });
230
249
  highlighter.loadHighlights(highlights);
231
-
250
+
232
251
  // Call HighlightHelper.js methods when color, style, wrapper, or remove buttons on this demo page are tapped
233
252
  const colorButtons = document.getElementsByName('hh-color');
234
253
  for (const colorButton of colorButtons) {
@@ -240,7 +259,13 @@
240
259
  }
241
260
  const wrapperButtons = document.getElementsByName('hh-wrapper');
242
261
  for (const wrapperButton of wrapperButtons) {
243
- wrapperButton.addEventListener('click', () => { highlighter.createOrUpdateHighlight({ wrapper: wrapperButton.value }); });
262
+ const contentSpan = wrapperButton.querySelector('[data-content]');
263
+ wrapperButton.addEventListener('click', () => {
264
+ highlighter.createOrUpdateHighlight({
265
+ wrapper: wrapperButton.value === 'none' ? null : wrapperButton.value,
266
+ wrapperVariables: { content: contentSpan?.dataset?.content ?? '', accessibilityLabel: contentSpan?.dataset?.accessibilityLabel ?? '', },
267
+ });
268
+ });
244
269
  }
245
270
  const removeButton = document.getElementsByName('hh-remove')[0];
246
271
  removeButton.addEventListener('click', () => { highlighter.removeHighlights([highlighter.getActiveHighlightId()]); });
@@ -248,7 +273,7 @@
248
273
  function respondToKeyPress(event) {
249
274
  if (event.key === 'Backspace') highlighter.removeHighlights([highlighter.getActiveHighlightId()]);
250
275
  }
251
-
276
+
252
277
  // Call HighlightHelper.js methods when options are changed
253
278
  const rememberStyleCheckbox = document.getElementById('hh-remember-style');
254
279
  const snapToWordCheckbox = document.getElementById('hh-snap-to-word');
@@ -278,11 +303,11 @@
278
303
  selectionHandlesTextSelectionCheckbox.addEventListener('change', (event) => { highlighter.setOption('showCustomSelectionHandlesForTextSelection', event.currentTarget.checked); });
279
304
  const selectionHandlesOnTouchCheckbox = document.getElementById('hh-selection-handles-touch');
280
305
  selectionHandlesOnTouchCheckbox.addEventListener('change', (event) => { highlighter.setOption('showCustomSelectionHandlesOnTouch', event.currentTarget.checked); });
281
-
306
+
282
307
  const container = document.querySelector(highlighter.getOptions().containerSelector);
283
308
  const snippetDiv = document.getElementById('snippet');
284
309
  const infoDiv = document.getElementById('highlight-info');
285
-
310
+
286
311
  // Respond to highlight update and highlight activate event
287
312
  container.addEventListener('hh:highlightupdate', updateHighlightInfo);
288
313
  container.addEventListener('hh:highlightactivate', updateHighlightInfo);
@@ -300,6 +325,9 @@
300
325
  for (const annotatedParagraph of annotatedParagraphs) {
301
326
  const snippetParagraph = annotatedParagraph.cloneNode(true);
302
327
  snippetParagraph.id = `${snippetParagraph.id}_${event.detail.highlight.highlightId}`;
328
+ for (const wrapperEl of snippetParagraph.querySelectorAll('[data-highlight-id]')) {
329
+ wrapperEl.dataset.highlightId = wrapperEl.dataset.highlightId + '_snippet';
330
+ }
303
331
  snippetDiv.append(snippetParagraph);
304
332
  }
305
333
  const snippetHighlights = [
@@ -317,15 +345,23 @@
317
345
  });
318
346
  snippetHighlighter.loadHighlights(snippetHighlights);
319
347
  }
320
-
348
+
321
349
  // Respond to highlight deactivate event
322
350
  container.addEventListener('hh:highlightdeactivate', clearHighlightInfo);
323
351
  function clearHighlightInfo(event) {
324
352
  snippetDiv.innerHTML = '';
325
353
  infoDiv.innerHTML = 'N/A';
326
354
  }
327
-
328
- // Respond to ambiguous action events (when a user taps on overlapping highlights, or an overlapping link and highlight)
355
+
356
+ // Respond to tap
357
+ container.addEventListener('hh:tap', respondToTap);
358
+ function respondToTap(event) {
359
+ if (event.detail.wrappers.length === 1) {
360
+ highlighter.activateHighlight(event.detail.wrappers[0].highlightInfo.highlightId);
361
+ }
362
+ }
363
+
364
+ // Respond to ambiguous action events (when a user taps on overlapping highlights, wrappers, and/or links)
329
365
  const disambiguationPanel = document.getElementById('disambiguation-panel');
330
366
  const disambiguationButtonsDiv = document.getElementById('disambiguation-buttons');
331
367
  const disambiguationCancelButton = document.getElementById('disambiguation-cancel');
@@ -333,7 +369,11 @@
333
369
  container.addEventListener('hh:ambiguousaction', handleAmbiguousAction);
334
370
  function handleAmbiguousAction(event) {
335
371
  disambiguationButtonsDiv.innerHTML = '';
336
- for (const highlight of event.detail.highlights) {
372
+ const highlightIds = new Set();
373
+ for (const highlight of event.detail.highlights) highlightIds.add(highlight.highlightId);
374
+ for (const wrapper of event.detail.wrappers) highlightIds.add(wrapper.highlightInfo.highlightId);
375
+ const highlights = highlighter.getHighlightInfo(highlightIds);
376
+ for (const highlight of highlights) {
337
377
  let buttonStyle = highlighter.getOptions().styles[highlight.style]['css']
338
378
  let buttonHtml = `
339
379
  <button data-color="${highlight.color}" onclick="disambiguationPanel.close();highlighter.activateHighlight('${highlight.highlightId}');">
@@ -354,7 +394,20 @@
354
394
  // TODO: Waiting 10 milliseconds before showing the disambiguation panel is a workaround for an issue in Android Chrome where a click event fires right after the pointerup event that triggered hh:ambiguousaction. If the click event happens to be at the same position as a button in the panel, the button will receive the click, dismissing the panel before the user sees it.
355
395
  setTimeout(function() { disambiguationPanel.showModal(); }, 10);
356
396
  }
357
-
397
+
398
+ // Update margin note position when a highlight with a start wrapper is active
399
+ function updateMarginNotePosition(event) {
400
+ const activeHighlightId = highlighter.getActiveHighlightId();
401
+ if (!activeHighlightId) return;
402
+ const note = container.querySelector(`.hh-wrapper-start[data-highlight-id="${activeHighlightId}"] .note`);
403
+ if (!note) return;
404
+ const selection = getSelection();
405
+ const startRect = selection.type === 'Range' ? selection.getRangeAt(0).getBoundingClientRect() : null;
406
+ if (startRect) note.style.top = (startRect.top + window.scrollY - 1) + 'px';
407
+ }
408
+ container.addEventListener('hh:highlightactivate', updateMarginNotePosition);
409
+ document.addEventListener('selectionchange', updateMarginNotePosition);
410
+
358
411
  // Log events for debugging
359
412
  container.addEventListener('hh:highlightcreate', console.info)
360
413
  container.addEventListener('hh:highlightupdate', console.info)
@@ -365,16 +418,6 @@
365
418
  container.addEventListener('hh:selectionupdate', console.info)
366
419
  container.addEventListener('hh:ambiguousaction', console.info)
367
420
  container.addEventListener('hh:tap', console.info)
368
-
369
- // snippetDiv.addEventListener('hh:highlightcreate', console.warn)
370
- // snippetDiv.addEventListener('hh:highlightupdate', console.warn)
371
- // snippetDiv.addEventListener('hh:highlightremove', console.warn)
372
- // snippetDiv.addEventListener('hh:highlightsload', console.warn)
373
- // snippetDiv.addEventListener('hh:highlightactivate', console.warn)
374
- // snippetDiv.addEventListener('hh:highlightdeactivate', console.warn)
375
- // snippetDiv.addEventListener('hh:selectionupdate', console.warn)
376
- // snippetDiv.addEventListener('hh:ambiguousaction', console.warn)
377
- // snippetDiv.addEventListener('hh:tap', console.warn)
378
421
  </script>
379
422
  </body>
380
423
  </html>
@@ -57,7 +57,7 @@ function Highlighter(options = _defaultOptions) {
57
57
 
58
58
  this._updateAppearanceStylesheet();
59
59
  this._updateSelectionUi('appearance');
60
- this.setOption('selectionHandles', this._options.selectionHandles);
60
+ this.setOptions({ selectionHandles: this._options.selectionHandles });
61
61
  }
62
62
 
63
63
  Highlighter.prototype._initializeHighlighter = function (previousContainerSelector = null) {
@@ -112,11 +112,13 @@ Highlighter.prototype._initializeHighlighter = function (previousContainerSelect
112
112
  this._hyperlinkElements = this._annotatableContainer.getElementsByTagName('a');
113
113
  this._hyperlinksByPosition = {}
114
114
  for (let hyp = 0; hyp < this._hyperlinkElements.length; hyp++) {
115
+ const hyperlink = this._hyperlinkElements[hyp];
116
+ hyperlink.dataset.hhPosition = hyp;
115
117
  this._hyperlinksByPosition[hyp] = {
116
118
  'position': hyp,
117
- 'text': this._hyperlinkElements[hyp].innerHTML,
118
- 'url': this._hyperlinkElements[hyp].href,
119
- 'hyperlinkElement': this._hyperlinkElements[hyp],
119
+ 'text': hyperlink.innerHTML,
120
+ 'url': hyperlink.href,
121
+ 'hyperlinkElement': hyperlink,
120
122
  }
121
123
  }
122
124
 
@@ -315,12 +317,12 @@ Highlighter.prototype._loadEventListeners = function () {
315
317
  this._tapResult.isLongPress = isLongPress;
316
318
  this._annotatableContainer.dispatchEvent(new CustomEvent('hh:tap', { detail: this._tapResult, }));
317
319
  if (options.autoTapToActivate && this._tapResult?.targetFound && !isLongPress) {
318
- if (this._tapResult.highlights.length === 1 && this._tapResult.hyperlinks.length === 0) {
320
+ if (this._tapResult.targetCount > 1) {
321
+ return this._annotatableContainer.dispatchEvent(new CustomEvent('hh:ambiguousaction', { detail: this._tapResult, }));
322
+ } else if (this._tapResult.highlights.length === 1) {
319
323
  return this.activateHighlight(this._tapResult.highlights[0].highlightId);
320
- } else if (this._tapResult.highlights.length === 0 && this._tapResult.hyperlinks.length === 1) {
324
+ } else if (this._tapResult.hyperlinks.length === 1) {
321
325
  return this.activateHyperlink(this._tapResult.hyperlinks[0].position);
322
- } else if (this._tapResult.highlights.length + this._tapResult.hyperlinks.length > 1) {
323
- return this._annotatableContainer.dispatchEvent(new CustomEvent('hh:ambiguousaction', { detail: this._tapResult, }));
324
326
  }
325
327
  }
326
328
  }
@@ -446,6 +448,34 @@ Highlighter.prototype.drawHighlights = function (highlightIds = Object.keys(this
446
448
  // Don't redraw a read-only highlight that's already drawn
447
449
  if (wasDrawnAsReadOnly) continue;
448
450
 
451
+ // Insert wrappers before drawing, so any layout shift from wrapper content is reflected in drawing positions
452
+ if (highlightInfo.wrapper && (options.wrappers[highlightInfo.wrapper]?.start || options.wrappers[highlightInfo.wrapper]?.end)) {
453
+ const addWrapper = (edge, range, htmlString) => {
454
+ htmlString = `<span class="hh-wrapper-${edge}" data-highlight-id="${highlightId}" data-color="${highlightInfo.color}" data-style="${highlightInfo.style}" data-hh-ignore="">${htmlString}</span>`
455
+ for (const key of Object.keys(highlightInfo.wrapperVariables)) {
456
+ htmlString = htmlString.replaceAll(`{${key}}`, highlightInfo.wrapperVariables[key]);
457
+ }
458
+ const template = document.createElement('template');
459
+ template.innerHTML = htmlString;
460
+ let htmlElement = template.content.firstChild;
461
+ range.insertNode(htmlElement);
462
+ }
463
+ const startRange = highlightInfo.rangeObj;
464
+ const endRange = document.createRange(); endRange.setStart(highlightInfo.rangeObj.endContainer, highlightInfo.rangeObj.endOffset);
465
+ const wrapperInfo = options.wrappers[highlightInfo.wrapper];
466
+ addWrapper('start', startRange, wrapperInfo.start);
467
+ addWrapper('end', endRange, wrapperInfo.end);
468
+ rangeParagraphs.forEach(p => { p.normalize(); });
469
+ range = this._getCorrectedRangeObj(highlightId);
470
+ if (!highlightInfo.readOnly && options.drawingMode === 'svg') {
471
+ const startWrapperEl = this._annotatableContainer.querySelector(`.hh-wrapper-start[data-highlight-id="${highlightId}"]`);
472
+ const endWrapperEl = this._annotatableContainer.querySelector(`.hh-wrapper-end[data-highlight-id="${highlightId}"]`);
473
+ range = document.createRange();
474
+ range.setStartAfter(startWrapperEl);
475
+ range.setEndBefore(endWrapperEl);
476
+ }
477
+ }
478
+
449
479
  // Draw highlights with mark elements
450
480
  if (highlightInfo.readOnly || options.drawingMode === 'mark-elements') {
451
481
  // Inject HTML <mark> elements
@@ -478,13 +508,13 @@ Highlighter.prototype.drawHighlights = function (highlightIds = Object.keys(this
478
508
  styledMark.appendChild(textNode);
479
509
  // Absorb adjacent elements without text into the mark element. This prevents unstyled gaps around CSS-rendered pseudoelement content (such as superscript footnote markers).
480
510
  let prev = styledMark.previousSibling;
481
- while (prev && prev.nodeType === Node.ELEMENT_NODE && prev.textContent === '') {
511
+ while (prev && prev.nodeType === Node.ELEMENT_NODE && prev.textContent === '' && !prev.hasAttribute('data-hh-ignore')) {
482
512
  const toAbsorb = prev; prev = prev.previousSibling;
483
513
  styledMark.prepend(toAbsorb);
484
514
  }
485
515
  if (tn < relevantTextNodes.length - 1) {
486
516
  let next = styledMark.nextSibling;
487
- while (next && next.nodeType === Node.ELEMENT_NODE && next.textContent === '') {
517
+ while (next && next.nodeType === Node.ELEMENT_NODE && next.textContent === '' && !next.hasAttribute('data-hh-ignore')) {
488
518
  const toAbsorb = next; next = next.nextSibling;
489
519
  styledMark.appendChild(toAbsorb);
490
520
  }
@@ -528,28 +558,6 @@ Highlighter.prototype.drawHighlights = function (highlightIds = Object.keys(this
528
558
  group.innerHTML = svgContent;
529
559
  this._svgBackground.appendChild(group);
530
560
  }
531
-
532
- // Update wrapper (for read-only highlights only)
533
- if (highlightInfo.readOnly) {
534
- if (highlightInfo.wrapper && (options.wrappers[highlightInfo.wrapper]?.start || options.wrappers[highlightInfo.wrapper]?.end)) {
535
- const addWrapper = (edge, range, htmlString) => {
536
- htmlString = `<span class="hh-wrapper-${edge}" data-highlight-id="${highlightId}" data-color="${highlightInfo.color}" data-style="${highlightInfo.style}" data-hh-ignore="">${htmlString}</span>`
537
- for (const key of Object.keys(highlightInfo.wrapperVariables)) {
538
- htmlString = htmlString.replaceAll(`{${key}}`, highlightInfo.wrapperVariables[key]);
539
- }
540
- const template = document.createElement('template');
541
- template.innerHTML = htmlString;
542
- let htmlElement = template.content.firstChild;
543
- range.insertNode(htmlElement);
544
- }
545
- const startRange = highlightInfo.rangeObj;
546
- const endRange = document.createRange(); endRange.setStart(highlightInfo.rangeObj.endContainer, highlightInfo.rangeObj.endOffset);
547
- const wrapperInfo = options.wrappers[highlightInfo.wrapper];
548
- addWrapper('start', startRange, wrapperInfo.start);
549
- addWrapper('end', endRange, wrapperInfo.end);
550
- rangeParagraphs.forEach(p => { p.normalize(); });
551
- }
552
- }
553
561
  }
554
562
 
555
563
  // Show container
@@ -683,14 +691,23 @@ Highlighter.prototype.createOrUpdateHighlight = function (attributes = {}, trigg
683
691
  changes: appearanceChanges.concat(boundsChanges),
684
692
  }
685
693
 
694
+ const wrapperChanged = appearanceChanges.some(k => k.startsWith('wrapper'));
686
695
  if (triggeredByUserAction && !this._activeHighlightId) {
687
696
  this.activateHighlight(highlightId);
688
697
  } else if (this._activeHighlightId && highlightId === this._activeHighlightId && appearanceChanges.length > 0) {
689
698
  this._updateSelectionUi('appearance');
690
- } else if (highlightId !== this._activeHighlightId) {
699
+ }
700
+ if (highlightId !== this._activeHighlightId || wrapperChanged) {
691
701
  this.drawHighlights([highlightId]);
692
702
  }
693
703
 
704
+ // Redraw highlights after the current highlight if needed
705
+ if (wrapperChanged && options.drawingMode === 'svg') {
706
+ const highlights = this.getHighlightInfo();
707
+ const changedIndex = highlights.findIndex(h => h.highlightId === highlightId);
708
+ this.drawHighlights(highlights.slice(changedIndex + 1).map(h => h.highlightId));
709
+ }
710
+
694
711
  if (isNewHighlight) {
695
712
  this._annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightcreate', { detail: detail }));
696
713
  } else {
@@ -792,33 +809,47 @@ Highlighter.prototype.getHighlightInfo = function (highlightIds = Object.keys(th
792
809
  return filteredHighlights;
793
810
  }
794
811
 
795
- // Update one of the initialized options
796
- Highlighter.prototype.setOption = function (key, value) {
812
+ // Update initialized options
813
+ Highlighter.prototype.setOptions = function (optionsToUpdate) {
797
814
  const options = this._options;
798
- options[key] = value ?? options[key];
799
- if (key === 'drawingMode' || key === 'styles') {
800
- this._updateAppearanceStylesheet();
801
- if (supportsHighlightApi) CSS.highlights.clear();
802
- this.drawHighlights();
803
- } else if (key === 'colors') {
804
- this._updateAppearanceStylesheet();
805
- } else if (key === 'containerSelector') {
815
+ for (const key in optionsToUpdate) {
816
+ options[key] = optionsToUpdate[key] ?? options[key];
817
+ }
818
+ if ('containerSelector' in optionsToUpdate) {
806
819
  this.removeHighlighter();
807
820
  this._initializeHighlighter();
808
821
  this._loadStyles();
809
822
  this._loadEventListeners();
810
823
  this._updateAppearanceStylesheet();
811
824
  this._updateSelectionUi('appearance');
812
- this.setOption('selectionHandles', options.selectionHandles);
813
- } else if (key === 'paragraphSelector') {
814
- this._initializeHighlighter();
815
- } else if (key === 'selectionHandles') {
816
825
  for (const selectionHandle of this._selectionHandles) {
817
826
  selectionHandle.children[1].innerHTML = options.selectionHandles[selectionHandle.dataset.side] ?? '';
818
827
  }
828
+ } else {
829
+ if ('paragraphSelector' in optionsToUpdate) {
830
+ this._initializeHighlighter();
831
+ }
832
+ if ('drawingMode' in optionsToUpdate || 'styles' in optionsToUpdate || 'colors' in optionsToUpdate) {
833
+ this._updateAppearanceStylesheet();
834
+ }
835
+ if ('drawingMode' in optionsToUpdate || 'styles' in optionsToUpdate) {
836
+ if (supportsHighlightApi) CSS.highlights.clear();
837
+ this.drawHighlights();
838
+ }
839
+ if ('selectionHandles' in optionsToUpdate) {
840
+ for (const selectionHandle of this._selectionHandles) {
841
+ selectionHandle.children[1].innerHTML = options.selectionHandles[selectionHandle.dataset.side] ?? '';
842
+ }
843
+ }
819
844
  }
820
845
  }
821
846
 
847
+ /** @deprecated Use setOptions({ key: value }) instead */
848
+ Highlighter.prototype.setOption = function (key, value) {
849
+ console.warn('setOption() is deprecated. Use setOptions({ key: value }) instead.');
850
+ this.setOptions({ [key]: value });
851
+ }
852
+
822
853
  // Get all of the initialized options
823
854
  Highlighter.prototype.getOptions = function () {
824
855
  return this._options;
@@ -839,41 +870,51 @@ Highlighter.prototype.removeHighlighter = function () {
839
870
 
840
871
  /********************** Private Methods **********************/
841
872
 
842
- // Check if the tap is in the range of an existing highlight or link
873
+ // Check if the tap hits any highlights, wrappers, or links
843
874
  Highlighter.prototype._checkForTapTargets = function (pointerEvent) {
844
875
  if (!pointerEvent) return;
845
876
 
846
- // Check for tapped highlights and hyperlinks
847
- const tappedHighlights = [];
848
- const tappedHyperlinks = [];
877
+ // Check for tapped highlights
878
+ const tappedHighlightIds = [];
849
879
  if (!pointerEvent.target.closest('[data-hh-ignore]')) {
850
880
  for (const highlightId of Object.keys(this._highlightsById)) {
851
881
  const highlightInfo = this._highlightsById[highlightId];
852
882
  const highlightRange = highlightInfo.rangeObj;
853
883
  for (const rangeRect of highlightRange.getClientRects()) {
854
884
  if (this._isPointInRect(pointerEvent.clientX, pointerEvent.clientY, rangeRect, 5)) {
855
- tappedHighlights.push(highlightInfo);
885
+ tappedHighlightIds.push(highlightId);
856
886
  break;
857
887
  }
858
888
  }
859
889
  }
860
- for (const hyperlinkInfo of Object.values(this._hyperlinksByPosition)) {
861
- if (pointerEvent.target.closest('a') === hyperlinkInfo.hyperlinkElement) {
862
- tappedHyperlinks.push(hyperlinkInfo);
863
- }
864
- }
865
890
  }
891
+ const tappedHighlights = this.getHighlightInfo(tappedHighlightIds);
866
892
 
867
- // Sort highlights (hyperlinks should already be sorted)
868
- const tappedHighlightIds = [];
869
- for (const highlightInfo of tappedHighlights) tappedHighlightIds.push(highlightInfo.highlightId);
870
- const sortedTappedHighlights = this.getHighlightInfo(tappedHighlightIds);
893
+ // Check for tapped wrappers and hyperlinks
894
+ const tappedWrappers = [];
895
+ const tappedHyperlinks = [];
896
+ const targetElements = document.elementsFromPoint(pointerEvent.clientX, pointerEvent.clientY);
897
+ for (const element of targetElements) {
898
+ const wrapper = element.closest('.hh-wrapper-start, .hh-wrapper-end');
899
+ if (wrapper) {
900
+ tappedWrappers.push({
901
+ position: wrapper.className.includes('hh-wrapper-start') ? 'start' : 'end',
902
+ highlightInfo: this._highlightsById[wrapper.dataset.highlightId],
903
+ });
904
+ } else if (element.matches('a') && element.dataset.hhPosition) {
905
+ const hyperlinkInfo = this._hyperlinksByPosition[element.dataset.hhPosition];
906
+ tappedHyperlinks.push(hyperlinkInfo);
907
+ }
908
+ }
871
909
 
910
+ const targetCount = tappedHighlights.length + tappedWrappers.length + tappedHyperlinks.length;
872
911
  return {
873
- 'targetFound': sortedTappedHighlights.length > 0 || tappedHyperlinks.length > 0,
912
+ 'targetCount': targetCount,
913
+ 'targetFound': targetCount > 0,
874
914
  'tapRange': this._getCaretFromCoordinates(pointerEvent.clientX, pointerEvent.clientY),
875
915
  'pointerEvent': pointerEvent,
876
- 'highlights': sortedTappedHighlights,
916
+ 'highlights': tappedHighlights,
917
+ 'wrappers': tappedWrappers,
877
918
  'hyperlinks': tappedHyperlinks,
878
919
  }
879
920
  }
@@ -958,6 +999,7 @@ Highlighter.prototype._updateSelectionUi = function (changeType = 'appearance')
958
999
  this._svgActiveOverlay.innerHTML = '';
959
1000
  if (this._activeHighlightId && options.drawingMode === 'svg') {
960
1001
  let range = this._getCorrectedRangeObj(this._activeHighlightId);
1002
+ range = this._snapRangeToBoundaries(range);
961
1003
  const rangeParagraphs = this._annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
962
1004
  const clientRects = this._getMergedClientRects(range, rangeParagraphs);
963
1005
  this._svgActiveOverlay.dataset.color = color;
@@ -981,7 +1023,7 @@ Highlighter.prototype._updateSelectionUi = function (changeType = 'appearance')
981
1023
  if (this._activeHighlightId && options.drawingMode === 'svg') {
982
1024
  this._selectionStylesheet.replaceSync(`
983
1025
  ${options.containerSelector} g[data-highlight-id="${this._activeHighlightId}"][data-style] { display: none; }
984
- ${options.containerSelector} .hh-wrapper-start[data-highlight-id="${this._activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${this._activeHighlightId}"] { display: none; }
1026
+ ${options.containerSelector} .hh-wrapper-start[data-highlight-id="${this._activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${this._activeHighlightId}"] { visibility: hidden; }
985
1027
  ${options.containerSelector} ::selection { background-color: transparent; }
986
1028
  `);
987
1029
  } else if (this._activeHighlightId) {
@@ -989,7 +1031,7 @@ Highlighter.prototype._updateSelectionUi = function (changeType = 'appearance')
989
1031
  this._selectionStylesheet.replaceSync(`
990
1032
  ${options.containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
991
1033
  ${options.containerSelector} mark[data-highlight-id="${this._activeHighlightId}"][data-style] { all: unset; }
992
- ${options.containerSelector} .hh-wrapper-start[data-highlight-id="${this._activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${this._activeHighlightId}"] { display: none; }
1034
+ ${options.containerSelector} .hh-wrapper-start[data-highlight-id="${this._activeHighlightId}"], .hh-wrapper-end[data-highlight-id="${this._activeHighlightId}"] { visibility: hidden; }
993
1035
  ${options.containerSelector} ::selection { --hh-color: ${colorString}; ${styleTemplate} }
994
1036
  ${options.containerSelector} rt::selection, ${options.containerSelector} img::selection { background-color: transparent; }
995
1037
  `);
@@ -1035,7 +1077,7 @@ Highlighter.prototype._updateSelectionUi = function (changeType = 'appearance')
1035
1077
  }
1036
1078
  if (selectionHandle.dataset.side !== side) {
1037
1079
  selectionHandle.dataset.side = side;
1038
- this.setOption('selectionHandles', options.selectionHandles);
1080
+ this.setOptions({ selectionHandles: options.selectionHandles });
1039
1081
  }
1040
1082
  }
1041
1083
  this._setCustomSelectionHandleVisibility(true);
@@ -1076,6 +1118,17 @@ Highlighter.prototype._snapRangeToBoundaries = function (range, anchorNode = nul
1076
1118
  }
1077
1119
  }
1078
1120
 
1121
+ // If the range starts at the end of a text node, move it to start at the beginning of the following text node. This prevents the range from jumping across the text node boundary and selecting an extra word.
1122
+ if (startOffset === startNode.textContent.length) {
1123
+ const walker = this._getTextNodeWalker(range.commonAncestorContainer);
1124
+ walker.currentNode = startNode;
1125
+ const nextTextNode = walker.nextNode();
1126
+ if (nextTextNode) {
1127
+ startNode = nextTextNode;
1128
+ startOffset = 0;
1129
+ }
1130
+ }
1131
+
1079
1132
  // Prevent the range from starting or ending in an invalid text node
1080
1133
  if (this._annotatableContainer.contains(range.commonAncestorContainer) && (this._shouldSkipTextNode(startNode) || this._shouldSkipTextNode(endNode))) {
1081
1134
  if (this._shouldSkipTextNode(startNode)) {
@@ -1095,17 +1148,6 @@ Highlighter.prototype._snapRangeToBoundaries = function (range, anchorNode = nul
1095
1148
 
1096
1149
  // Snap to the nearest word
1097
1150
  if (options.snapToWord) {
1098
- // If the range starts at the end of a text node, move it to start at the beginning of the following text node. This prevents the range from jumping across the text node boundary and selecting an extra word.
1099
- if (startOffset === startNode.textContent.length) {
1100
- const walker = this._getTextNodeWalker(range.commonAncestorContainer);
1101
- walker.currentNode = startNode;
1102
- const nextTextNode = walker.nextNode();
1103
- if (nextTextNode) {
1104
- startNode = nextTextNode;
1105
- startOffset = 0;
1106
- }
1107
- }
1108
-
1109
1151
  // Trim whitespace and dashes at range start and end
1110
1152
  while (_reWhitespaceDash.test(startOffset < startNode.textContent.length && startNode.textContent[startOffset])) startOffset += 1;
1111
1153
  while (endOffset > 0 && _reWhitespaceDash.test(endNode.textContent[endOffset - 1])) endOffset -= 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samuelbradshaw/highlight-helper-js",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "JavaScript tool that enables highlighting or underlining text in an HTML page.",
5
5
  "type": "module",
6
6
  "main": "highlight-helper.mjs",