From 8f890ea405718ee094c2ef9c92c4d30c33ba8f67 Mon Sep 17 00:00:00 2001 From: Gerben Date: Wed, 2 Nov 2022 15:31:29 +0100 Subject: [PATCH] Turn highlightText into a class TextHighlight Keeping the previous API available for now, can remove in a new release. --- packages/dom/src/highlight-text.ts | 164 -------------- packages/dom/src/index.ts | 2 +- packages/dom/src/text-highlight.ts | 214 ++++++++++++++++++ ...ht-text.test.ts => text-highlight.test.ts} | 76 +++++-- web/index.js | 14 +- 5 files changed, 274 insertions(+), 196 deletions(-) delete mode 100644 packages/dom/src/highlight-text.ts create mode 100644 packages/dom/src/text-highlight.ts rename packages/dom/test/highlight-text/{highlight-text.test.ts => text-highlight.test.ts} (74%) diff --git a/packages/dom/src/highlight-text.ts b/packages/dom/src/highlight-text.ts deleted file mode 100644 index 3a2448d..0000000 --- a/packages/dom/src/highlight-text.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @license - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - * - * SPDX-FileCopyrightText: The Apache Software Foundation - * SPDX-License-Identifier: Apache-2.0 - */ - -import { ownerDocument } from './owner-document.js'; -import { toRange } from './to-range.js'; - -/** - * Wrap each text node in a given Node or Range with a `` or other - * element. - * - * If a Range is given that starts and/or ends within a Text node, that node - * will be split in order to only wrap the contained part in the mark element. - * - * The highlight can be removed again by calling the function that cleans up the - * wrapper elements. Note that this might not perfectly restore the DOM to its - * previous state: text nodes that were split are not merged again. One could - * consider running `range.commonAncestorContainer.normalize()` afterwards to - * join all adjacent text nodes. - * - * @param target - The Node/Range containing the text. If it is a Range, note - * that as highlighting modifies the DOM, the Range may be unusable afterwards. - * @param tagName - The element used to wrap text nodes. Defaults to `'mark'`. - * @param attributes - An object defining any attributes to be set on the - * wrapper elements, e.g. its `class`. - * @returns A function that removes the created highlight. - * - * @public - */ -export function highlightText( - target: Node | Range, - tagName = 'mark', - attributes: Record = {}, -): () => void { - // First put all nodes in an array (splits start and end nodes if needed) - const nodes = textNodesInRange(toRange(target)); - - // Highlight each node - const highlightElements: HTMLElement[] = []; - for (const node of nodes) { - const highlightElement = wrapNodeInHighlight(node, tagName, attributes); - highlightElements.push(highlightElement); - } - - // Return a function that cleans up the highlightElements. - function removeHighlights() { - // Remove each of the created highlightElements. - for (const highlightElement of highlightElements) { - removeHighlight(highlightElement); - } - } - return removeHighlights; -} - -// Return an array of the text nodes in the range. Split the start and end nodes if required. -function textNodesInRange(range: Range): Text[] { - // If the start or end node is a text node and only partly in the range, split it. - if (isTextNode(range.startContainer) && range.startOffset > 0) { - const endOffset = range.endOffset; // (this may get lost when the splitting the node) - const createdNode = range.startContainer.splitText(range.startOffset); - if (range.endContainer === range.startContainer) { - // If the end was in the same container, it will now be in the newly created node. - range.setEnd(createdNode, endOffset - range.startOffset); - } - range.setStart(createdNode, 0); - } - if ( - isTextNode(range.endContainer) && - range.endOffset < range.endContainer.length - ) { - range.endContainer.splitText(range.endOffset); - } - - // Collect the text nodes. - const walker = ownerDocument(range).createTreeWalker( - range.commonAncestorContainer, - NodeFilter.SHOW_TEXT, - { - acceptNode: (node) => - range.intersectsNode(node) - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT, - }, - ); - walker.currentNode = range.startContainer; - - // // Optimise by skipping nodes that are explicitly outside the range. - // const NodeTypesWithCharacterOffset = [ - // Node.TEXT_NODE, - // Node.PROCESSING_INSTRUCTION_NODE, - // Node.COMMENT_NODE, - // ]; - // if (!NodeTypesWithCharacterOffset.includes(range.startContainer.nodeType)) { - // if (range.startOffset < range.startContainer.childNodes.length) { - // walker.currentNode = range.startContainer.childNodes[range.startOffset]; - // } else { - // walker.nextSibling(); // TODO verify this is correct. - // } - // } - - const nodes: Text[] = []; - if (isTextNode(walker.currentNode)) nodes.push(walker.currentNode); - while (walker.nextNode() && range.comparePoint(walker.currentNode, 0) !== 1) - nodes.push(walker.currentNode as Text); - return nodes; -} - -// Replace [node] with [node] -function wrapNodeInHighlight( - node: ChildNode, - tagName: string, - attributes: Record, -): HTMLElement { - const document = node.ownerDocument as Document; - const highlightElement = document.createElement(tagName); - Object.keys(attributes).forEach((key) => { - highlightElement.setAttribute(key, attributes[key]); - }); - const tempRange = document.createRange(); - tempRange.selectNode(node); - tempRange.surroundContents(highlightElement); - return highlightElement; -} - -// Remove a highlight element created with wrapNodeInHighlight. -function removeHighlight(highlightElement: HTMLElement) { - // If it has somehow been removed already, there is nothing to be done. - if (!highlightElement.parentNode) return; - if (highlightElement.childNodes.length === 1) { - highlightElement.replaceWith(highlightElement.firstChild as Node); - } else { - // If the highlight somehow contains multiple nodes now, move them all. - while (highlightElement.firstChild) { - highlightElement.parentNode.insertBefore( - highlightElement.firstChild, - highlightElement, - ); - } - highlightElement.remove(); - } -} - -function isTextNode(node: Node): node is Text { - return node.nodeType === Node.TEXT_NODE; -} diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts index 6969ea9..aa328ad 100644 --- a/packages/dom/src/index.ts +++ b/packages/dom/src/index.ts @@ -25,4 +25,4 @@ export * from './css.js'; export * from './range/index.js'; export * from './text-quote/index.js'; export * from './text-position/index.js'; -export * from './highlight-text.js'; +export * from './text-highlight.js'; diff --git a/packages/dom/src/text-highlight.ts b/packages/dom/src/text-highlight.ts new file mode 100644 index 0000000..dc90816 --- /dev/null +++ b/packages/dom/src/text-highlight.ts @@ -0,0 +1,214 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ownerDocument } from './owner-document.js'; +import { toRange } from './to-range.js'; + +/** + * Wrap each text node in a given Node or Range with a `` or other + * element. + * + * If a Range is given that starts and/or ends within a Text node, that node + * will be split in order to only wrap the contained part in the mark element. + * + * The highlight can be removed again by calling the function that cleans up the + * wrapper elements. Note that this might not perfectly restore the DOM to its + * previous state: text nodes that were split are not merged again. One could + * consider running `range.commonAncestorContainer.normalize()` afterwards to + * join all adjacent text nodes. + * + * @deprecated + * Use TextHighlight instead: + * + * highlight = new TextHighlight(…); + * highlight.remove(); + * + * @param target - The Node/Range containing the text. If it is a Range, note + * that as highlighting modifies the DOM, the Range may be unusable afterwards. + * @param tagName - The element used to wrap text nodes. Defaults to `'mark'`. + * @param attributes - An object defining any attributes to be set on the + * wrapper elements, e.g. its `class`. + * @returns A function that removes the created highlight. + * + * @public + */ +export function highlightText( + target: Node | Range, + tagName = 'mark', + attributes: Record = {}, +): () => void { + const highlight = new TextHighlight(target, tagName, attributes); + return () => highlight.remove(); +} + +export class TextHighlight { + public readonly highlightElements: HTMLElement[] = []; + + /** + * Wrap each text node in a given Node or Range with a `` or other + * element. + * + * If a Range is given that starts and/or ends within a Text node, that node + * will be split in order to only wrap the contained part in the mark element. + * + * @param target - The Node/Range containing the text. If it is a Range, note + * that as highlighting modifies the DOM, the Range may be unusable + * afterwards. + * @param element - The element used to wrap text nodes. Pass either a string + * with its tag name, or an Element to be cloned. Defaults to `'mark'`. + * @param attributes - An object defining any attributes to be set on the + * wrapper elements, e.g. its `class`. Not applicable if `element` is a + * function. + * @returns A {@link Highlight} object, that can be used to access the wrapper + * elements or to remove the highlight again. + * + * @public + */ + constructor( + target: Node | Range, + element: string | HTMLElement = "mark", + attributes: Record = {}, + ) { + // First put all nodes in an array (splits start and end nodes if needed) + const nodes = this.textNodesInRange(toRange(target)); + + // Highlight each node + for (const node of nodes) { + const highlightElement = this.wrapNodeInHighlight(node, element, attributes); + this.highlightElements.push(highlightElement); + } + } + + /** + * Remove each of the created highlightElements. + * + * It takes the wrapping (``) elements out of the DOM tree, thus moving + * their child node(s) one level up. + */ + public remove() { + for (const highlightElement of this.highlightElements) { + this.removeHighlightElement(highlightElement); + } + } + + /** + * Return an array of the text nodes in the range. Split the start and end nodes if required. + */ + private textNodesInRange(range: Range): Text[] { + // If the start or end node is a text node and only partly in the range, split it. + if (isTextNode(range.startContainer) && range.startOffset > 0) { + const endOffset = range.endOffset; // (this may get lost when the splitting the node) + const createdNode = range.startContainer.splitText(range.startOffset); + if (range.endContainer === range.startContainer) { + // If the end was in the same container, it will now be in the newly created node. + range.setEnd(createdNode, endOffset - range.startOffset); + } + range.setStart(createdNode, 0); + } + if ( + isTextNode(range.endContainer) && + range.endOffset < range.endContainer.length + ) { + range.endContainer.splitText(range.endOffset); + } + + // Collect the text nodes. + const walker = ownerDocument(range).createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => + range.intersectsNode(node) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + } + ); + walker.currentNode = range.startContainer; + + // // Optimise by skipping nodes that are explicitly outside the range. + // const NodeTypesWithCharacterOffset = [ + // Node.TEXT_NODE, + // Node.PROCESSING_INSTRUCTION_NODE, + // Node.COMMENT_NODE, + // ]; + // if (!NodeTypesWithCharacterOffset.includes(range.startContainer.nodeType)) { + // if (range.startOffset < range.startContainer.childNodes.length) { + // walker.currentNode = range.startContainer.childNodes[range.startOffset]; + // } else { + // walker.nextSibling(); // TODO verify this is correct. + // } + // } + + const nodes: Text[] = []; + if (isTextNode(walker.currentNode)) nodes.push(walker.currentNode); + while (walker.nextNode() && range.comparePoint(walker.currentNode, 0) !== 1) + nodes.push(walker.currentNode as Text); + return nodes; + } + + /** + * Replace [node] with [node] + */ + private wrapNodeInHighlight( + node: ChildNode, + element: string | HTMLElement, + attributes: Record + ): HTMLElement { + const document = node.ownerDocument as Document; + const highlightElement = + typeof element === "string" + ? document.createElement(element) + : (element.cloneNode() as typeof element); + Object.keys(attributes).forEach((key) => { + highlightElement.setAttribute(key, attributes[key]); + }); + const tempRange = document.createRange(); + tempRange.selectNode(node); + tempRange.surroundContents(highlightElement); + return highlightElement; + } + + /** + * Remove a highlight element created with wrapNodeInHighlight. + */ + private removeHighlightElement(highlightElement: HTMLElement) { + // If it has somehow been removed already, there is nothing to be done. + if (!highlightElement.parentNode) return; + if (highlightElement.childNodes.length === 1) { + highlightElement.replaceWith(highlightElement.firstChild as Node); + } else { + // If the highlight somehow contains multiple nodes now, move them all. + while (highlightElement.firstChild) { + highlightElement.parentNode.insertBefore( + highlightElement.firstChild, + highlightElement + ); + } + highlightElement.remove(); + } + } +} + +function isTextNode(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE; +} diff --git a/packages/dom/test/highlight-text/highlight-text.test.ts b/packages/dom/test/highlight-text/text-highlight.test.ts similarity index 74% rename from packages/dom/test/highlight-text/highlight-text.test.ts rename to packages/dom/test/highlight-text/text-highlight.test.ts index 7ef91eb..065de4d 100644 --- a/packages/dom/test/highlight-text/highlight-text.test.ts +++ b/packages/dom/test/highlight-text/text-highlight.test.ts @@ -22,7 +22,7 @@ */ import { strict as assert } from 'assert'; -import { highlightText } from '../../src/highlight-text.js'; +import { TextHighlight } from '../../src/text-highlight.js'; import type { RangeInfo } from '../utils.js'; import { hydrateRange, evaluateXPath } from '../utils.js'; @@ -32,7 +32,7 @@ const testCases: { [name: string]: { inputHtml: string; range: RangeInfo; - tagName?: string; + element?: string; attributes?: Record; expectedHtml: string; }; @@ -76,7 +76,7 @@ const testCases: { endContainerXPath: '//b/text()', endOffset: 20, }, - tagName: 'span', + element: 'span', expectedHtml: 'lorem ipsum dolor amet yada yada', }, 'custom attributes': { @@ -102,30 +102,30 @@ const testCases: { endContainerXPath: '//b/text()[2]', endOffset: 7, }, - tagName: 'mark2', + element: 'mark2', expectedHtml: 'lorem ipsum dolor amet yada yada', }, }; -describe('highlightText', () => { +describe('TextHighlight', () => { for (const [ name, - { inputHtml, range, tagName, attributes, expectedHtml }, + { inputHtml, range, element, attributes, expectedHtml }, ] of Object.entries(testCases)) { it(`works for case: ${name}`, () => { const doc = domParser.parseFromString(inputHtml, 'text/html'); - // Invoke highlightText for the specified Range, and check the result. - const removeHighlights = highlightText( + // Highlight the specified Range, and check the result. + const highlight = new TextHighlight( hydrateRange(range, doc), - tagName, + element, attributes, ); assert.equal(doc.body.innerHTML, expectedHtml); // Remove the highlight again and check that we end up exactly how we started. - removeHighlights(); + highlight.remove(); assert.equal(doc.body.innerHTML, inputHtml); }); } @@ -141,12 +141,12 @@ describe('highlightText', () => { range.setStart(evaluateXPath(doc, '//b/text()[1]'), 12); // before 'dolor am' range.setEnd(evaluateXPath(doc, '//b/text()[2]'), 20 - 15); // after 'dolor am' - const removeHighlights = highlightText(range); + const highlight = new TextHighlight(range); const expectedHtml = 'lorem ipsum dolor amet yada yada'; assert.equal(doc.body.innerHTML, expectedHtml); - removeHighlights(); + highlight.remove(); assert.equal(doc.body.innerHTML, inputHtml); }); @@ -162,12 +162,12 @@ describe('highlightText', () => { range.setStart(evaluateXPath(doc, '//b/text()[1]'), 12); // before 'dolor am' range.setEnd(evaluateXPath(doc, '//b/text()[3]'), 20 - 15); // after 'dolor am' - const removeHighlights = highlightText(range); + const highlight = new TextHighlight(range); const expectedHtml = 'lorem ipsum dolor amet yada yada'; assert.equal(doc.body.innerHTML, expectedHtml); - removeHighlights(); + highlight.remove(); assert.equal(doc.body.innerHTML, inputHtml); }); @@ -178,10 +178,10 @@ describe('highlightText', () => { const range = doc.createRange(); range.selectNode(evaluateXPath(doc, '//img')); - const removeHighlights = highlightText(range); + const highlight = new TextHighlight(range); assert.equal(doc.body.innerHTML, inputHtml); - removeHighlights(); + highlight.remove(); assert.equal(doc.body.innerHTML, inputHtml); }); @@ -190,12 +190,12 @@ describe('highlightText', () => { const { range: range2, expectedHtml } = testCases['overlapping highlight']; const doc = domParser.parseFromString(inputHtml, 'text/html'); - const removeHighlights1 = highlightText(hydrateRange(range, doc)); - const removeHighlights2 = highlightText(hydrateRange(range2, doc), 'mark2'); + const highlight1 = new TextHighlight(hydrateRange(range, doc)); + const highlight2 = new TextHighlight(hydrateRange(range2, doc), 'mark2'); assert.equal(doc.body.innerHTML, expectedHtml); - removeHighlights1(); - removeHighlights2(); + highlight1.remove(); + highlight2.remove(); assert.equal(doc.body.innerHTML, inputHtml); }); @@ -204,12 +204,40 @@ describe('highlightText', () => { const { range: range2, expectedHtml } = testCases['overlapping highlight']; const doc = domParser.parseFromString(inputHtml, 'text/html'); - const removeHighlights1 = highlightText(hydrateRange(range, doc)); - const removeHighlights2 = highlightText(hydrateRange(range2, doc), 'mark2'); + const highlight1 = new TextHighlight(hydrateRange(range, doc)); + const highlight2 = new TextHighlight(hydrateRange(range2, doc), 'mark2'); assert.equal(doc.body.innerHTML, expectedHtml); - removeHighlights2(); - removeHighlights1(); + highlight2.remove(); + highlight1.remove(); assert.equal(doc.body.innerHTML, inputHtml); }); + + it('Clones a given wrapper element', () => { + const { inputHtml, range } = testCases['across elements']; + const doc = domParser.parseFromString(inputHtml, 'text/html'); + const element = doc.createElement('span'); + element.setAttribute('class', 'highlighted'); + + const highlight = new TextHighlight(hydrateRange(range, doc), element); + const expectedHtml = + 'lorem ipsum dolor amet yada yada'; + assert.equal(doc.body.innerHTML, expectedHtml); + + highlight.remove(); + assert.equal(doc.body.innerHTML, inputHtml); + }); + + it('exposes the wrapper elements', () => { + const { inputHtml, range } = testCases['across elements']; + const doc = domParser.parseFromString(inputHtml, 'text/html'); + + const highlight = new TextHighlight(hydrateRange(range, doc)); + + assert.equal(highlight.highlightElements.length, 2); + assert.equal(highlight.highlightElements[0].tagName, 'MARK'); + assert.equal(highlight.highlightElements[0].textContent, 'dolor '); + assert.equal(highlight.highlightElements[1].tagName, 'MARK'); + assert.equal(highlight.highlightElements[1].textContent, 'am'); + }); }); diff --git a/web/index.js b/web/index.js index 7917798..39e3d08 100644 --- a/web/index.js +++ b/web/index.js @@ -29,7 +29,7 @@ import { describeTextQuote, createTextPositionSelectorMatcher, describeTextPosition, - highlightText, + TextHighlight, } from '@apache-annotator/dom'; import { makeRefinable } from '@apache-annotator/selector'; @@ -87,13 +87,13 @@ const EXAMPLE_SELECTORS = [ ]; let moduleState = { - cleanupFunctions: [], + highlights: [], }; function cleanup() { - let removeHighlight; - while ((removeHighlight = moduleState.cleanupFunctions.shift())) { - removeHighlight(); + let highlight; + while ((highlight = moduleState.highlights.shift())) { + highlight.remove(); } target.normalize(); info.innerText = ''; @@ -124,8 +124,8 @@ async function anchor(selector) { } for (const range of ranges) { - const removeHighlight = highlightText(range); - moduleState.cleanupFunctions.push(removeHighlight); + const highlight = new TextHighlight(range); + moduleState.highlights.push(highlight); } info.innerText += JSON.stringify(selector, null, 2) + '\n\n';