Unverified Commit d78c29cd authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #1682 from hypothesis/convert-highlighter-to-js

Convert text highlighter to JS
parents b72e00eb d7bf8116
import $ from 'jquery';
/**
* Wraps the DOM Nodes within the provided range with a highlight
* element of the specified class and returns the highlight Elements.
*
* @param {NormalizedRange} normedRange - Range to be highlighted.
* @param {string} cssClass - A CSS class to use for the highlight (default: 'annotator-hl')
* @return {HTMLElement[]} - Elements wrapping text in `normedRange` to add a highlight effect
*/
export function highlightRange(normedRange, cssClass = 'annotator-hl') {
const white = /^\s*$/;
// A custom element name is used here rather than `<span>` to reduce the
// likelihood of highlights being hidden by page styling.
const hl = $(
`<hypothesis-highlight class='${cssClass}'></hypothesis-highlight>`
);
// Ignore text nodes that contain only whitespace characters. This prevents
// spans being injected between elements that can only contain a restricted
// subset of nodes such as table rows and lists. This does mean that there
// may be the odd abandoned whitespace node in a paragraph that is skipped
// but better than breaking table layouts.
const nodes = $(normedRange.textNodes()).filter(function() {
return !white.test(this.nodeValue);
});
return nodes
.wrap(hl)
.parent()
.toArray();
}
/**
* Remove highlights from a range previously highlighted with `highlightRange`.
*
* @param {HTMLElement[]} highlights - The highlight elements returned by `highlightRange`
*/
export function removeHighlights(highlights) {
for (let h of highlights) {
if (h.parentNode) {
$(h).replaceWith(h.childNodes);
}
}
}
/**
* @typedef Rect
* @prop {number} top
* @prop {number} left
* @prop {number} bottom
* @prop {number} right
*/
/**
* Get the bounding client rectangle of a collection in viewport coordinates.
* Unfortunately, Chrome has issues ([1]) with Range.getBoundingClient rect or we
* could just use that.
*
* [1] https://bugs.chromium.org/p/chromium/issues/detail?id=324437
*
* @param {HTMLElement[]} collection
* @return {Rect}
*/
export function getBoundingClientRect(collection) {
// Reduce the client rectangles of the highlights to a bounding box
const rects = collection.map(n => n.getBoundingClientRect());
return rects.reduce((acc, r) => ({
top: Math.min(acc.top, r.top),
left: Math.min(acc.left, r.left),
bottom: Math.max(acc.bottom, r.bottom),
right: Math.max(acc.right, r.right),
}));
}
$ = require('jquery')
# Public: Wraps the DOM Nodes within the provided range with a highlight
# element of the specified class and returns the highlight Elements.
#
# normedRange - A NormalizedRange to be highlighted.
# cssClass - A CSS class to use for the highlight (default: 'annotator-hl')
#
# Returns an array of highlight Elements.
exports.highlightRange = (normedRange, cssClass='annotator-hl') ->
white = /^\s*$/
# A custom element name is used here rather than `<span>` to reduce the
# likelihood of highlights being hidden by page styling.
hl = $("<hypothesis-highlight class='#{cssClass}'></hypothesis-highlight>")
# Ignore text nodes that contain only whitespace characters. This prevents
# spans being injected between elements that can only contain a restricted
# subset of nodes such as table rows and lists. This does mean that there
# may be the odd abandoned whitespace node in a paragraph that is skipped
# but better than breaking table layouts.
nodes = $(normedRange.textNodes()).filter((i) -> not white.test @nodeValue)
return nodes.wrap(hl).parent().toArray()
exports.removeHighlights = (highlights) ->
for h in highlights when h.parentNode?
$(h).replaceWith(h.childNodes)
# Get the bounding client rectangle of a collection in viewport coordinates.
# Unfortunately, Chrome has issues[1] with Range.getBoundingClient rect or we
# could just use that.
# [1] https://code.google.com/p/chromium/issues/detail?id=324437
exports.getBoundingClientRect = (collection) ->
# Reduce the client rectangles of the highlights to a bounding box
rects = collection.map((n) -> n.getBoundingClientRect())
return rects.reduce (acc, r) ->
top: Math.min(acc.top, r.top)
left: Math.min(acc.left, r.left)
bottom: Math.max(acc.bottom, r.bottom)
right: Math.max(acc.right, r.right)
Range = require('../../anchoring/range')
$ = require('jquery')
highlighter = require('./index')
describe "highlightRange", ->
it 'wraps a highlight span around the given range', ->
txt = document.createTextNode('test highlight span')
el = document.createElement('span')
el.appendChild(txt)
r = new Range.NormalizedRange({
commonAncestor: el,
start: txt,
end: txt
})
result = highlighter.highlightRange(r)
assert.equal(result.length, 1)
assert.strictEqual(el.childNodes[0], result[0])
assert.equal(result[0].nodeName, 'HYPOTHESIS-HIGHLIGHT');
assert.isTrue(result[0].classList.contains('annotator-hl'))
it 'skips text nodes that are only white space', ->
txt = document.createTextNode('one')
blank = document.createTextNode(' ')
txt2 = document.createTextNode('two')
el = document.createElement('span')
el.appendChild(txt)
el.appendChild(blank)
el.appendChild(txt2)
r = new Range.NormalizedRange({
commonAncestor: el,
start: txt,
end: txt2
})
result = highlighter.highlightRange(r)
assert.equal(result.length, 2)
assert.strictEqual(el.childNodes[0], result[0])
assert.strictEqual(el.childNodes[2], result[1])
describe 'removeHighlights', ->
it 'unwraps all the elements', ->
txt = document.createTextNode('word')
el = document.createElement('span')
hl = document.createElement('span')
div = document.createElement('div')
el.appendChild(txt)
hl.appendChild(el)
div.appendChild(hl)
highlighter.removeHighlights([hl])
assert.isNull(hl.parentNode)
assert.strictEqual(el.parentNode, div)
it 'does not fail on nodes with no parent', ->
txt = document.createTextNode('no parent')
hl = document.createElement('span')
hl.appendChild(txt)
highlighter.removeHighlights([hl])
describe "getBoundingClientRect", ->
it 'returns the bounding box of all the highlight client rectangles', ->
rects = [
{
top: 20
left: 15
bottom: 30
right: 25
}
{
top: 10
left: 15
bottom: 20
right: 25
}
{
top: 15
left: 20
bottom: 25
right: 30
}
{
top: 15
left: 10
bottom: 25
right: 20
}
]
fakeHighlights = rects.map (r) ->
return getBoundingClientRect: -> r
result = highlighter.getBoundingClientRect(fakeHighlights)
assert.equal(result.left, 10)
assert.equal(result.top, 10)
assert.equal(result.right, 30)
assert.equal(result.bottom, 30)
const domWrapHighlighter = require('./dom-wrap-highlighter');
const overlayHighlighter = require('./overlay-highlighter');
const features = require('../features');
// we need a facade for the highlighter interface
// that will let us lazy check the overlay_highlighter feature
// flag and later determine which interface should be used.
const highlighterFacade = {};
let overlayFlagEnabled;
Object.keys(domWrapHighlighter).forEach(methodName => {
highlighterFacade[methodName] = (...args) => {
// lazy check the value but we will
// use that first value as the rule throughout
// the in memory session
if (overlayFlagEnabled === undefined) {
overlayFlagEnabled = features.flagEnabled('overlay_highlighter');
}
const method = overlayFlagEnabled
? overlayHighlighter[methodName]
: domWrapHighlighter[methodName];
return method.apply(null, args);
};
});
module.exports = highlighterFacade;
module.exports = {
highlightRange: () => {
// eslint-disable-next-line no-console
console.log('highlightRange not implemented');
},
removeHighlights: () => {
// eslint-disable-next-line no-console
console.log('removeHighlights not implemented');
},
getBoundingClientRect: () => {
// eslint-disable-next-line no-console
console.log('getBoundingClientRect not implemented');
},
};
import Range from '../anchoring/range';
import {
highlightRange,
removeHighlights,
getBoundingClientRect,
} from '../highlighter';
describe('annotator/highlighter', () => {
describe('highlightRange', () => {
it('wraps a highlight span around the given range', () => {
const txt = document.createTextNode('test highlight span');
const el = document.createElement('span');
el.appendChild(txt);
const r = new Range.NormalizedRange({
commonAncestor: el,
start: txt,
end: txt,
});
const result = highlightRange(r);
assert.equal(result.length, 1);
assert.strictEqual(el.childNodes[0], result[0]);
assert.equal(result[0].nodeName, 'HYPOTHESIS-HIGHLIGHT');
assert.isTrue(result[0].classList.contains('annotator-hl'));
});
it('skips text nodes that are only white space', () => {
const txt = document.createTextNode('one');
const blank = document.createTextNode(' ');
const txt2 = document.createTextNode('two');
const el = document.createElement('span');
el.appendChild(txt);
el.appendChild(blank);
el.appendChild(txt2);
const r = new Range.NormalizedRange({
commonAncestor: el,
start: txt,
end: txt2,
});
const result = highlightRange(r);
assert.equal(result.length, 2);
assert.strictEqual(el.childNodes[0], result[0]);
assert.strictEqual(el.childNodes[2], result[1]);
});
});
describe('removeHighlights', () => {
it('unwraps all the elements', () => {
const txt = document.createTextNode('word');
const el = document.createElement('span');
const hl = document.createElement('span');
const div = document.createElement('div');
el.appendChild(txt);
hl.appendChild(el);
div.appendChild(hl);
removeHighlights([hl]);
assert.isNull(hl.parentNode);
assert.strictEqual(el.parentNode, div);
});
it('does not fail on nodes with no parent', () => {
const txt = document.createTextNode('no parent');
const hl = document.createElement('span');
hl.appendChild(txt);
removeHighlights([hl]);
});
});
describe('getBoundingClientRect', () => {
it('returns the bounding box of all the highlight client rectangles', () => {
const rects = [
{
top: 20,
left: 15,
bottom: 30,
right: 25,
},
{
top: 10,
left: 15,
bottom: 20,
right: 25,
},
{
top: 15,
left: 20,
bottom: 25,
right: 30,
},
{
top: 15,
left: 10,
bottom: 25,
right: 20,
},
];
const fakeHighlights = rects.map(r => {
return { getBoundingClientRect: () => r };
});
const result = getBoundingClientRect(fakeHighlights);
assert.equal(result.left, 10);
assert.equal(result.top, 10);
assert.equal(result.right, 30);
assert.equal(result.bottom, 30);
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment