Commit e1531877 authored by Robert Knight's avatar Robert Knight

Use `TextRange` to improve handling of overlapping highlights

After resolving an annotation's selectors to a `Range`, convert the
range to a `TextRange` object which is stored with the anchor and later resolved
to a concrete `Range` just before inserting highlights. Representing the
anchored region using a `TextRange` allows for the correct text to be
highlighted later if the region has been modified as a result of
inserting other highlights, providing that the text content of the
region has not been changed.

Fixes https://github.com/hypothesis/h/issues/3278
Fixes https://github.com/hypothesis/client/issues/2326
Fixes https://github.com/hypothesis/h/issues/5997
parent 5445c2a3
...@@ -4,6 +4,7 @@ import Delegator from './delegator'; ...@@ -4,6 +4,7 @@ import Delegator from './delegator';
import { Adder } from './adder'; import { Adder } from './adder';
import * as htmlAnchoring from './anchoring/html'; import * as htmlAnchoring from './anchoring/html';
import { TextRange } from './anchoring/text-range';
import { import {
getHighlightsContainingNode, getHighlightsContainingNode,
highlightRange, highlightRange,
...@@ -60,6 +61,26 @@ function annotationsAt(node) { ...@@ -60,6 +61,26 @@ function annotationsAt(node) {
return /** @type {AnnotationData[]} */ (items); return /** @type {AnnotationData[]} */ (items);
} }
/**
* Resolve an anchor's associated document region to a concrete `Range`.
*
* This may fail if anchoring failed or if the document has been mutated since
* the anchor was created in a way that invalidates the anchor.
*
* @param {Anchor} anchor
* @return {Range|null}
*/
function resolveAnchor(anchor) {
if (!anchor.range) {
return null;
}
try {
return anchor.range.toRange();
} catch {
return null;
}
}
/** /**
* `Guest` is the central class of the annotator that handles anchoring (locating) * `Guest` is the central class of the annotator that handles anchoring (locating)
* annotations in the document when they are fetched by the sidebar, rendering * annotations in the document when they are fetched by the sidebar, rendering
...@@ -300,10 +321,15 @@ export default class Guest extends Delegator { ...@@ -300,10 +321,15 @@ export default class Guest extends Delegator {
for (let anchor of this.anchors) { for (let anchor of this.anchors) {
if (anchor.highlights) { if (anchor.highlights) {
if (anchor.annotation.$tag === tag) { if (anchor.annotation.$tag === tag) {
const range = resolveAnchor(anchor);
if (!range) {
continue;
}
const event = new CustomEvent('scrolltorange', { const event = new CustomEvent('scrolltorange', {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
detail: anchor.range, detail: range,
}); });
const defaultNotPrevented = this.element.dispatchEvent(event); const defaultNotPrevented = this.element.dispatchEvent(event);
if (defaultNotPrevented) { if (defaultNotPrevented) {
...@@ -396,7 +422,12 @@ export default class Guest extends Delegator { ...@@ -396,7 +422,12 @@ export default class Guest extends Delegator {
.then(range => ({ .then(range => ({
annotation, annotation,
target, target,
range,
// Convert the `Range` to a `TextRange` which can be converted back to
// a `Range` later. The `TextRange` representation allows for highlights
// to be inserted during anchoring other annotations without "breaking"
// this anchor.
range: TextRange.fromRange(range),
})) }))
.catch(() => ({ .catch(() => ({
annotation, annotation,
...@@ -410,11 +441,13 @@ export default class Guest extends Delegator { ...@@ -410,11 +441,13 @@ export default class Guest extends Delegator {
* @param {Anchor} anchor * @param {Anchor} anchor
*/ */
const highlight = anchor => { const highlight = anchor => {
if (!anchor.range) { const range = resolveAnchor(anchor);
if (!range) {
return anchor; return anchor;
} }
const highlights = /** @type {AnnotationHighlight[]} */ (highlightRange( const highlights = /** @type {AnnotationHighlight[]} */ (highlightRange(
anchor.range range
)); ));
highlights.forEach(h => { highlights.forEach(h => {
h._annotation = anchor.annotation; h._annotation = anchor.annotation;
......
...@@ -24,6 +24,20 @@ class FakePlugin extends Delegator { ...@@ -24,6 +24,20 @@ class FakePlugin extends Delegator {
} }
FakePlugin.instance = null; FakePlugin.instance = null;
class FakeTextRange {
constructor(range) {
this.range = range;
}
toRange() {
return this.range;
}
static fromRange(range) {
return new FakeTextRange(range);
}
}
// A little helper which returns a promise that resolves after a timeout // A little helper which returns a promise that resolves after a timeout
const timeoutPromise = (millis = 0) => const timeoutPromise = (millis = 0) =>
new Promise(resolve => setTimeout(resolve, millis)); new Promise(resolve => setTimeout(resolve, millis));
...@@ -86,6 +100,9 @@ describe('Guest', () => { ...@@ -86,6 +100,9 @@ describe('Guest', () => {
$imports.$mock({ $imports.$mock({
'./adder': { Adder: FakeAdder }, './adder': { Adder: FakeAdder },
'./anchoring/html': htmlAnchoring, './anchoring/html': htmlAnchoring,
'./anchoring/text-range': {
TextRange: FakeTextRange,
},
'./highlighter': highlighter, './highlighter': highlighter,
'./range-util': rangeUtil, './range-util': rangeUtil,
'./selection-observer': { './selection-observer': {
...@@ -262,55 +279,82 @@ describe('Guest', () => { ...@@ -262,55 +279,82 @@ describe('Guest', () => {
it('scrolls to the anchor with the matching tag', () => { it('scrolls to the anchor with the matching tag', () => {
const highlight = document.createElement('span'); const highlight = document.createElement('span');
const guest = createGuest(); const guest = createGuest();
const fakeRange = sinon.stub();
guest.anchors = [ guest.anchors = [
{ annotation: { $tag: 'tag1' }, highlights: [highlight] }, {
annotation: { $tag: 'tag1' },
highlights: [highlight],
range: new FakeTextRange(fakeRange),
},
]; ];
emitGuestEvent('scrollToAnnotation', 'tag1'); emitGuestEvent('scrollToAnnotation', 'tag1');
assert.called(scrollIntoView); assert.called(scrollIntoView);
assert.calledWith(scrollIntoView, highlight); assert.calledWith(scrollIntoView, highlight);
}); });
context('when dispatching the "scrolltorange" event', () => { it('emits a "scrolltorange" DOM event', () => {
it('emits with the range', () => { const highlight = document.createElement('span');
const highlight = document.createElement('span'); const guest = createGuest();
const guest = createGuest(); const fakeRange = sinon.stub();
const fakeRange = sinon.stub(); guest.anchors = [
guest.anchors = [ {
{ annotation: { $tag: 'tag1' },
annotation: { $tag: 'tag1' }, highlights: [highlight],
highlights: [highlight], range: new FakeTextRange(fakeRange),
range: fakeRange, },
}, ];
];
return new Promise(resolve => {
guest.element.addEventListener('scrolltorange', event => {
assert.equal(event.detail, fakeRange);
resolve();
});
emitGuestEvent('scrollToAnnotation', 'tag1'); return new Promise(resolve => {
guest.element.addEventListener('scrolltorange', event => {
assert.equal(event.detail, fakeRange);
resolve();
}); });
emitGuestEvent('scrollToAnnotation', 'tag1');
}); });
});
it('allows the default scroll behaviour to be prevented', () => { it('allows the default scroll behaviour to be prevented', () => {
const highlight = document.createElement('span'); const highlight = document.createElement('span');
const guest = createGuest(); const guest = createGuest();
const fakeRange = sinon.stub(); const fakeRange = sinon.stub();
guest.anchors = [ guest.anchors = [
{ {
annotation: { $tag: 'tag1' }, annotation: { $tag: 'tag1' },
highlights: [highlight], highlights: [highlight],
range: fakeRange, range: new FakeTextRange(fakeRange),
},
];
guest.element.addEventListener('scrolltorange', event =>
event.preventDefault()
);
emitGuestEvent('scrollToAnnotation', 'tag1');
assert.notCalled(scrollIntoView);
});
it("does nothing if the anchor's range cannot be resolved", () => {
const highlight = document.createElement('span');
const guest = createGuest();
guest.anchors = [
{
annotation: { $tag: 'tag1' },
highlights: [highlight],
range: {
toRange: sinon.stub().throws(new Error('Something went wrong')),
}, },
]; },
];
const eventEmitted = sinon.stub();
guest.element.addEventListener('scrolltorange', eventEmitted);
guest.element.addEventListener('scrolltorange', event => emitGuestEvent('scrollToAnnotation', 'tag1');
event.preventDefault()
); assert.notCalled(eventEmitted);
emitGuestEvent('scrollToAnnotation', 'tag1'); assert.notCalled(scrollIntoView);
assert.notCalled(scrollIntoView);
});
}); });
}); });
...@@ -763,7 +807,7 @@ describe('Guest', () => { ...@@ -763,7 +807,7 @@ describe('Guest', () => {
assert.equal(guest.anchors.length, 1); assert.equal(guest.anchors.length, 1);
assert.strictEqual(guest.anchors[0].annotation, annotation); assert.strictEqual(guest.anchors[0].annotation, annotation);
assert.strictEqual(guest.anchors[0].target, target); assert.strictEqual(guest.anchors[0].target, target);
assert.strictEqual(guest.anchors[0].range, range); assert.strictEqual(guest.anchors[0].range.toRange(), range);
assert.strictEqual(guest.anchors[0].highlights, highlights); assert.strictEqual(guest.anchors[0].highlights, highlights);
}); });
}); });
......
...@@ -74,16 +74,12 @@ describe('anchoring', function () { ...@@ -74,16 +74,12 @@ describe('anchoring', function () {
quotes: ["This has not been a scientist's war"], quotes: ["This has not been a scientist's war"],
}, },
{ {
// Known failure with nested annotations that are anchored via quotes
// or positions. See https://github.com/hypothesis/h/pull/3313 and
// https://github.com/hypothesis/h/issues/3278
tag: 'nested quotes', tag: 'nested quotes',
quotes: [ quotes: [
"This has not been a scientist's war;" + "This has not been a scientist's war;" +
' it has been a war in which all have had a part', ' it has been a war in which all have had a part',
"scientist's war", "scientist's war",
], ],
expectFail: true,
}, },
].forEach(testCase => { ].forEach(testCase => {
it(`should highlight ${testCase.tag} when annotations are loaded`, () => { it(`should highlight ${testCase.tag} when annotations are loaded`, () => {
......
...@@ -2,6 +2,22 @@ ...@@ -2,6 +2,22 @@
* Type definitions for objects passed between the annotator and sidebar. * Type definitions for objects passed between the annotator and sidebar.
*/ */
/**
* Object representing a region of a document that an annotation
* has been anchored to.
*
* This representation of anchor ranges allows for certain document mutations
* in between anchoring an annotation and later making use of the anchored range,
* such as inserting highlights for other anchors. Compared to the initial
* anchoring of serialized selectors, resolving these ranges should be a
* cheap operation.
*
* @typedef AbstractRange
* @prop {() => Range} toRange -
* Resolve the abstract range to a concrete live `Range`. The implementation
* may or may not return the same `Range` each time.
*/
/** /**
* @typedef {import("./api").Selector} Selector * @typedef {import("./api").Selector} Selector
* @typedef {import("./api").Target} Target * @typedef {import("./api").Target} Target
...@@ -30,8 +46,10 @@ ...@@ -30,8 +46,10 @@
* *
* @typedef Anchor * @typedef Anchor
* @prop {AnnotationData} annotation * @prop {AnnotationData} annotation
* @prop {HTMLElement[]} [highlights] * @prop {HTMLElement[]} [highlights] -
* @prop {Range} [range] * The HTML elements that create the highlight for this annotation.
* @prop {AbstractRange} [range] -
* Region of the document that this annotation's selectors were resolved to.
* @prop {Target} target * @prop {Target} target
*/ */
......
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