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 { HTMLMetadata } from './html-metadata';
......@@ -7,6 +5,7 @@ import {
guessMainContentArea,
preserveScrollPosition,
} from './html-side-by-side';
import { scrollElementIntoView } from '../util/scroll';
/**
* @typedef {import('../../types/annotator').Anchor} Anchor
......@@ -161,10 +160,11 @@ export class HTMLIntegration {
/**
* @param {Anchor} anchor
*/
scrollToAnchor(anchor) {
const highlights = /** @type {Element[]} */ (anchor.highlights);
return new Promise(resolve => {
scrollIntoView(highlights[0], resolve);
});
async scrollToAnchor(anchor) {
const highlight = anchor.highlights?.[0];
if (!highlight) {
return;
}
await scrollElementIntoView(highlight);
}
}
......@@ -5,7 +5,7 @@ describe('HTMLIntegration', () => {
let fakeHTMLMetadata;
let fakeGuessMainContentArea;
let fakePreserveScrollPosition;
let fakeScrollIntoView;
let fakeScrollElementIntoView;
beforeEach(() => {
fakeHTMLAnchoring = {
......@@ -18,15 +18,17 @@ describe('HTMLIntegration', () => {
uri: sinon.stub().returns('https://example.com/'),
};
fakeScrollIntoView = sinon.stub().yields();
fakeScrollElementIntoView = sinon.stub().resolves();
fakeGuessMainContentArea = sinon.stub().returns(null);
fakePreserveScrollPosition = sinon.stub().yields();
const HTMLMetadata = sinon.stub().returns(fakeHTMLMetadata);
$imports.$mock({
'scroll-into-view': fakeScrollIntoView,
'../anchoring/html': fakeHTMLAnchoring,
'../util/scroll': {
scrollElementIntoView: fakeScrollElementIntoView,
},
'./html-metadata': { HTMLMetadata },
'./html-side-by-side': {
guessMainContentArea: fakeGuessMainContentArea,
......@@ -179,21 +181,34 @@ describe('HTMLIntegration', () => {
});
describe('#scrollToAnchor', () => {
it('scrolls to first highlight of anchor', async () => {
const highlight = document.createElement('div');
let highlight;
beforeEach(() => {
highlight = document.createElement('div');
document.body.appendChild(highlight);
});
afterEach(() => {
highlight.remove();
});
try {
it('scrolls to first highlight of anchor', async () => {
const anchor = { highlights: [highlight] };
const integration = new HTMLIntegration();
await integration.scrollToAnchor(anchor);
assert.calledOnce(fakeScrollIntoView);
assert.calledWith(fakeScrollIntoView, highlight, sinon.match.func);
} finally {
highlight.remove();
}
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.notCalled(fakeScrollElementIntoView);
});
});
......
import scrollIntoView from 'scroll-into-view';
/**
* Return a promise that resolves on the next animation frame.
*/
......@@ -69,3 +71,19 @@ export async function scrollElement(
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', () => {
let containers;
......@@ -68,4 +72,42 @@ describe('annotator/util/scroll', () => {
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