Skip to content

Commit aa2e17b

Browse files
Allow editing highlights in inserted-marks drawing mode
1 parent 51ab037 commit aa2e17b

File tree

4 files changed

+67
-18
lines changed

4 files changed

+67
-18
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Behind the scenes, Highlight Helper supports three different mechanisms for draw
66

77
1. [SVG shapes](https://developer.mozilla.org/en-US/docs/Web/SVG) drawn behind text (default).
88
2. The [CSS Custom Highlight API](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API) (experimental*).
9-
3. Inserted [HTML mark elements](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/mark) (for read-only highlights).
9+
3. Inserted [HTML mark elements](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/mark).
1010

1111
An HTML demo page that shows basic functionality can be found here: [Highlight Helper Demo](https://samuelbradshaw.github.io/highlight-helper-js/demo.html). Source code for the demo is in **demo.html**. Highlight Helper itself is in **highlight-helper.js**.
1212

demo.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ <h1 id="title1">Highlight Helper Demo</h1>
142142
Drawing mode:<br>
143143
<label><input type="radio" name="hh-drawing-mode" value="svg" checked> SVG shapes</label><br>
144144
<label><input type="radio" name="hh-drawing-mode" value="highlight-api"> Custom Highlight API</label><br>
145-
<label><input type="radio" name="hh-drawing-mode" value="inserted-marks"> Inserted mark elements (read-only)</label><br>
145+
<label><input type="radio" name="hh-drawing-mode" value="inserted-marks"> Inserted mark elements</label><br>
146146
</p>
147147
<b>Active highlight:</b>
148148
<div id="snippet-container">

highlight-helper.js

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -218,13 +218,12 @@ function Highlighter(options = hhDefaultOptions) {
218218
const highlightInfo = highlightsById[highlightId];
219219
let range = getCorrectedRangeObj(highlightId);
220220
const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
221-
const isReadOnly = (options.drawingMode === 'inserted-marks') || highlightInfo.readOnly;
222221
const wasDrawnAsReadOnly = this.annotatableContainer.querySelector(`[data-highlight-id="${highlightId}"][data-read-only]`);
223222

224223
// Remove old highlight elements and styles
225-
if (!wasDrawnAsReadOnly || (wasDrawnAsReadOnly && !isReadOnly)) undrawHighlight(highlightInfo);
224+
if (!wasDrawnAsReadOnly || (wasDrawnAsReadOnly && !highlightInfo.readOnly)) undrawHighlight(highlightInfo);
226225

227-
if (isReadOnly) {
226+
if (highlightInfo.readOnly || options.drawingMode === 'inserted-marks') {
228227
// Don't redraw a read-only highlight
229228
if (wasDrawnAsReadOnly) continue;
230229

@@ -234,24 +233,31 @@ function Highlighter(options = hhDefaultOptions) {
234233
const textNodeIter = document.createNodeIterator(range.commonAncestorContainer, NodeFilter.SHOW_TEXT);
235234
const relevantTextNodes = [];
236235
while (node = textNodeIter.nextNode()) {
237-
if (range.intersectsNode(node) && node !== range.startContainer && node.textContent !== '' && !node.parentElement.closest('rt')) relevantTextNodes.push(node);
236+
if (range.intersectsNode(node) && node !== range.startContainer && node.textContent !== '' && !node.parentElement.closest('rt') && node.parentElement.closest(options.paragraphSelector)) relevantTextNodes.push(node);
238237
if (node === range.endContainer) break;
239238
}
239+
const overlappingHighlightIds = new Set();
240240
for (let tn = 0; tn < relevantTextNodes.length; tn++) {
241241
const textNode = relevantTextNodes[tn];
242+
if (textNode.parentElement.dataset.highlightId) {
243+
overlappingHighlightIds.add(textNode.parentElement.dataset.highlightId)
244+
}
242245
const styledMark = document.createElement('mark');
243246
styledMark.dataset.highlightId = highlightId;
244-
styledMark.dataset.readOnly = '';
247+
if (highlightInfo.readOnly) styledMark.dataset.readOnly = '';
245248
styledMark.dataset.color = highlightInfo.color;
246249
styledMark.dataset.style = highlightInfo.style;
247250
if (tn === 0) styledMark.dataset.start = '';
248251
if (tn === relevantTextNodes.length - 1) styledMark.dataset.end = '';
249252
textNode.before(styledMark);
250253
styledMark.appendChild(textNode);
251254
}
252-
rangeParagraphs.forEach(p => { p.normalize(); });
253-
// Update the highlight's stored range object (because the DOM changed)
255+
256+
// Update highlight ranges that were invalidated by the DOM change
254257
range = getCorrectedRangeObj(highlightId);
258+
for (const overlappingHighlightId of overlappingHighlightIds) {
259+
getCorrectedRangeObj(overlappingHighlightId);
260+
}
255261
} else {
256262
// Draw highlights with Custom Highlight API
257263
if (options.drawingMode === 'highlight-api' && supportsHighlightApi) {
@@ -286,7 +292,7 @@ function Highlighter(options = hhDefaultOptions) {
286292
}
287293

288294
// Update wrapper (for read-only highlights only)
289-
if (isReadOnly && !wasDrawnAsReadOnly) {
295+
if (highlightInfo.readOnly && !wasDrawnAsReadOnly) {
290296
if (highlightInfo.wrapper && (options.wrappers[highlightInfo.wrapper]?.start || options.wrappers[highlightInfo.wrapper]?.end)) {
291297
const addWrapper = (edge, range, htmlString) => {
292298
htmlString = `<span class="hh-wrapper-${edge}" data-highlight-id="${highlightId}" data-color="${highlightInfo.color}" data-style="${highlightInfo.style}">${htmlString}</span>`
@@ -443,7 +449,10 @@ function Highlighter(options = hhDefaultOptions) {
443449
changes: appearanceChanges.concat(boundsChanges),
444450
}
445451

446-
this.drawHighlights([highlightId]);
452+
if (highlightId !== activeHighlightId || options.drawingMode !== 'inserted-marks') {
453+
this.drawHighlights([highlightId]);
454+
}
455+
447456
if (highlightId === activeHighlightId && appearanceChanges.length > 0) {
448457
updateSelectionUi('appearance');
449458
} else if (triggeredByUserAction && highlightId !== activeHighlightId) {
@@ -460,14 +469,25 @@ function Highlighter(options = hhDefaultOptions) {
460469
// Activate a highlight by ID
461470
this.activateHighlight = (highlightId) => {
462471
const highlightToActivate = highlightsById[highlightId];
463-
if (options.drawingMode === 'inserted-marks' || highlightToActivate.readOnly) {
472+
if (highlightToActivate.readOnly) {
464473
// If the highlight is read-only, return events, but don't actually activate it
465474
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightactivate', { detail: { highlight: highlightToActivate } }));
466475
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: { highlight: highlightToActivate } }));
467476
return;
468477
}
469478
const selection = window.getSelection();
470479
const highlightRange = highlightToActivate.rangeObj.cloneRange();
480+
481+
// Hide <mark> highlights and wrappers while the highlight is active. This prevents it from getting visually out of sync with the selection UI (mark highlights and wrappers aren't redrawn while the highlight is active, because DOM manipulation can make the selection UI unstable).
482+
for (const element of this.annotatableContainer.querySelectorAll(`[data-highlight-id="${highlightId}"]:not(g)`)) {
483+
if (element.tagName.toLowerCase() === 'mark') {
484+
element.dataset.color = '';
485+
element.dataset.style = '';
486+
} else {
487+
element.style.display = 'none';
488+
}
489+
}
490+
471491
activeHighlightId = highlightId;
472492
updateSelectionUi('appearance');
473493
selection.setBaseAndExtent(highlightRange.startContainer, highlightRange.startOffset, highlightRange.endContainer, highlightRange.endOffset);
@@ -494,6 +514,9 @@ function Highlighter(options = hhDefaultOptions) {
494514
}
495515
updateSelectionUi('appearance');
496516
if (deactivatedHighlight) {
517+
if (options.drawingMode === 'inserted-marks') {
518+
this.drawHighlights([deactivatedHighlight.highlightId]);
519+
}
497520
this.annotatableContainer.dispatchEvent(new CustomEvent('hh:highlightdeactivate', { detail: {
498521
highlight: deactivatedHighlight,
499522
}}));
@@ -802,20 +825,46 @@ function Highlighter(options = hhDefaultOptions) {
802825
const undrawHighlight = (highlightInfo) => {
803826
const highlightId = highlightInfo.highlightId;
804827

805-
// Remove HTML and SVG elements
806-
if (document.querySelector('[data-highlight-id]')) {
807-
this.annotatableContainer.querySelectorAll(`[data-highlight-id="${highlightId}"]`).forEach(element => {
808-
if (element.hasAttribute('data-read-only')) {
828+
// Remove <mark> highlights and HTML wrappers
829+
if (this.annotatableContainer.querySelector(`[data-highlight-id="${highlightId}"]:not(g)`)) {
830+
const overlappingHighlightIds = new Set();
831+
this.annotatableContainer.querySelectorAll(`[data-highlight-id="${highlightId}"]:not(g)`).forEach(element => {
832+
if (element.parentElement.dataset.highlightId) {
833+
overlappingHighlightIds.add(element.parentElement.dataset.highlightId);
834+
}
835+
for (const childHighlight of element.querySelectorAll('[data-highlight-id]')) {
836+
overlappingHighlightIds.add(childHighlight.dataset.highlightId);
837+
}
838+
if (element.tagName.toLowerCase() === 'mark') {
809839
element.outerHTML = element.innerHTML;
810840
} else {
811841
element.remove();
812842
}
813843
});
814-
const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.rangeParagraphIds.join(', #')}`);
844+
845+
// Redraw overlapping highlights
846+
overlappingHighlightIds.delete(highlightId);
847+
if (overlappingHighlightIds.size > 0) {
848+
for (const overlappingHighlightId of overlappingHighlightIds) {
849+
undrawHighlight(highlightsById[overlappingHighlightId]);
850+
}
851+
this.drawHighlights(overlappingHighlightIds);
852+
}
853+
854+
// Normalize text nodes
855+
const rangeParagraphs = this.annotatableContainer.querySelectorAll(`#${highlightInfo.startParagraphId}, #${highlightInfo.endParagraphId}`);
815856
rangeParagraphs.forEach(p => { p.normalize(); });
816857
getCorrectedRangeObj(highlightId);
817858
}
818859

860+
// Remove SVG highlights
861+
if (svgBackground.querySelector(`g[data-highlight-id="${highlightId}"]`)) {
862+
const overlappingHighlightIds = new Set();
863+
svgBackground.querySelectorAll(`g[data-highlight-id="${highlightId}"]`).forEach(element => {
864+
element.remove();
865+
});
866+
}
867+
819868
// Remove Highlight API highlights
820869
if (supportsHighlightApi && CSS.highlights.has(highlightId)) {
821870
const ruleIndexesToDelete = [];

test-load.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ <h1><span id="highlights-count"></span> Highlights <small id="time-to-load"></sm
3939
Drawing mode:<br>
4040
<label><input type="radio" name="hh-drawing-mode" value="svg" checked> SVG shapes</label><br>
4141
<label><input type="radio" name="hh-drawing-mode" value="highlight-api"> Custom Highlight API</label><br>
42-
<label><input type="radio" name="hh-drawing-mode" value="inserted-marks"> HTML mark elements (read-only)</label><br>
42+
<label><input type="radio" name="hh-drawing-mode" value="inserted-marks"> HTML mark elements</label><br>
4343
</p>
4444

4545
<main>

0 commit comments

Comments
 (0)