Commit 00abbb11 authored by Eduardo Sanz García's avatar Eduardo Sanz García Committed by Eduardo

Add `scrollToAnnotations` RPC event

In order to decouple `BucketBar` from `Guest` we need to send RPC events
through the `guest-host` inter-frame communication channel.  The
messages must trigger identical behaviour on the `Guest`.

In this PR, we replace the direct use of `Guest#scrollToAnchor` by a new
RPC event, `scrollToAnnotations`.
parent b1b52048
......@@ -16,17 +16,27 @@ import { anchorBuckets } from './util/buckets';
export default class BucketBar {
/**
* @param {HTMLElement} container
* @param {Pick<import('./guest').default, 'anchors'|'scrollToAnchor'>} guest
* @param {Pick<import('./guest').default, 'anchors'>} guest
* @param {object} options
* @param {(tags: string[]) => void} options.onFocusAnnotations
* @param {(tags: string[], direction: 'down'|'up') => void} options.onScrollToClosestOffScreenAnchor
* @param {(tags: string[], toggle: boolean) => void} options.onSelectAnnotations
*/
constructor(container, guest, { onFocusAnnotations, onSelectAnnotations }) {
constructor(
container,
guest,
{
onFocusAnnotations,
onScrollToClosestOffScreenAnchor,
onSelectAnnotations,
}
) {
this._bucketsContainer = document.createElement('div');
container.appendChild(this._bucketsContainer);
this._guest = guest;
this._onFocusAnnotations = onFocusAnnotations;
this._onScrollToClosestOffScreenAnchor = onScrollToClosestOffScreenAnchor;
this._onSelectAnnotations = onSelectAnnotations;
// Immediately render the buckets for the current anchors.
......@@ -46,10 +56,12 @@ export default class BucketBar {
below={buckets.below}
buckets={buckets.buckets}
onFocusAnnotations={tags => this._onFocusAnnotations(tags)}
onScrollToClosestOffScreenAnchor={(tags, direction) =>
this._onScrollToClosestOffScreenAnchor(tags, direction)
}
onSelectAnnotations={(tags, toogle) =>
this._onSelectAnnotations(tags, toogle)
}
scrollToAnchor={anchor => this._guest.scrollToAnchor(anchor)}
/>,
this._bucketsContainer
);
......
import classnames from 'classnames';
import { findClosestOffscreenAnchor } from '../util/buckets';
/**
* @typedef {import('../../types/annotator').AnnotationData} AnnotationData
* @typedef {import('../../types/annotator').Anchor} Anchor
* @typedef {import('../util/buckets').Bucket} Bucket
*/
......@@ -13,9 +9,9 @@ import { findClosestOffscreenAnchor } from '../util/buckets';
* or selects associated annotations.
*
* @param {object} props
* @param {Bucket} props.bucket
* @param {(tags: string[]) => void} props.onFocusAnnotations
* @param {(tags: string[], toggle: boolean) => void} props.onSelectAnnotations
* @param {Bucket} props.bucket
* @param {(tags: string[]) => void} props.onFocusAnnotations
* @param {(tags: string[], toggle: boolean) => void} props.onSelectAnnotations
*/
function BucketButton({ bucket, onFocusAnnotations, onSelectAnnotations }) {
const buttonTitle = `Select nearby annotations (${bucket.anchors.length})`;
......@@ -55,18 +51,19 @@ function BucketButton({ bucket, onFocusAnnotations, onSelectAnnotations }) {
*
* @param {object} props
* @param {Bucket} props.bucket
* @param {'up'|'down'} props.direction
* @param {(a: Anchor) => void} props.scrollToAnchor - Callback invoked to
* scroll the document to a given anchor
* @param {'down'|'up'} props.direction
* @param {(tags: string[], direction: 'down'|'up') => void} props.onScrollToClosestOffScreenAnchor
*/
function NavigationBucketButton({ bucket, direction, scrollToAnchor }) {
function NavigationBucketButton({
bucket,
direction,
onScrollToClosestOffScreenAnchor,
}) {
const buttonTitle = `Go ${direction} to next annotations (${bucket.anchors.length})`;
function scrollToClosest() {
const closest = findClosestOffscreenAnchor(bucket.anchors, direction);
if (closest) {
scrollToAnchor(closest);
}
const tags = bucket.anchors.map(anchor => anchor.annotation.$tag);
onScrollToClosestOffScreenAnchor(tags, direction);
}
return (
......@@ -90,17 +87,16 @@ function NavigationBucketButton({ bucket, direction, scrollToAnchor }) {
* @param {Bucket} props.below
* @param {Bucket[]} props.buckets
* @param {(tags: string[]) => void} props.onFocusAnnotations
* @param {(tags: string[], direction: 'down'|'up') => void} props.onScrollToClosestOffScreenAnchor
* @param {(tags: string[], toggle: boolean) => void} props.onSelectAnnotations
* @param {(a: Anchor) => void} props.scrollToAnchor - Callback invoked to
* scroll the document to a given anchor
*/
export default function Buckets({
above,
below,
buckets,
onFocusAnnotations,
onScrollToClosestOffScreenAnchor,
onSelectAnnotations,
scrollToAnchor,
}) {
const showUpNavigation = above.anchors.length > 0;
const showDownNavigation = below.anchors.length > 0;
......@@ -112,7 +108,7 @@ export default function Buckets({
<NavigationBucketButton
bucket={above}
direction="up"
scrollToAnchor={scrollToAnchor}
onScrollToClosestOffScreenAnchor={onScrollToClosestOffScreenAnchor}
/>
</li>
)}
......@@ -134,7 +130,7 @@ export default function Buckets({
<NavigationBucketButton
bucket={below}
direction="down"
scrollToAnchor={scrollToAnchor}
onScrollToClosestOffScreenAnchor={onScrollToClosestOffScreenAnchor}
/>
</li>
)}
......
import { mount } from 'enzyme';
import { checkAccessibility } from '../../../test-util/accessibility';
import Buckets, { $imports } from '../Buckets';
import Buckets from '../Buckets';
describe('Buckets', () => {
let fakeAbove;
let fakeBelow;
let fakeBuckets;
let fakeBucketsUtil;
let fakeOnFocusAnnotations;
let fakeOnScrollToClosestOffScreenAnchor;
let fakeOnSelectAnnotations;
beforeEach(() => {
fakeAbove = { anchors: ['hi', 'there'], position: 150 };
fakeBelow = { anchors: ['ho', 'there'], position: 550 };
fakeAbove = {
anchors: [
{ annotation: { $tag: 'a1' }, highlights: ['hi'] },
{ annotation: { $tag: 'a2' }, highlights: ['there'] },
],
position: 150,
};
fakeBelow = {
anchors: [
{ annotation: { $tag: 'b1' }, highlights: ['ho'] },
{ annotation: { $tag: 'b2' }, highlights: ['there'] },
],
position: 550,
};
fakeBuckets = [
{
anchors: [
......@@ -25,30 +36,20 @@ describe('Buckets', () => {
},
{ anchors: ['you', 'also', 'are', 'welcome'], position: 350 },
];
fakeBucketsUtil = {
findClosestOffscreenAnchor: sinon.stub().returns({}),
};
fakeOnFocusAnnotations = sinon.stub();
fakeOnScrollToClosestOffScreenAnchor = sinon.stub();
fakeOnSelectAnnotations = sinon.stub();
$imports.$mock({
'../util/buckets': fakeBucketsUtil,
});
});
afterEach(() => {
$imports.$restore();
});
const createComponent = props =>
const createComponent = () =>
mount(
<Buckets
above={fakeAbove}
below={fakeBelow}
buckets={fakeBuckets}
onFocusAnnotations={fakeOnFocusAnnotations}
onScrollToClosestOffScreenAnchor={fakeOnScrollToClosestOffScreenAnchor}
onSelectAnnotations={fakeOnSelectAnnotations}
{...props}
/>
);
......@@ -93,37 +94,29 @@ describe('Buckets', () => {
});
it('scrolls to anchors above when up navigation button is pressed', () => {
const fakeAnchor = { highlights: ['hi'] };
fakeBucketsUtil.findClosestOffscreenAnchor.returns(fakeAnchor);
const scrollToAnchor = sinon.stub();
const wrapper = createComponent({ scrollToAnchor });
const wrapper = createComponent();
const upButton = wrapper.find('.Buckets__button--up');
upButton.simulate('click');
assert.calledWith(
fakeBucketsUtil.findClosestOffscreenAnchor,
fakeAbove.anchors,
fakeOnScrollToClosestOffScreenAnchor,
['a1', 'a2'],
'up'
);
assert.calledWith(scrollToAnchor, fakeAnchor);
});
it('scrolls to anchors below when down navigation button is pressed', () => {
const fakeAnchor = { highlights: ['hi'] };
fakeBucketsUtil.findClosestOffscreenAnchor.returns(fakeAnchor);
const scrollToAnchor = sinon.stub();
const wrapper = createComponent({ scrollToAnchor });
const wrapper = createComponent();
const downButton = wrapper.find('.Buckets__button--down');
downButton.simulate('click');
assert.calledWith(
fakeBucketsUtil.findClosestOffscreenAnchor,
fakeBelow.anchors,
fakeOnScrollToClosestOffScreenAnchor,
['b1', 'b2'],
'down'
);
assert.calledWith(scrollToAnchor, fakeAnchor);
});
});
......
......@@ -18,6 +18,7 @@ import { HypothesisInjector } from './hypothesis-injector';
import { createIntegration } from './integrations';
import * as rangeUtil from './range-util';
import { SelectionObserver } from './selection-observer';
import { findClosestOffscreenAnchor } from './util/buckets';
import { normalizeURI } from './util/url';
/**
......@@ -350,6 +351,15 @@ export default class Guest {
tags => this._focusAnnotations(tags)
);
this._hostRPC.on(
'scrollToClosestOffScreenAnchor',
/**
* @param {string[]} tags
* @param {'down'|'up'} direction
*/
(tags, direction) => this._scrollToClosestOffScreenAnchor(tags, direction)
);
this._hostRPC.on(
'selectAnnotations',
/**
......@@ -679,6 +689,22 @@ export default class Guest {
this._sidebarRPC.call('focusAnnotations', tags);
}
/**
* Scroll to the closest off screen anchor.
*
* @param {string[]} tags
* @param {'down'|'up'} direction
*/
_scrollToClosestOffScreenAnchor(tags, direction) {
const anchors = this.anchors.filter(({ annotation }) =>
tags.includes(annotation.$tag)
);
const closest = findClosestOffscreenAnchor(anchors, direction);
if (closest) {
this._integration.scrollToAnchor(closest);
}
}
/**
* Show or hide the adder toolbar when the selection changes.
*
......@@ -736,15 +762,6 @@ export default class Guest {
this._sidebarRPC.call('openSidebar');
}
/**
* Scroll the document content so that `anchor` is visible.
*
* @param {Anchor} anchor
*/
scrollToAnchor(anchor) {
return this._integration.scrollToAnchor(anchor);
}
/**
* Set whether highlights are visible in the document or not.
*
......
......@@ -117,6 +117,10 @@ export default class Sidebar {
this.bucketBar = new BucketBar(this.iframeContainer, guest, {
onFocusAnnotations: tags =>
this._guestRPC.forEach(rpc => rpc.call('focusAnnotations', tags)),
onScrollToClosestOffScreenAnchor: (tags, direction) =>
this._guestRPC.forEach(rpc =>
rpc.call('scrollToClosestOffScreenAnchor', tags, direction)
),
onSelectAnnotations: (tags, toggle) =>
this._guestRPC.forEach(rpc =>
rpc.call('selectAnnotations', tags, toggle)
......
......@@ -7,6 +7,7 @@ describe('BucketBar', () => {
let fakeBucketUtil;
let fakeGuest;
let fakeOnFocusAnnotations;
let fakeOnScrollToClosestOffScreenAnchor;
let fakeOnSelectAnnotations;
beforeEach(() => {
......@@ -25,6 +26,7 @@ describe('BucketBar', () => {
};
fakeOnFocusAnnotations = sinon.stub();
fakeOnScrollToClosestOffScreenAnchor = sinon.stub();
fakeOnSelectAnnotations = sinon.stub();
const FakeBuckets = props => {
......@@ -47,6 +49,7 @@ describe('BucketBar', () => {
const createBucketBar = () => {
const bucketBar = new BucketBar(container, fakeGuest, {
onFocusAnnotations: fakeOnFocusAnnotations,
onScrollToClosestOffScreenAnchor: fakeOnScrollToClosestOffScreenAnchor,
onSelectAnnotations: fakeOnSelectAnnotations,
});
bucketBars.push(bucketBar);
......@@ -68,22 +71,23 @@ describe('BucketBar', () => {
assert.calledWith(fakeOnFocusAnnotations, tags);
});
it('passes "onSelectAnnotations" to the Bucket component', () => {
it('passes "onScrollToClosestOffScreenAnchor" to the Bucket component', () => {
createBucketBar();
const tags = ['t1', 't2'];
const direction = 'down';
bucketProps.onSelectAnnotations(tags, true);
bucketProps.onScrollToClosestOffScreenAnchor(tags, direction);
assert.calledWith(fakeOnSelectAnnotations, tags, true);
assert.calledWith(fakeOnScrollToClosestOffScreenAnchor, tags, direction);
});
it('should scroll to anchor when Buckets component invokes callback', () => {
it('passes "onSelectAnnotations" to the Bucket component', () => {
createBucketBar();
const anchor = {};
const tags = ['t1', 't2'];
bucketProps.scrollToAnchor(anchor);
bucketProps.onSelectAnnotations(tags, true);
assert.calledWith(fakeGuest.scrollToAnchor, anchor);
assert.calledWith(fakeOnSelectAnnotations, tags, true);
});
describe('#update', () => {
......
......@@ -38,12 +38,13 @@ describe('Guest', () => {
let FakeBucketBarClient;
let fakeBucketBarClient;
let fakeCreateIntegration;
let FakePortRPC;
let fakePortRPCs;
let fakeIntegration;
let fakeFindClosestOffscreenAnchor;
let FakeHypothesisInjector;
let fakeHypothesisInjector;
let fakeIntegration;
let fakePortFinder;
let FakePortRPC;
let fakePortRPCs;
const createGuest = (config = {}) => {
const element = document.createElement('div');
......@@ -120,6 +121,14 @@ describe('Guest', () => {
};
FakeBucketBarClient = sinon.stub().returns(fakeBucketBarClient);
fakeFindClosestOffscreenAnchor = sinon.stub();
fakeHypothesisInjector = {
destroy: sinon.stub(),
injectClient: sinon.stub().resolves(),
};
FakeHypothesisInjector = sinon.stub().returns(fakeHypothesisInjector);
fakeIntegration = {
anchor: sinon.stub(),
canAnnotate: sinon.stub().returns(true),
......@@ -167,15 +176,18 @@ describe('Guest', () => {
'./bucket-bar-client': {
BucketBarClient: FakeBucketBarClient,
},
'./highlighter': highlighter,
'./hypothesis-injector': { HypothesisInjector: FakeHypothesisInjector },
'./integrations': {
createIntegration: fakeCreateIntegration,
},
'./highlighter': highlighter,
'./hypothesis-injector': { HypothesisInjector: FakeHypothesisInjector },
'./range-util': rangeUtil,
'./selection-observer': {
SelectionObserver: FakeSelectionObserver,
},
'./util/buckets': {
findClosestOffscreenAnchor: fakeFindClosestOffscreenAnchor,
},
});
});
......@@ -207,7 +219,7 @@ describe('Guest', () => {
});
describe('on "focusAnnotations" event', () => {
it('calls "Guest#._focusAnnotations"', () => {
it('focus on annotations', () => {
const guest = createGuest();
sandbox.stub(guest, '_focusAnnotations').callThrough();
const tags = ['t1', 't2'];
......@@ -220,8 +232,31 @@ describe('Guest', () => {
});
});
describe('on "scrollToClosestOffScreenAnchor" event', () => {
it('scrolls to the nearest off-screen anchor"', () => {
const guest = createGuest();
guest.anchors = [
{ annotation: { $tag: 't1' } },
{ annotation: { $tag: 't2' } },
];
const anchor = {};
fakeFindClosestOffscreenAnchor.returns(anchor);
const tags = ['t1', 't2'];
const direction = 'down';
emitHostEvent('scrollToClosestOffScreenAnchor', tags, direction);
assert.calledWith(
fakeFindClosestOffscreenAnchor,
guest.anchors,
direction
);
assert.calledWith(fakeIntegration.scrollToAnchor, anchor);
});
});
describe('on "selectAnnotations" event', () => {
it('calls "Guest#selectAnnotations"', () => {
it('selects annotations', () => {
const guest = createGuest();
sandbox.stub(guest, 'selectAnnotations').callThrough();
const tags = ['t1', 't2'];
......@@ -753,17 +788,6 @@ describe('Guest', () => {
});
});
describe('#scrollToAnchor', () => {
it("invokes the document integration's `scrollToAnchor` implementation", () => {
const guest = createGuest();
const anchor = {};
guest.scrollToAnchor(anchor);
assert.calledWith(fakeIntegration.scrollToAnchor, anchor);
});
});
describe('#getDocumentInfo', () => {
let guest;
......
......@@ -956,6 +956,24 @@ describe('Sidebar', () => {
assert.calledWith(guestRPC().call, 'focusAnnotations', tags);
});
it('calls the "scrollToClosestOffScreenAnchor" RPC method', () => {
const sidebar = createSidebar();
connectGuest(sidebar);
const { onScrollToClosestOffScreenAnchor } =
FakeBucketBar.getCall(0).args[2];
const tags = ['t1', 't2'];
const direction = 'down';
onScrollToClosestOffScreenAnchor(tags, direction);
assert.calledWith(
guestRPC().call,
'scrollToClosestOffScreenAnchor',
tags,
direction
);
});
it('calls the "selectAnnotations" RPC method', () => {
const sidebar = createSidebar();
connectGuest(sidebar);
......
......@@ -91,7 +91,12 @@ export type HostToGuestEvent =
/**
* The host informs guests that the sidebar layout has been changed.
*/
| 'sidebarLayoutChanged';
| 'sidebarLayoutChanged'
/**
* The host informs guests to scroll to the closest off-screen anchor associated with a set of annotations.
*/
| 'scrollToClosestOffScreenAnchor';
/**
* Events that the host sends to the sidebar
......
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