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 = { ...@@ -102,6 +102,50 @@ export type GuestConfig = {
contentInfoBanner?: ContentInfoConfig; 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) * `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
...@@ -433,18 +477,16 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable { ...@@ -433,18 +477,16 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable {
return; return;
} }
// Emit a custom event that the host page can respond to. This is useful, // 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 // if the content is in a hidden section of the page that needs to be
// section of the page that needs to be un-collapsed. // revealed before it can be scrolled to.
const event = new CustomEvent('scrolltorange', { const event = new ScrollToRangeEvent(range);
bubbles: true,
cancelable: true,
detail: range,
});
const defaultNotPrevented = this.element.dispatchEvent(event); const defaultNotPrevented = this.element.dispatchEvent(event);
if (defaultNotPrevented) { if (defaultNotPrevented) {
this._integration.scrollToAnchor(anchor); const ready = Promise.resolve(event.ready);
ready.then(() => this._integration.scrollToAnchor(anchor));
} }
}); });
......
...@@ -336,7 +336,7 @@ describe('Guest', () => { ...@@ -336,7 +336,7 @@ describe('Guest', () => {
}); });
describe('on "scrollToAnnotation" event', () => { describe('on "scrollToAnnotation" event', () => {
it('scrolls to the anchor with the matching tag', () => { const setupGuest = () => {
const highlight = document.createElement('span'); const highlight = document.createElement('span');
const guest = createGuest(); const guest = createGuest();
const fakeRange = sinon.stub(); const fakeRange = sinon.stub();
...@@ -347,9 +347,22 @@ describe('Guest', () => { ...@@ -347,9 +347,22 @@ describe('Guest', () => {
range: new FakeTextRange(fakeRange), range: new FakeTextRange(fakeRange),
}, },
]; ];
return guest;
};
const triggerScroll = async () => {
emitSidebarEvent('scrollToAnnotation', 'tag1'); 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.called(fakeIntegration.scrollToAnchor);
assert.calledWith(fakeIntegration.scrollToAnchor, guest.anchors[0]); assert.calledWith(fakeIntegration.scrollToAnchor, guest.anchors[0]);
}); });
...@@ -376,22 +389,38 @@ describe('Guest', () => { ...@@ -376,22 +389,38 @@ describe('Guest', () => {
}); });
}); });
it('allows the default scroll behaviour to be prevented', () => { it('defers scrolling if "scrolltorange" event\'s `waitUntil` method is called', async () => {
const highlight = document.createElement('span'); const guest = setupGuest();
const guest = createGuest(); let contentReady;
const fakeRange = sandbox.stub(); const listener = event => {
guest.anchors = [ event.waitUntil(
{ new Promise(resolve => {
annotation: { $tag: 'tag1' }, contentReady = resolve;
highlights: [highlight], })
range: new FakeTextRange(fakeRange), );
}, };
]; 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 => guest.element.addEventListener('scrolltorange', event =>
event.preventDefault() event.preventDefault()
); );
emitSidebarEvent('scrollToAnnotation', 'tag1'); emitSidebarEvent('scrollToAnnotation', 'tag1');
await delay(0);
assert.notCalled(fakeIntegration.scrollToAnchor); 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