blob: 6b42785813e3fa8c4b12f0d129859b91ada4180e [file] [log] [blame]
/**
* @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.
*/
import { assert } from 'chai';
import { highlightText } from '../../src/highlight-text';
import type { RangeInfo } from '../utils';
import { hydrateRange, evaluateXPath } from '../utils';
const domParser = new DOMParser();
const testCases: {
[name: string]: {
inputHtml: string;
range: RangeInfo;
tagName?: string;
attributes?: Record<string, string>;
expectedHtml: string;
};
} = {
'single text node': {
inputHtml: '<b>lorem ipsum dolor amet yada yada</b>',
range: {
startContainerXPath: '//b/text()',
startOffset: 12,
endContainerXPath: '//b/text()',
endOffset: 20,
},
expectedHtml: '<b>lorem ipsum <mark>dolor am</mark>et yada yada</b>',
},
'across elements': {
inputHtml: '<b>lorem <i>ipsum</i> dolor <u>amet</u> yada yada</b>',
range: {
startContainerXPath: '//b/text()[2]',
startOffset: 1,
endContainerXPath: '//u/text()',
endOffset: 2,
},
expectedHtml:
'<b>lorem <i>ipsum</i> <mark>dolor </mark><u><mark>am</mark>et</u> yada yada</b>',
},
'collapsed range': {
inputHtml: '<b>lorem ipsum dolor amet yada yada</b>',
range: {
startContainerXPath: '//b/text()',
startOffset: 12,
endContainerXPath: '//b/text()',
endOffset: 12,
},
expectedHtml: '<b>lorem ipsum <mark></mark>dolor amet yada yada</b>',
},
'custom tag name': {
inputHtml: '<b>lorem ipsum dolor amet yada yada</b>',
range: {
startContainerXPath: '//b/text()',
startOffset: 12,
endContainerXPath: '//b/text()',
endOffset: 20,
},
tagName: 'span',
expectedHtml: '<b>lorem ipsum <span>dolor am</span>et yada yada</b>',
},
'custom attributes': {
inputHtml: '<b>lorem ipsum dolor amet yada yada</b>',
range: {
startContainerXPath: '//b/text()',
startOffset: 12,
endContainerXPath: '//b/text()',
endOffset: 20,
},
attributes: {
class: 'red',
},
expectedHtml:
'<b>lorem ipsum <mark class="red">dolor am</mark>et yada yada</b>',
},
'overlapping highlight': {
// Starts off from the result of the 'single text node' case.
inputHtml: '<b>lorem ipsum <mark>dolor am</mark>et yada yada</b>',
range: {
startContainerXPath: '//mark/text()',
startOffset: 6,
endContainerXPath: '//b/text()[2]',
endOffset: 7,
},
tagName: 'mark2',
expectedHtml:
'<b>lorem ipsum <mark>dolor <mark2>am</mark2></mark><mark2>et yada</mark2> yada</b>',
},
};
describe('highlightText', () => {
for (const [
name,
{ inputHtml, range, tagName, 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(
hydrateRange(range, doc),
tagName,
attributes,
);
assert.equal(doc.body.innerHTML, expectedHtml);
// Remove the highlight again and check that we end up exactly how we started.
removeHighlights();
assert.equal(doc.body.innerHTML, inputHtml);
});
}
it('works on adjacent text nodes', () => {
const inputHtml = '<b>lorem ipsum dolor amet yada yada</b>';
const doc = domParser.parseFromString(inputHtml, 'text/html');
const textNode = evaluateXPath(doc, '//b/text()') as Text;
textNode.splitText(15); // after 'dol'
const range = doc.createRange();
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 expectedHtml =
'<b>lorem ipsum <mark>dol</mark><mark>or am</mark>et yada yada</b>';
assert.equal(doc.body.innerHTML, expectedHtml);
removeHighlights();
assert.equal(doc.body.innerHTML, inputHtml);
});
it('also marks empty text nodes', () => {
const inputHtml = '<b>lorem ipsum dolor amet yada yada</b>';
const doc = domParser.parseFromString(inputHtml, 'text/html');
const textNode = evaluateXPath(doc, '//b/text()') as Text;
textNode.splitText(15);
textNode.splitText(15); // Split the node twice to create an empty text node.
const range = doc.createRange();
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 expectedHtml =
'<b>lorem ipsum <mark>dol</mark><mark></mark><mark>or am</mark>et yada yada</b>';
assert.equal(doc.body.innerHTML, expectedHtml);
removeHighlights();
assert.equal(doc.body.innerHTML, inputHtml);
});
it('ignores a range that does not contain Text nodes', () => {
const inputHtml = `<b>Try highlighting this image: <img> — would that work?</b>`;
const doc = domParser.parseFromString(inputHtml, 'text/html');
const range = doc.createRange();
range.selectNode(evaluateXPath(doc, '//img'));
const removeHighlights = highlightText(range);
assert.equal(doc.body.innerHTML, inputHtml);
removeHighlights();
assert.equal(doc.body.innerHTML, inputHtml);
});
it('correctly removes multiple highlights (fifo order)', () => {
const { inputHtml, range } = testCases['single text node'];
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');
assert.equal(doc.body.innerHTML, expectedHtml);
removeHighlights1();
removeHighlights2();
assert.equal(doc.body.innerHTML, inputHtml);
});
it('correctly removes multiple highlights (lifo order)', () => {
const { inputHtml, range } = testCases['single text node'];
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');
assert.equal(doc.body.innerHTML, expectedHtml);
removeHighlights2();
removeHighlights1();
assert.equal(doc.body.innerHTML, inputHtml);
});
});