Commit fc3c29a4 authored by Robert Knight's avatar Robert Knight

Enable host page to respond async to `scrolltorange` event

Add a `waitUntil` method to the `scrolltorange` event which allows the host page
to signal to the client that it should wait for async work to complete before
the client attempts to scroll to a highlight. This is useful in web applications
which need to perform an async re-render of the UI before the content can be
scrolled to.

The initial use case is enabling Via's video player app to clear transcript
search filters and re-render the Preact UI before scrolling to a highlight [1]

[1] See https://github.com/hypothesis/via/pull/932.
parent da264e76
......@@ -102,6 +102,50 @@ export type GuestConfig = {
contentInfoBanner?: ContentInfoConfig;
};
/**
* Event dispatched by the client when it is about to scroll a highlight into
* view.
*
* The host page can listen for this event in order to reveal the content if
* not already visible. If the content will be revealed asynchronously,
* {@link waitUntil} can be used to notify the client when it is ready.
*
* For more flexibility the host page can completely take over scrolling to the
* range by calling {@link Event.preventDefault} on the event.
*/
export class ScrollToRangeEvent extends CustomEvent<Range> {
private _ready: Promise<void> | null;
/**
* @param range - The DOM range that Hypothesis will scroll into view.
*/
constructor(range: Range) {
super('scrolltorange', {
bubbles: true,
cancelable: true,
detail: range,
});
this._ready = null;
}
/**
* If scrolling was deferred using {@link waitUntil}, returns the promise
* that must resolve before the highlight is scrolled to.
*/
get ready(): Promise<void> | null {
return this._ready;
}
/**
* Provide Hypothesis with a promise that resolves when the content
* associated with the event's range is ready to be scrolled into view.
*/
waitUntil(ready: Promise<void>) {
this._ready = ready;
}
}
/**
* `Guest` is the central class of the annotator that handles anchoring (locating)
* annotations in the document when they are fetched by the sidebar, rendering
......@@ -433,18 +477,16 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable {
return;
}
// Emit a custom event that the host page can respond to. This is useful,
// for example, if the highlighted content is contained in a collapsible
// section of the page that needs to be un-collapsed.
const event = new CustomEvent('scrolltorange', {
bubbles: true,
cancelable: true,
detail: range,
});
// Emit a custom event that the host page can respond to. This is useful
// if the content is in a hidden section of the page that needs to be
// revealed before it can be scrolled to.
const event = new ScrollToRangeEvent(range);
const defaultNotPrevented = this.element.dispatchEvent(event);
if (defaultNotPrevented) {
this._integration.scrollToAnchor(anchor);
const ready = Promise.resolve(event.ready);
ready.then(() => this._integration.scrollToAnchor(anchor));
}
});
......
......@@ -336,7 +336,7 @@ describe('Guest', () => {
});
describe('on "scrollToAnnotation" event', () => {
it('scrolls to the anchor with the matching tag', () => {
const setupGuest = () => {
const highlight = document.createElement('span');
const guest = createGuest();
const fakeRange = sinon.stub();
......@@ -347,9 +347,22 @@ describe('Guest', () => {
range: new FakeTextRange(fakeRange),
},
];
return guest;
};
const triggerScroll = async () => {
emitSidebarEvent('scrollToAnnotation', 'tag1');
// The call to `scrollToAnchor` on the integration happens
// asynchronously. Wait for the minimum delay before this happens.
await delay(0);
};
it('scrolls to the anchor with the matching tag', async () => {
const guest = setupGuest();
await triggerScroll();
assert.called(fakeIntegration.scrollToAnchor);
assert.calledWith(fakeIntegration.scrollToAnchor, guest.anchors[0]);
});
......@@ -376,22 +389,38 @@ describe('Guest', () => {
});
});
it('allows the default scroll behaviour to be prevented', () => {
const highlight = document.createElement('span');
const guest = createGuest();
const fakeRange = sandbox.stub();
guest.anchors = [
{
annotation: { $tag: 'tag1' },
highlights: [highlight],
range: new FakeTextRange(fakeRange),
},
];
it('defers scrolling if "scrolltorange" event\'s `waitUntil` method is called', async () => {
const guest = setupGuest();
let contentReady;
const listener = event => {
event.waitUntil(
new Promise(resolve => {
contentReady = resolve;
})
);
};
guest.element.addEventListener('scrolltorange', listener);
// Trigger scroll. `scrollToAnchor` shouldn't be called immediately
// because `ScrollToRangeEvent.waitUntil` was used to defer scrolling.
await triggerScroll();
assert.notCalled(fakeIntegration.scrollToAnchor);
// Resolve promise passed to `ScrollToRangeEvent.waitUntil`.
contentReady();
await delay(0);
assert.calledWith(fakeIntegration.scrollToAnchor, guest.anchors[0]);
});
it('allows the default scroll behaviour to be prevented', async () => {
const guest = setupGuest();
guest.element.addEventListener('scrolltorange', event =>
event.preventDefault()
);
emitSidebarEvent('scrollToAnnotation', 'tag1');
await delay(0);
assert.notCalled(fakeIntegration.scrollToAnchor);
});
......
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