@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 +15 -12
- package/highlight-helper.js +97 -50
- package/package.json +1 -1
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.
|
|
225
|
-
- **selection** – Show custom handles for text selection.
|
|
226
|
-
- **touch** – Show custom handles on touch devices (may
|
|
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
|
|
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
|
|
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
|
|
package/highlight-helper.js
CHANGED
|
@@ -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:
|
|
140
|
-
height: calc(100% + 1.
|
|
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: -
|
|
145
|
-
[data-hh-handle][data-hh-side="right"] [draggable] { left: -
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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})
|
|
1155
|
-
${this._containerSelector} :is(rt, img)::highlight(${highlightInfo.escapedHighlightId})
|
|
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}]
|
|
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
|
|
1262
|
+
// Draw SVG selection rects (SVG drawing mode, or WebKit selection style fallback)
|
|
1236
1263
|
this._svgActiveOverlay.innerHTML = '';
|
|
1237
|
-
if (
|
|
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
|
|
1258
|
-
if (
|
|
1259
|
-
|
|
1260
|
-
${this._containerSelector}
|
|
1261
|
-
${this._containerSelector}
|
|
1262
|
-
${this._containerSelector}
|
|
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
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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 +=
|
|
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
|
-
|
|
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
|
|
1593
|
-
|
|
1594
|
-
let styleString = options.styleDefs[style]?.[type] ?? '';
|
|
1624
|
+
const styleDef = this._getStyleDef(style);
|
|
1625
|
+
let styleString = styleDef?.[type] ?? '';
|
|
1595
1626
|
if (active) {
|
|
1596
|
-
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
|
-
|
|
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
|
-
|
|
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 = /^((
|
|
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