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

Focus on annotations rely on inter-frame communication

`BucketBar` was setting the focus when hovering on the left-pointed
buckets. That relied on direct access to the anchors, which it is not
always possible. Instead, now the `BucketBar` communicates with a new
RPC event, `focusAnnotations`, in the `guest-host` channel (analogous to
the `focusAnnotations` in the `sidebar-guest` channel.

I have made a small improvement: when hovering a left-pointed bucket,
not only focus the anchor's highlights but also the corresponding
annotation cards in the sidebar. The same can be done for top and
bottom-pointed buckets.
parent 433e8512
......@@ -18,13 +18,15 @@ export default class BucketBar {
* @param {HTMLElement} container
* @param {Pick<import('./guest').default, 'anchors'|'scrollToAnchor'>} guest
* @param {object} options
* @param {(tags: string[]) => void} options.onFocusAnnotations
* @param {(tags: string[], toggle: boolean) => void} options.onSelectAnnotations
*/
constructor(container, guest, { onSelectAnnotations }) {
constructor(container, guest, { onFocusAnnotations, onSelectAnnotations }) {
this._bucketsContainer = document.createElement('div');
container.appendChild(this._bucketsContainer);
this._guest = guest;
this._onFocusAnnotations = onFocusAnnotations;
this._onSelectAnnotations = onSelectAnnotations;
// Immediately render the buckets for the current anchors.
......@@ -43,6 +45,7 @@ export default class BucketBar {
above={buckets.above}
below={buckets.below}
buckets={buckets.buckets}
onFocusAnnotations={tags => this._onFocusAnnotations(tags)}
onSelectAnnotations={(tags, toogle) =>
this._onSelectAnnotations(tags, toogle)
}
......
import classnames from 'classnames';
import { setHighlightsFocused } from '../highlighter';
import { findClosestOffscreenAnchor } from '../util/buckets';
/**
......@@ -15,9 +14,10 @@ import { findClosestOffscreenAnchor } from '../util/buckets';
*
* @param {object} props
* @param {Bucket} props.bucket
* @param {(tags: string[]) => void} props.onFocusAnnotations
* @param {(tags: string[], toggle: boolean) => void} props.onSelectAnnotations
*/
function BucketButton({ bucket, onSelectAnnotations }) {
function BucketButton({ bucket, onFocusAnnotations, onSelectAnnotations }) {
const buttonTitle = `Select nearby annotations (${bucket.anchors.length})`;
function selectAnnotations(event) {
......@@ -25,10 +25,13 @@ function BucketButton({ bucket, onSelectAnnotations }) {
onSelectAnnotations(tags, event.metaKey || event.ctrlKey);
}
function setFocus(focusState) {
bucket.anchors.forEach(anchor => {
setHighlightsFocused(anchor.highlights || [], focusState);
});
function setFocus(hasFocus) {
if (hasFocus) {
const tags = bucket.anchors.map(anchor => anchor.annotation.$tag);
onFocusAnnotations(tags);
} else {
onFocusAnnotations([]);
}
}
return (
......@@ -86,6 +89,7 @@ function NavigationBucketButton({ bucket, direction, scrollToAnchor }) {
* @param {Bucket} props.above
* @param {Bucket} props.below
* @param {Bucket[]} props.buckets
* @param {(tags: string[]) => void} props.onFocusAnnotations
* @param {(tags: string[], toggle: boolean) => void} props.onSelectAnnotations
* @param {(a: Anchor) => void} props.scrollToAnchor - Callback invoked to
* scroll the document to a given anchor
......@@ -94,6 +98,7 @@ export default function Buckets({
above,
below,
buckets,
onFocusAnnotations,
onSelectAnnotations,
scrollToAnchor,
}) {
......@@ -119,6 +124,7 @@ export default function Buckets({
>
<BucketButton
bucket={bucket}
onFocusAnnotations={onFocusAnnotations}
onSelectAnnotations={onSelectAnnotations}
/>
</li>
......
......@@ -5,23 +5,12 @@ import { checkAccessibility } from '../../../test-util/accessibility';
import Buckets, { $imports } from '../Buckets';
describe('Buckets', () => {
let fakeBucketsUtil;
let fakeHighlighter;
let fakeAbove;
let fakeBelow;
let fakeBuckets;
const createComponent = props =>
mount(
<Buckets
above={fakeAbove}
below={fakeBelow}
buckets={fakeBuckets}
onSelectAnnotations={() => null}
{...props}
/>
);
let fakeBucketsUtil;
let fakeOnFocusAnnotations;
let fakeOnSelectAnnotations;
beforeEach(() => {
fakeAbove = { anchors: ['hi', 'there'], position: 150 };
......@@ -39,12 +28,10 @@ describe('Buckets', () => {
fakeBucketsUtil = {
findClosestOffscreenAnchor: sinon.stub().returns({}),
};
fakeHighlighter = {
setHighlightsFocused: sinon.stub(),
};
fakeOnFocusAnnotations = sinon.stub();
fakeOnSelectAnnotations = sinon.stub();
$imports.$mock({
'../highlighter': fakeHighlighter,
'../util/buckets': fakeBucketsUtil,
});
});
......@@ -53,6 +40,18 @@ describe('Buckets', () => {
$imports.$restore();
});
const createComponent = props =>
mount(
<Buckets
above={fakeAbove}
below={fakeBelow}
buckets={fakeBuckets}
onFocusAnnotations={fakeOnFocusAnnotations}
onSelectAnnotations={fakeOnSelectAnnotations}
{...props}
/>
);
describe('up and down navigation', () => {
it('renders an up navigation button if there are above-screen anchors', () => {
const wrapper = createComponent();
......@@ -140,17 +139,8 @@ describe('Buckets', () => {
wrapper.find('.Buckets__button--left').first().simulate('mousemove');
assert.calledTwice(fakeHighlighter.setHighlightsFocused);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[0].highlights,
true
);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[1].highlights,
true
);
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, ['t1', 't2']);
});
it('removes focus on associated anchors when element is blurred', () => {
......@@ -158,17 +148,8 @@ describe('Buckets', () => {
wrapper.find('.Buckets__button--left').first().simulate('blur');
assert.calledTwice(fakeHighlighter.setHighlightsFocused);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[0].highlights,
false
);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[1].highlights,
false
);
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, []);
});
it('removes focus on associated anchors when mouse leaves the element', () => {
......@@ -176,19 +157,12 @@ describe('Buckets', () => {
wrapper.find('.Buckets__button--left').first().simulate('mouseout');
assert.calledTwice(fakeHighlighter.setHighlightsFocused);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[0].highlights,
false
);
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, []);
});
it('selects associated annotations when bucket button pressed', () => {
const fakeOnSelectAnnotations = sinon.stub();
const wrapper = createComponent({
onSelectAnnotations: fakeOnSelectAnnotations,
});
const wrapper = createComponent();
wrapper
.find('.Buckets__button--left')
......@@ -205,10 +179,7 @@ describe('Buckets', () => {
});
it('toggles annotation selection if metakey pressed', () => {
const fakeOnSelectAnnotations = sinon.stub();
const wrapper = createComponent({
onSelectAnnotations: fakeOnSelectAnnotations,
});
const wrapper = createComponent();
wrapper
.find('.Buckets__button--left')
......
......@@ -269,13 +269,13 @@ export default class Guest {
this._listeners.add(this.element, 'mouseover', ({ target }) => {
const tags = annotationsAt(/** @type {Element} */ (target));
if (tags.length && this._highlightsVisible) {
this._focusAnnotations(tags);
this._sidebarRPC.call('focusAnnotations', tags);
}
});
this._listeners.add(this.element, 'mouseout', () => {
if (this._highlightsVisible) {
this._focusAnnotations([]);
this._sidebarRPC.call('focusAnnotations', []);
}
});
......@@ -344,6 +344,12 @@ export default class Guest {
}
);
this._hostRPC.on(
'focusAnnotations',
/** @param {string[]} tags */
tags => this._focusAnnotations(tags)
);
this._hostRPC.on(
'selectAnnotations',
/**
......@@ -375,17 +381,7 @@ export default class Guest {
this._sidebarRPC.on(
'focusAnnotations',
/** @param {string[]} tags */
(tags = []) => {
this._focusedAnnotations.clear();
tags.forEach(tag => this._focusedAnnotations.add(tag));
for (let anchor of this.anchors) {
if (anchor.highlights) {
const toggle = tags.includes(anchor.annotation.$tag);
setHighlightsFocused(anchor.highlights, toggle);
}
}
}
tags => this._focusAnnotations(tags)
);
this._sidebarRPC.on(
......@@ -670,6 +666,16 @@ export default class Guest {
* @param {string[]} tags
*/
_focusAnnotations(tags) {
this._focusedAnnotations.clear();
tags.forEach(tag => this._focusedAnnotations.add(tag));
for (let anchor of this.anchors) {
if (anchor.highlights) {
const toggle = tags.includes(anchor.annotation.$tag);
setHighlightsFocused(anchor.highlights, toggle);
}
}
this._sidebarRPC.call('focusAnnotations', tags);
}
......
......@@ -115,6 +115,8 @@ export default class Sidebar {
this.iframeContainer.classList.add('annotator-frame--theme-clean');
} else {
this.bucketBar = new BucketBar(this.iframeContainer, guest, {
onFocusAnnotations: tags =>
this._guestRPC.forEach(rpc => rpc.call('focusAnnotations', tags)),
onSelectAnnotations: (tags, toggle) =>
this._guestRPC.forEach(rpc =>
rpc.call('selectAnnotations', tags, toggle)
......
......@@ -6,6 +6,7 @@ describe('BucketBar', () => {
let container;
let fakeBucketUtil;
let fakeGuest;
let fakeOnFocusAnnotations;
let fakeOnSelectAnnotations;
beforeEach(() => {
......@@ -23,6 +24,7 @@ describe('BucketBar', () => {
selectAnnotations: sinon.stub(),
};
fakeOnFocusAnnotations = sinon.stub();
fakeOnSelectAnnotations = sinon.stub();
const FakeBuckets = props => {
......@@ -44,6 +46,7 @@ describe('BucketBar', () => {
const createBucketBar = () => {
const bucketBar = new BucketBar(container, fakeGuest, {
onFocusAnnotations: fakeOnFocusAnnotations,
onSelectAnnotations: fakeOnSelectAnnotations,
});
bucketBars.push(bucketBar);
......@@ -56,6 +59,15 @@ describe('BucketBar', () => {
assert.ok(bucketBar._bucketsContainer.querySelector('.FakeBuckets'));
});
it('passes "onFocusAnnotations" to the Bucket component', () => {
createBucketBar();
const tags = ['t1', 't2'];
bucketProps.onFocusAnnotations(tags);
assert.calledWith(fakeOnFocusAnnotations, tags);
});
it('passes "onSelectAnnotations" to the Bucket component', () => {
createBucketBar();
const tags = ['t1', 't2'];
......
......@@ -203,6 +203,20 @@ describe('Guest', () => {
});
});
describe('on "focusAnnotations" event', () => {
it('calls "Guest#._focusAnnotations"', () => {
const guest = createGuest();
sandbox.stub(guest, '_focusAnnotations').callThrough();
const tags = ['t1', 't2'];
sidebarRPC().call.resetHistory();
emitHostEvent('focusAnnotations', tags);
assert.calledWith(guest._focusAnnotations, tags);
assert.calledWith(sidebarRPC().call, 'focusAnnotations', tags);
});
});
describe('on "selectAnnotations" event', () => {
it('calls "Guest#selectAnnotations"', () => {
const guest = createGuest();
......
......@@ -945,6 +945,17 @@ describe('Sidebar', () => {
assert.isNull(sidebar.bucketBar);
});
it('calls the "focusAnnotations" RPC method', () => {
const sidebar = createSidebar();
connectGuest(sidebar);
const { onFocusAnnotations } = FakeBucketBar.getCall(0).args[2];
const tags = ['t1', 't2'];
onFocusAnnotations(tags);
assert.calledWith(guestRPC().call, 'focusAnnotations', tags);
});
it('calls the "selectAnnotations" RPC method', () => {
const sidebar = createSidebar();
connectGuest(sidebar);
......
......@@ -78,6 +78,11 @@ export type HostToGuestEvent =
*/
| 'clearSelectionExceptIn'
/**
* The host informs guests to focus on a set of annotations
*/
| 'focusAnnotations'
/**
* The host informs guests to select/toggle on a set of annotations
*/
......
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