Commit dbbecf46 authored by Robert Knight's avatar Robert Knight

Create `scrollElementIntoView` wrapper around smooth-scrolling logic

Extract the logic for smoothly scrolling an element into view into the
scrolling utility module, in preparation for adding a workaround for an
issue with XHTML documents. This will also provide a place to configure
common defaults and enable us to swap out the underlying implementation
more easily in future.
parent 9326eee3
import scrollIntoView from 'scroll-into-view';
import { anchor, describe } from '../anchoring/html'; import { anchor, describe } from '../anchoring/html';
import { HTMLMetadata } from './html-metadata'; import { HTMLMetadata } from './html-metadata';
...@@ -7,6 +5,7 @@ import { ...@@ -7,6 +5,7 @@ import {
guessMainContentArea, guessMainContentArea,
preserveScrollPosition, preserveScrollPosition,
} from './html-side-by-side'; } from './html-side-by-side';
import { scrollElementIntoView } from '../util/scroll';
/** /**
* @typedef {import('../../types/annotator').Anchor} Anchor * @typedef {import('../../types/annotator').Anchor} Anchor
...@@ -161,10 +160,11 @@ export class HTMLIntegration { ...@@ -161,10 +160,11 @@ export class HTMLIntegration {
/** /**
* @param {Anchor} anchor * @param {Anchor} anchor
*/ */
scrollToAnchor(anchor) { async scrollToAnchor(anchor) {
const highlights = /** @type {Element[]} */ (anchor.highlights); const highlight = anchor.highlights?.[0];
return new Promise(resolve => { if (!highlight) {
scrollIntoView(highlights[0], resolve); return;
}); }
await scrollElementIntoView(highlight);
} }
} }
...@@ -5,7 +5,7 @@ describe('HTMLIntegration', () => { ...@@ -5,7 +5,7 @@ describe('HTMLIntegration', () => {
let fakeHTMLMetadata; let fakeHTMLMetadata;
let fakeGuessMainContentArea; let fakeGuessMainContentArea;
let fakePreserveScrollPosition; let fakePreserveScrollPosition;
let fakeScrollIntoView; let fakeScrollElementIntoView;
beforeEach(() => { beforeEach(() => {
fakeHTMLAnchoring = { fakeHTMLAnchoring = {
...@@ -18,15 +18,17 @@ describe('HTMLIntegration', () => { ...@@ -18,15 +18,17 @@ describe('HTMLIntegration', () => {
uri: sinon.stub().returns('https://example.com/'), uri: sinon.stub().returns('https://example.com/'),
}; };
fakeScrollIntoView = sinon.stub().yields(); fakeScrollElementIntoView = sinon.stub().resolves();
fakeGuessMainContentArea = sinon.stub().returns(null); fakeGuessMainContentArea = sinon.stub().returns(null);
fakePreserveScrollPosition = sinon.stub().yields(); fakePreserveScrollPosition = sinon.stub().yields();
const HTMLMetadata = sinon.stub().returns(fakeHTMLMetadata); const HTMLMetadata = sinon.stub().returns(fakeHTMLMetadata);
$imports.$mock({ $imports.$mock({
'scroll-into-view': fakeScrollIntoView,
'../anchoring/html': fakeHTMLAnchoring, '../anchoring/html': fakeHTMLAnchoring,
'../util/scroll': {
scrollElementIntoView: fakeScrollElementIntoView,
},
'./html-metadata': { HTMLMetadata }, './html-metadata': { HTMLMetadata },
'./html-side-by-side': { './html-side-by-side': {
guessMainContentArea: fakeGuessMainContentArea, guessMainContentArea: fakeGuessMainContentArea,
...@@ -179,21 +181,34 @@ describe('HTMLIntegration', () => { ...@@ -179,21 +181,34 @@ describe('HTMLIntegration', () => {
}); });
describe('#scrollToAnchor', () => { describe('#scrollToAnchor', () => {
it('scrolls to first highlight of anchor', async () => { let highlight;
const highlight = document.createElement('div');
beforeEach(() => {
highlight = document.createElement('div');
document.body.appendChild(highlight); document.body.appendChild(highlight);
});
try { afterEach(() => {
const anchor = { highlights: [highlight] }; highlight.remove();
});
const integration = new HTMLIntegration(); it('scrolls to first highlight of anchor', async () => {
await integration.scrollToAnchor(anchor); const anchor = { highlights: [highlight] };
const integration = new HTMLIntegration();
await integration.scrollToAnchor(anchor);
assert.calledOnce(fakeScrollElementIntoView);
assert.calledWith(fakeScrollElementIntoView, highlight);
});
it('does nothing if anchor has no highlights', async () => {
const anchor = {};
const integration = new HTMLIntegration();
await integration.scrollToAnchor(anchor);
assert.calledOnce(fakeScrollIntoView); assert.notCalled(fakeScrollElementIntoView);
assert.calledWith(fakeScrollIntoView, highlight, sinon.match.func);
} finally {
highlight.remove();
}
}); });
}); });
......
import scrollIntoView from 'scroll-into-view';
/** /**
* Return a promise that resolves on the next animation frame. * Return a promise that resolves on the next animation frame.
*/ */
...@@ -69,3 +71,19 @@ export async function scrollElement( ...@@ -69,3 +71,19 @@ export async function scrollElement(
element.scrollTop = interpolate(startOffset, endOffset, scrollFraction); element.scrollTop = interpolate(startOffset, endOffset, scrollFraction);
} }
} }
/**
* Smoothly scroll an element into view.
*
* @param {HTMLElement} element
* @param {object} options
* @prop {number} maxDuration
*/
export async function scrollElementIntoView(
element,
{ maxDuration = 500 } = {}
) {
await new Promise(resolve =>
scrollIntoView(element, { time: maxDuration }, resolve)
);
}
import { offsetRelativeTo, scrollElement } from '../scroll'; import {
offsetRelativeTo,
scrollElement,
scrollElementIntoView,
} from '../scroll';
describe('annotator/util/scroll', () => { describe('annotator/util/scroll', () => {
let containers; let containers;
...@@ -68,4 +72,42 @@ describe('annotator/util/scroll', () => { ...@@ -68,4 +72,42 @@ describe('annotator/util/scroll', () => {
container.remove(); container.remove();
}); });
}); });
describe('scrollElementIntoView', () => {
let container;
let target;
beforeEach(() => {
container = document.createElement('div');
container.style.height = '500px';
container.style.overflow = 'auto';
container.style.position = 'relative';
document.body.append(container);
target = document.createElement('div');
target.style.position = 'absolute';
target.style.top = '1000px';
target.style.height = '20px';
target.style.width = '100px';
container.append(target);
assert.isTrue(container.scrollHeight > container.clientHeight);
});
afterEach(() => {
target.remove();
});
// A basic test for scrolling. We assume that the underlying implementation
// has more detailed tests.
it('scrolls element into view', async () => {
await scrollElementIntoView(target, { maxDuration: 1 });
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
assert.isTrue(containerRect.top <= targetRect.top);
assert.isTrue(containerRect.bottom >= targetRect.bottom);
});
});
}); });
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