@samuelbradshaw/highlight-helper-js 1.0.2 → 1.1.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
@@ -122,7 +122,7 @@ You can also install it using [npm](https://www.npmjs.com/package/@samuelbradsha
122
122
  - **loadHighlights(highlights)** – Load a set of highlights. If highlights are already loaded, this method will diff for changes and replace the loaded highlights. Parameters:
123
123
  - **highlights** – Array of [highlight objects](#highlight-objects). Each highlight should have a `highlightId`, and one or more other attributes. Omitted or invalid properties will fall back to previous or default values.
124
124
  - **createOrUpdateHighlight(properties, draw = true, activate = true)** – Create and load a new highlight, or update an existing highlight. Return value: updated highlight object. Parameters:
125
- - **properties** – Object with one or more [highlight object](#highlight-objects) properties. When updating an existing highlight, only the `highlightId` and properties that changed need to be provided. If a highlight ID isn't provided, HighlightHelper.js will update the currently-active highlight or create a new highlight with a default ID.
125
+ - **properties** – Object with one or more [highlight object](#highlight-objects) properties. When updating an existing highlight, only the `highlightId` and properties that changed need to be provided. If a highlight ID isn't provided, HighlightHelper.js will update the currently-active highlight or create a new highlight with a default ID. New highlights and the active highlight use the text selection for their range, unless a range is provided. Other highlights keep their range.
126
126
  - **draw** – Boolean. Indicates if the highlight should be drawn after loading. Default: `true`.
127
127
  - **activate** – Boolean. Indicates if the highlight should be activated after loading. Default: `true`.
128
128
  - **drawHighlights(highlightIds = all)** – Draw or redraw highlights. In most cases, HighlightHelper.js will draw highlights automatically, but a manual redraw can be triggered if needed. Parameters:
@@ -165,6 +165,7 @@ HighlightHelper.js sends [custom events](https://developer.mozilla.org/en-US/doc
165
165
  - **hh:highlightactivate** – Sent when a highlight is activated.
166
166
  - **hh:highlightdeactivate** – Sent when a highlight is deactivated.
167
167
  - **hh:highlightremove** – Sent when a highlight is removed.
168
+ - **hh:layoutchange** – Sent when highlights redraw because of container size changes.
168
169
 
169
170
  Each event has a `detail` property that provides additional information. For example, the `hh:tap` event could be used to activate a highlight:
170
171
 
@@ -221,9 +222,9 @@ The following options can be set using the `setOptions()` method. Options that a
221
222
  - **right** – HTML string for the right selection handle.
222
223
  - Template variables (optional): `var(--hh-color)` can be used to reference the highlight color.
223
224
  - **showDragHandles** – Array. Indicates when custom drag handles should be shown. Default: `['highlights']`. Possible values:
224
- - **highlights** – Show custom handles for active highlights. Default: `true`.
225
- - **selection** – Show custom handles for text selection. Default: `false`.
226
- - **touch** – Show custom handles on touch devices (may be unstable due to interaction with built-in selection handles). Default: `false`.
225
+ - **highlights** – Show custom handles for active highlights.
226
+ - **selection** – Show custom handles for text selection.
227
+ - **touch** – Show custom handles on touch devices (may conflict with built-in selection handles).
227
228
  - **colorDefs** – Object. Defines highlight colors. Keys are color names, and values are [CSS color values](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default keys: `red`, `orange`, `yellow`, `green`, `blue`.
228
229
  - **styleDefs** – Object. Defines highlight style templates. Keys are style names. Default keys: `fill`, `underline`. Each style has four available properties:
229
230
  - **css** (required) – CSS string used in `highlight-api` and `mark-elements` drawing modes, for active text selection, and as a fallback in `svg` drawing mode.
@@ -279,19 +280,21 @@ Other:
279
280
  ### <a name="known-issues"></a>Known issues
280
281
 
281
282
  - **Chrome on Android** doesn't show text selection handles when text is selected programmatically (such as from a disambiguation panel, or with snap-to-word). Text selection UI must be initiated by a user gesture.
282
- - Workaround: Tap the highlight again to show the handles.
283
- - **Safari on iOS and macOS** doesn't allow setting underline thickness on a ::highlight() pseudo-element, so underline highlights drawn by the CSS Custom Highlight API are thin and hard to see (see [StackOverflow](https://stackoverflow.com/q/79060854/1349044) and [WebKit Bugzilla](https://bugs.webkit.org/show_bug.cgi?id=282027)).
283
+ - Workaround: Tap the highlight again to show the handles, or enable custom drag handles.
284
+ - **Safari on iOS and macOS** doesn't allow full control of the underline style on a `::highlight()` pseudo-element, so underline highlights drawn by the CSS Custom Highlight API may look incorrect (see [StackOverflow](https://stackoverflow.com/q/79060854/1349044) and [WebKit Bugzilla](https://bugs.webkit.org/show_bug.cgi?id=282027)).
284
285
  - Workaround: Use a different drawing mode.
286
+ - **Safari on iOS and macOS** ignores text decoration styles on `::selection`, so active underline highlights can't be styled with CSS alone.
287
+ - Workaround: Set `svg` and `svgActive` for underline styles in styleDefs. On Safari, if a style has an `svg` template, HighlightHelper.js draws the active highlight with `svgActive` (or `svg` if `svgActive` isn't set), in any drawing mode.
288
+ - **Safari on iOS** ignores all `::selection` styles, so active highlights show the native blue selection overlay instead of the highlight style.
289
+ - Workaround: Set `svg` and `svgActive` for all styles in styleDefs. Alternatively, in a native app with a webview, you may be able to set colors natively. See [tintColor](https://stackoverflow.com/a/60510743/1349044), [highlightView](https://developer.apple.com/documentation/uikit/uitextselectiondisplayinteraction/4195471-highlightview), and/or [handleViews](https://developer.apple.com/documentation/uikit/uitextselectiondisplayinteraction/4195470-handleviews).
285
290
  - **Safari on iOS and macOS** expand text selection to include the previous word when selecting a word in an inline element at the beginning of the line of text. Code to reproduce: `<p style="font-family: Times; font-size: 16px; width: 250px">This is an example of <i>text selection</i> <b>jumping</b> to include the previous word when text wraps at the beginning of an inline element. Double-click (macOS) or long-press (iOS) the word "jumping."</p>`
286
291
  - Workaround: Manually adjust highlight after it's created.
287
- - **Safari on iOS** doesn't respect text selection colors set via CSS, so when a highlight is active for editing it has a blue overlay.
288
- - Workaround: In a native app with a webview, you may be able to set colors natively. See [tintColor](https://stackoverflow.com/a/60510743/1349044), [highlightView](https://developer.apple.com/documentation/uikit/uitextselectiondisplayinteraction/4195471-highlightview), and/or [handleViews](https://developer.apple.com/documentation/uikit/uitextselectiondisplayinteraction/4195470-handleviews).
289
292
  - **Safari on iOS 26** doesn't create a text selection when using an Apple Pencil (or other compatible stylus), until the stylus is lifted.
290
293
  - Workaround: Double-tap then drag the stylus.
291
294
  - **Safari on macOS** – SVG highlights are slightly shifted when zoomed to the maximum zoom size.
292
295
  - Workaround: Reduce text size.
293
- - **Amazon Fire webview** doesn’t show selection handles, making it difficult to resize an existing highlight (and custom drag handles don't work reliably on touch devices).
294
- - Workaround: Long-press and drag to select everything you need before lifting your finger.
296
+ - **Amazon Fire webview** doesn’t show selection handles, making it difficult to resize an existing highlight.
297
+ - Workaround: Long-press and drag to select everything you need before lifting your finger, or enable custom drag handles.
295
298
 
296
299
 
297
300
  ### <a name="choosing-a-drawing-mode"></a>Choosing a drawing mode
@@ -449,7 +452,7 @@ mark[data-hh-highlight-id][data-hh-style="fill"][data-hh-position~="end"] {
449
452
  border-bottom-right-radius: 0.25em;
450
453
  margin-right: -0.13em; padding-right: 0.13em;
451
454
  }
452
- mark[data-hh-highlight-id][data-hh-style="fill"] + mark[hh-highlight-id][data-hh-style="fill"] {
455
+ mark[data-hh-highlight-id][data-hh-style="fill"] + mark[data-hh-highlight-id][data-hh-style="fill"] {
453
456
  margin-left: 0; padding-left: 0;
454
457
  }
455
458
  ```
@@ -610,7 +613,7 @@ document.getElementById('copy-html').addEventListener('click', (event) => {
610
613
  @media print {
611
614
  [data-hh-additions] { display: none; } /* Hide SVG highlights and custom drag handles */
612
615
  [data-hh-wrapper] { display: none; } /* Hide wrappers */
613
- mark[data-hh-highlight-id] { all: unset; } /* Hide mark-element highlights */
616
+ mark[data-hh-highlight-id][data-hh-style] { all: unset; } /* Hide mark-element highlights */
614
617
  }
615
618
  ```
616
619
 
@@ -124,6 +124,7 @@
124
124
  position: absolute;
125
125
  width: 0px;
126
126
  visibility: hidden;
127
+ touch-action: none;
127
128
  }
128
129
  [data-hh-pointer-down="true"] [data-hh-handle] {
129
130
  /* Hiding selection handle reduces jumpiness when dragging (and makes it easier to see the selection) */
@@ -136,13 +137,13 @@
136
137
  [data-hh-handle] [draggable] {
137
138
  position: absolute;
138
139
  top: -0.3em;
139
- width: 3.2em;
140
- height: calc(100% + 1.3em);
140
+ width: 2.2em;
141
+ height: calc(100% + 1.8em);
141
142
  background-color: transparent;
142
143
  z-index: 1;
143
144
  }
144
- [data-hh-handle][data-hh-side="left"] [draggable] { right: -2em; }
145
- [data-hh-handle][data-hh-side="right"] [draggable] { left: -2em; }
145
+ [data-hh-handle][data-hh-side="left"] [draggable] { right: -0.4em; }
146
+ [data-hh-handle][data-hh-side="right"] [draggable] { left: -0.4em; }
146
147
  [data-hh-default-handle] {
147
148
  position: absolute;
148
149
  width: 0.8em;
@@ -177,7 +178,8 @@
177
178
  fill: transparent;
178
179
  stroke: none;
179
180
  }
180
- mark[data-hh-highlight-id] {
181
+ mark[data-hh-highlight-id]:where(${this._containerSelector} *) {
182
+ /* Scoped to the container, so another Highlighter instance's base rule can't override this instance's highlight styles */
181
183
  print-color-adjust: exact;
182
184
  background-color: transparent;
183
185
  color: inherit;
@@ -397,6 +399,7 @@
397
399
  // Hyperlink click (for each hyperlink in container)
398
400
  for (const hyperlinkElement of this._hyperlinkElements) {
399
401
  hyperlinkElement.addEventListener('click', (event) => {
402
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
400
403
  this.deactivateHighlights();
401
404
  if (!this._allowHyperlinkClick) event.preventDefault();
402
405
  }, { signal: this._controller.signal });
@@ -420,6 +423,7 @@
420
423
  if (this._previousSelectionRange) this._updateSelectionState();
421
424
  previousWidth = width;
422
425
  previousHeight = height;
426
+ this._container.dispatchEvent(new CustomEvent('hh:layoutchange', { detail: { width, height } }));
423
427
  }
424
428
  }
425
429
  }, Math.floor(Object.keys(this._highlightsById).length / 20)));
@@ -428,11 +432,14 @@
428
432
  // Set drawing mode to mark elements temporarily when printing (highlights get offset in SVG drawing mode)
429
433
  let previousDrawingMode = null;
430
434
  globalThis.addEventListener('beforeprint', () => {
435
+ if (this._activeHighlightId) this.deactivateHighlights();
431
436
  previousDrawingMode = this._options.drawingMode;
437
+ if (previousDrawingMode === 'mark-elements') return;
432
438
  this.setOptions({ drawingMode: 'mark-elements' });
433
439
  this.drawHighlights();
434
440
  }, { signal: this._controller.signal });
435
441
  globalThis.addEventListener('afterprint', () => {
442
+ if (previousDrawingMode === 'mark-elements') return;
436
443
  this.setOptions({ drawingMode: previousDrawingMode });
437
444
  this.drawHighlights();
438
445
  }, { signal: this._controller.signal });
@@ -531,9 +538,15 @@
531
538
  let startParagraphId, startParagraphOffset, endParagraphId, endParagraphOffset;
532
539
  const selection = this._getRestoredSelectionOrCaret(globalThis.getSelection());
533
540
  if (selection.type === 'Range') adjustedSelectionRange = this._snapRangeToBoundaries(selection.getRangeAt(0));
534
- if ((properties.startParagraphId ?? properties.startParagraphOffset ?? properties.endParagraphId ?? properties.endParagraphOffset != null) || (adjustedSelectionRange && !adjustedSelectionRange.collapsed)) {
541
+ const hasBoundsProperties = ['startParagraphId', 'startParagraphOffset', 'endParagraphId', 'endParagraphOffset'].some(key => properties[key] != null);
542
+ // New highlights and the active highlight take their bounds from the text selection. Other highlights keep their saved bounds, unless new bounds are provided (otherwise, updating an inactive highlight would move it to the text selection, or do nothing if there isn't one).
543
+ const useSelectionBounds = !hasBoundsProperties && adjustedSelectionRange && !adjustedSelectionRange.collapsed && (isNewHighlight || highlightId === this._activeHighlightId);
544
+ if (!isNewHighlight && !hasBoundsProperties && !useSelectionBounds) {
545
+ // Only appearance properties changed, so reuse the saved range instead of recalculating it
546
+ highlightRange = this._getCorrectedRangeObj(highlightId);
547
+ } else if (hasBoundsProperties || useSelectionBounds) {
535
548
  let startNode, startOffset, endNode, endOffset;
536
- if (properties.startParagraphId ?? properties.startParagraphOffset ?? properties.endParagraphId ?? properties.endParagraphOffset != null) {
549
+ if (!useSelectionBounds) {
537
550
  startParagraphId = properties.startParagraphId ?? oldHighlightInfo?.startParagraphId;
538
551
  startParagraphOffset = Number.parseInt(properties.startParagraphOffset ?? oldHighlightInfo?.startParagraphOffset);
539
552
  endParagraphId = properties.endParagraphId ?? oldHighlightInfo?.endParagraphId;
@@ -541,7 +554,7 @@
541
554
  if (!this._paragraphIds.includes(startParagraphId) || !this._paragraphIds.includes(endParagraphId)) return;
542
555
  ([ startNode, startOffset ] = this._getTextNodeAndOffset(document.getElementById(startParagraphId), startParagraphOffset, 'start'));
543
556
  ([ endNode, endOffset ] = this._getTextNodeAndOffset(document.getElementById(endParagraphId), endParagraphOffset, 'end'));
544
- } else if (adjustedSelectionRange) {
557
+ } else {
545
558
  startNode = adjustedSelectionRange.startContainer;
546
559
  startOffset = adjustedSelectionRange.startOffset;
547
560
  if (this._shouldSkipTextNode(startNode)) {
@@ -881,9 +894,19 @@
881
894
  return;
882
895
  }
883
896
  const selection = globalThis.getSelection();
884
- const highlightRange = this._getCorrectedRangeObj(highlightId);
897
+
898
+ // Switch the active highlight before redrawing the previously active one, so there's no in-between state with no active highlight (an hh:selectionchange event in that state could lead an app to create a new highlight over the previous one)
899
+ const previousHighlight = this._highlightsById[this._activeHighlightId];
885
900
  this._activeHighlightId = highlightId;
886
901
 
902
+ // Redraw the previously active highlight. In mark-elements mode, this changes the DOM, so get the new highlight's range afterward.
903
+ if (previousHighlight) this.drawHighlights([previousHighlight.highlightId]);
904
+ const highlightRange = this._getCorrectedRangeObj(highlightId);
905
+
906
+ // Save the highlight's range as the previous selection range. Otherwise, a pending selectionchange event (such as one from the tap that led to this highlight being activated) is compared against a stale range, which can deactivate the highlight or restore a different selection range.
907
+ this._previousSelectionRange = highlightRange.cloneRange();
908
+ this._previousSelectionDirection = 'forward';
909
+
887
910
  // Update the selection range if needed
888
911
  // 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.
889
912
  let selectionRange = selection.type === 'Range' ? selection.getRangeAt(0) : null;
@@ -893,6 +916,7 @@
893
916
  }
894
917
 
895
918
  this._updateSelectionState();
919
+ if (previousHighlight) this._container.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: { highlight: previousHighlight } }));
896
920
  this._container.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
897
921
  }
898
922
 
@@ -1149,13 +1173,14 @@
1149
1173
  if (resolvedMode === 'highlight-api') {
1150
1174
  const colorString = this._options.colorDefs[highlightInfo.color];
1151
1175
  cssText += `
1152
- ${this._containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
1153
- ${this._containerSelector} :is(${this._paragraphSelector})::highlight(${highlightInfo.escapedHighlightId}),
1154
- ${this._containerSelector} :is(${this._paragraphSelector}) *::highlight(${highlightInfo.escapedHighlightId}) { --hh-color: ${colorString}; ${styleString} }
1155
- ${this._containerSelector} :is(rt, img)::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }
1176
+ :where(${this._containerSelector} *)::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
1177
+ :where(${this._containerSelector} :is(${this._paragraphSelector}))::highlight(${highlightInfo.escapedHighlightId}),
1178
+ :where(${this._containerSelector} :is(${this._paragraphSelector}) *)::highlight(${highlightInfo.escapedHighlightId}) { --hh-color: ${colorString}; ${styleString} }
1179
+ :where(${this._containerSelector} :is(rt, img))::highlight(${highlightInfo.escapedHighlightId}),
1180
+ :where(${this._containerSelector} :is(${this._paragraphSelector}) :is(rt, img))::highlight(${highlightInfo.escapedHighlightId}) { color: inherit; background-color: transparent; }
1156
1181
  `;
1157
1182
  } else {
1158
- cssText += `mark[data-hh-highlight-id=${highlightInfo.escapedHighlightId}][data-hh-style] { ${styleString} }\n`;
1183
+ cssText += `mark[data-hh-style]:where([data-hh-highlight-id=${highlightInfo.escapedHighlightId}]) { ${styleString} }\n`;
1159
1184
  }
1160
1185
  }
1161
1186
  }
@@ -1188,7 +1213,6 @@
1188
1213
  // Get active highlight properties
1189
1214
  const activeHighlightId = this._activeHighlightId;
1190
1215
  const highlightInfo = this._highlightsById[activeHighlightId] ?? null;
1191
- const isSvgActive = activeHighlightId && highlightInfo?.resolvedDrawingMode === 'svg';
1192
1216
  const color = highlightInfo?.color ?? null;
1193
1217
  const style = highlightInfo?.style ?? null;
1194
1218
  const variables = highlightInfo?.variables ?? {};
@@ -1207,6 +1231,9 @@
1207
1231
  if (boundsHash != this._previousBoundsHash) changes.push('selection');
1208
1232
  if (changes.length === 0) return;
1209
1233
 
1234
+ // WebKit doesn't draw text decorations on ::selection, and iOS ignores ::selection styles entirely. If the style has an SVG template, use it for the active state.
1235
+ const useSvgSelection = activeHighlightId && (highlightInfo?.resolvedDrawingMode === 'svg' || isWebKit && this._getStyleDef(style)?.svg);
1236
+
1210
1237
  // Get range metadata
1211
1238
  let rangeText = null, rangeHtml = null, rangeParagraphIds = null, rangeObj = null;
1212
1239
  let rangeRect = null, rangeParagraphs = [], rangeLineRects = [];
@@ -1232,9 +1259,9 @@
1232
1259
  this._selectionState = { activeHighlightId, color, style, variables, isResizing, pointerType, selection, changes, rangeText, rangeHtml, rangeRect: rangeRect, rangeLineRects, rangeParagraphIds, rangeObj };
1233
1260
  this._previousBoundsHash = boundsHash;
1234
1261
 
1235
- // Draw SVG selection rects (SVG drawing mode only)
1262
+ // Draw SVG selection rects (SVG drawing mode, or WebKit selection style fallback)
1236
1263
  this._svgActiveOverlay.innerHTML = '';
1237
- if (isSvgActive) {
1264
+ if (useSvgSelection) {
1238
1265
  this._svgActiveOverlay.dataset.hhColor = color;
1239
1266
  this._svgActiveOverlay.dataset.hhStyle = style;
1240
1267
  let svgContent = '';
@@ -1254,33 +1281,33 @@
1254
1281
  const colorString = options.colorDefs[color] ?? (supportsAccentColor ? 'AccentColor' : 'dodgerblue');
1255
1282
  this._container.style.setProperty('--hh-color', colorString);
1256
1283
 
1257
- // 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).
1258
- if (isSvgActive) {
1259
- this._selectionStylesheet.replaceSync(`
1260
- ${this._containerSelector} g[data-hh-highlight-id="${activeHighlightId}"][data-hh-style] { display: none; }
1261
- ${this._containerSelector} [data-hh-wrapper][data-hh-highlight-id="${activeHighlightId}"] { visibility: hidden; }
1262
- ${this._containerSelector} ::selection { background-color: transparent; }
1263
- `);
1264
- } else if (activeHighlightId) {
1265
- // Remove stale SVG highlight if a highlight switches from SVG to a different resolvedDrawingMode
1266
- for (const svgHighlight of this._svgBackground.querySelectorAll(`g[data-hh-highlight-id="${activeHighlightId}"]`)) {
1267
- svgHighlight.remove();
1268
- }
1269
- const styleString = this._getStyleString(style, 'css', true, highlightInfo.variables, highlightInfo.rangeRect);
1270
- this._selectionStylesheet.replaceSync(`
1271
- ${this._containerSelector} ::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
1284
+ // Hide the active highlight (and wrappers), and either draw it with the SVG overlay or set a selection style that mimics it. 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).
1285
+ if (activeHighlightId) {
1286
+ let cssText = `
1287
+ ${this._containerSelector} ::highlight(${highlightInfo.escapedHighlightId}),
1288
+ ${this._containerSelector} :is(${this._paragraphSelector})::highlight(${highlightInfo.escapedHighlightId}),
1289
+ ${this._containerSelector} :is(${this._paragraphSelector}) *::highlight(${highlightInfo.escapedHighlightId}) { all: unset; }
1272
1290
  ${this._containerSelector} mark[data-hh-highlight-id="${activeHighlightId}"][data-hh-style] { all: unset; }
1273
1291
  ${this._containerSelector} [data-hh-wrapper][data-hh-highlight-id="${activeHighlightId}"] { visibility: hidden; }
1292
+ ${this._containerSelector} g[data-hh-highlight-id="${activeHighlightId}"][data-hh-style] { display: none; }
1274
1293
  ${this._containerSelector} ::selection { background-color: transparent; }
1275
- ${this._containerSelector} :is(${this._paragraphSelector})::selection,
1276
- ${this._containerSelector} :is(${this._paragraphSelector}) *::selection { --hh-color: ${colorString}; ${styleString} }
1277
- ${this._containerSelector} :is(rt, img)::selection { background-color: transparent; }
1278
- `);
1294
+ `;
1295
+ // If not drawn by the SVG overlay, style the selection to mimic the highlight
1296
+ if (!useSvgSelection) {
1297
+ const styleString = this._getStyleString(style, 'css', true, highlightInfo.variables, highlightInfo.rangeRect);
1298
+ cssText += `
1299
+ ${this._containerSelector} :is(${this._paragraphSelector})::selection,
1300
+ ${this._containerSelector} :is(${this._paragraphSelector}) *::selection { --hh-color: ${colorString}; ${styleString} }
1301
+ ${this._containerSelector} :is(rt, img)::selection,
1302
+ ${this._containerSelector} :is(${this._paragraphSelector}) :is(rt, img)::selection { background-color: transparent; }
1303
+ `;
1304
+ }
1305
+ this._selectionStylesheet.replaceSync(cssText);
1279
1306
 
1280
1307
  // No active highlight (show the regular selection UI)
1281
1308
  } else {
1282
1309
  this._selectionStylesheet.replaceSync(`
1283
- ${this._containerSelector} ::selection { background-color: Highlight; color: inherit; }
1310
+ :where(${this._containerSelector} *)::selection { background-color: Highlight; color: inherit; }
1284
1311
  ${this._containerSelector} rt::selection, ${this._containerSelector} img::selection { background-color: transparent; }
1285
1312
  `);
1286
1313
  }
@@ -1504,7 +1531,7 @@
1504
1531
  const options = this._options;
1505
1532
  let css = '';
1506
1533
  for (const color of Object.keys(options.colorDefs)) {
1507
- css += `${this._containerSelector} [data-hh-color="${color}"] { --hh-color: ${options.colorDefs[color]}; }\n`;
1534
+ css += `:where(${this._containerSelector} [data-hh-color="${color}"]) { --hh-color: ${options.colorDefs[color]}; }\n`;
1508
1535
  }
1509
1536
  this._colorStylesheet.replaceSync(css);
1510
1537
  }
@@ -1513,8 +1540,7 @@
1513
1540
  Highlighter.prototype._getResolvedDrawingMode = function (highlightInfo) {
1514
1541
  const options = this._options;
1515
1542
  if (options.drawingMode === 'mark-elements') return 'mark-elements';
1516
- const resolvedStyle = Object.hasOwn(options.styleDefs, highlightInfo.style) ? highlightInfo.style : _defaultStyle;
1517
- if ((options.drawingMode === 'svg' && !options.styleDefs[resolvedStyle]?.svg)
1543
+ if ((options.drawingMode === 'svg' && !this._getStyleDef(highlightInfo.style)?.svg)
1518
1544
  || (options.drawingMode === 'highlight-api' && !supportsHighlightApi)) {
1519
1545
  return 'mark-elements';
1520
1546
  }
@@ -1587,13 +1613,18 @@
1587
1613
  return str.replace(/\{[^}]+\}/g, match => map[match] ?? match);
1588
1614
  }
1589
1615
 
1616
+ // Get the style definition for a highlight style, falling back to the default style
1617
+ Highlighter.prototype._getStyleDef = function (style) {
1618
+ const styleDefs = this._options.styleDefs;
1619
+ return styleDefs[Object.hasOwn(styleDefs, style) ? style : _defaultStyle];
1620
+ }
1621
+
1590
1622
  // Get style string for a given highlight style
1591
1623
  Highlighter.prototype._getStyleString = function (style, type, active = false, variables = {}, rangeRect = null, lineRect = null) {
1592
- const options = this._options;
1593
- style = Object.hasOwn(options.styleDefs, style) ? style : _defaultStyle;
1594
- let styleString = options.styleDefs[style]?.[type] ?? '';
1624
+ const styleDef = this._getStyleDef(style);
1625
+ let styleString = styleDef?.[type] ?? '';
1595
1626
  if (active) {
1596
- styleString = options.styleDefs[style]?.[`${type}Active`] ?? styleString;
1627
+ styleString = styleDef?.[`${type}Active`] ?? styleString;
1597
1628
  }
1598
1629
  if (styleString) {
1599
1630
  styleString = this._applySubstitutions(styleString, variables, rangeRect, lineRect);
@@ -1716,6 +1747,22 @@
1716
1747
  const paragraphFontSize = Number.parseFloat(paragraphStyle.fontSize);
1717
1748
  const snapTolerance = Math.max(paragraphFontSize / 2, 4);
1718
1749
 
1750
+ const pseudoElemRects = _gatherPseudoElemRects(paragraph);
1751
+
1752
+ // Horizontal distance between a rect and a line's span so far, ignoring space taken up by pseudoelements (such as footnote markers) that contribute no text nodes. Zero or negative means the rect overlaps the span.
1753
+ const distanceToLine = (pos, rect) => {
1754
+ const gapLeft = Math.min(pos.maxRight, rect.right);
1755
+ const gapRight = Math.max(pos.minLeft, rect.left);
1756
+ let distance = gapRight - gapLeft;
1757
+ if (distance <= snapTolerance) return distance;
1758
+ for (const pseudoRect of pseudoElemRects) {
1759
+ if (pseudoRect.top >= rect.bottom || pseudoRect.bottom <= rect.top) continue;
1760
+ const overlap = Math.min(pseudoRect.right, gapRight) - Math.max(pseudoRect.left, gapLeft);
1761
+ if (overlap > 0) distance -= overlap;
1762
+ }
1763
+ return distance;
1764
+ };
1765
+
1719
1766
  const linePositions = {};
1720
1767
  const lineBottomKeys = [];
1721
1768
  const textNodeRange = document.createRange();
@@ -1731,8 +1778,7 @@
1731
1778
  for (const rect of textNodeRange.getClientRects()) {
1732
1779
  const lineBottom = lineBottomKeys.find(b => {
1733
1780
  if (Math.abs(b - rect.bottom) >= snapTolerance) return false;
1734
- const pos = linePositions[b];
1735
- return rect.left <= pos.maxRight + snapTolerance && rect.right >= pos.minLeft - snapTolerance;
1781
+ return distanceToLine(linePositions[b], rect) <= snapTolerance;
1736
1782
  }) ?? rect.bottom;
1737
1783
  if (!linePositions[lineBottom]) lineBottomKeys.push(lineBottom);
1738
1784
  const pos = linePositions[lineBottom] ??= { minLeft: rect.left, maxRight: rect.right, maxFontSize: fontSize, maxLineHeight: lineHeight };
@@ -1744,7 +1790,6 @@
1744
1790
  }
1745
1791
 
1746
1792
  // Expand line rects to include pseudoelements, and snap to the paragraph's leading edge
1747
- const pseudoElemRects = _gatherPseudoElemRects(paragraph);
1748
1793
  lineBottomKeys.sort((a, b) => a - b);
1749
1794
  const lines = lineBottomKeys.map((lineBottom) => {
1750
1795
  const pos = linePositions[lineBottom];
@@ -1761,6 +1806,8 @@
1761
1806
  }
1762
1807
 
1763
1808
  Highlighter.prototype._updateHighlightRects = function (highlightIds = Object.keys(this._highlightsById), paragraphLineRectsCache = new Map(), columnGeometryCache = new Map()) {
1809
+ // Refresh _additionsRect in case it has stale data
1810
+ this._additionsRect = this._additionsDiv.getBoundingClientRect();
1764
1811
  for (const highlightId of highlightIds) {
1765
1812
  const highlightInfo = this._highlightsById[highlightId];
1766
1813
  const rangeParagraphs = highlightInfo.rangeParagraphIds.map(id => document.getElementById(id));
@@ -1784,9 +1831,9 @@
1784
1831
  paragraphLineRectsCache.set(paragraph.id, paragraphLineRects);
1785
1832
  }
1786
1833
  const { lines, snapTolerance, pseudoElemRects } = paragraphLineRects;
1787
- // If the previous paragraph is below the current paragraph (such as in multi-column layouts), reset prevParagraphBottom
1788
1834
  if (lines.length === 0) continue;
1789
- if (prevParagraphBottom > lines[0].bottom) prevParagraphBottom = Number.NEGATIVE_INFINITY;
1835
+ // The previous paragraph's bottom is only a valid boundary if it sits above this paragraph's first line — reset it if the paragraphs are side by side, or in a later column
1836
+ if (prevParagraphBottom > lines[0].top + snapTolerance) prevParagraphBottom = Number.NEGATIVE_INFINITY;
1790
1837
  const lineStates = lines.map((lineRect, i) => ({
1791
1838
  topY: i === 0 ? prevParagraphBottom : lines[i - 1].bottom,
1792
1839
  left: Infinity, right: -Infinity,
@@ -2048,7 +2095,7 @@
2048
2095
 
2049
2096
  // Check browser type
2050
2097
  const isTouchDevice = navigator.maxTouchPoints > 0;
2051
- const isWebKit = /^((?!Chrome|Firefox|Android|Samsung).)*AppleWebKit/i.test(navigator.userAgent);
2098
+ const isWebKit = /^(?!.*(Chrome|Firefox|Android|Samsung)).*AppleWebKit/i.test(navigator.userAgent);
2052
2099
  const isWKWebView = isWebKit && globalThis.webkit?.messageHandlers;
2053
2100
  const supportsCaretPositionFromPoint = document.caretPositionFromPoint;
2054
2101
  const supportsCaretRangeFromPoint = document.caretRangeFromPoint;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samuelbradshaw/highlight-helper-js",
3
- "version": "1.0.2",
3
+ "version": "1.1.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",