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

Use an RPC call instead of direct call to `Guest#selectAnnotations`

We want the `BucketBar` to be able to communicate with `Guest`s that are
in other iframes, like in the Ebook scenario. Currently, the `BucketBar`
has an instance of the `Guest`, associated with `host` frame, and
assumes to be the main `Guest`, for communication purposes.

I have replaced the reliance of the direct invocation of
`Guest#selectAnnotations` by a new `selectAnnotations` RPC event in the
`guest-host` communication channel. This has the advantage of making no
assumptions about which iframe contains the annotations.

I have made some minor modification in the types and functionality of
`range-util`.
parent 53f06183
...@@ -62,9 +62,9 @@ function nearestPositionedAncestor(el) { ...@@ -62,9 +62,9 @@ function nearestPositionedAncestor(el) {
/** /**
* @typedef AdderOptions * @typedef AdderOptions
* @prop {() => any} onAnnotate - Callback invoked when "Annotate" button is clicked * @prop {() => void} onAnnotate - Callback invoked when "Annotate" button is clicked
* @prop {() => any} onHighlight - Callback invoked when "Highlight" button is clicked * @prop {() => void} onHighlight - Callback invoked when "Highlight" button is clicked
* @prop {(annotations: object[]) => any} onShowAnnotations - * @prop {(tags: string[]) => void} onShowAnnotations -
* Callback invoked when "Show" button is clicked * Callback invoked when "Show" button is clicked
* *
* @typedef {import('../types/annotator').Destroyable} Destroyable * @typedef {import('../types/annotator').Destroyable} Destroyable
...@@ -126,11 +126,11 @@ export class Adder { ...@@ -126,11 +126,11 @@ export class Adder {
this._onShowAnnotations = options.onShowAnnotations; this._onShowAnnotations = options.onShowAnnotations;
/** /**
* Annotation objects associated with the current selection. If non-empty, * Annotation tags associated with the current selection. If non-empty,
* a "Show" button appears in the toolbar. Clicking the button calls the * a "Show" button appears in the toolbar. Clicking the button calls the
* `onShowAnnotations` callback with the current value of `annotationsForSelection`. * `onShowAnnotations` callback with the current value of `annotationsForSelection`.
* *
* @type {object[]} * @type {string[]}
*/ */
this.annotationsForSelection = []; this.annotationsForSelection = [];
......
...@@ -16,13 +16,16 @@ import { anchorBuckets } from './util/buckets'; ...@@ -16,13 +16,16 @@ import { anchorBuckets } from './util/buckets';
export default class BucketBar { export default class BucketBar {
/** /**
* @param {HTMLElement} container * @param {HTMLElement} container
* @param {Pick<import('./guest').default, 'anchors'|'scrollToAnchor'|'selectAnnotations'>} guest * @param {Pick<import('./guest').default, 'anchors'|'scrollToAnchor'>} guest
* @param {object} options
* @param {(tags: string[], toggle: boolean) => void} options.onSelectAnnotations
*/ */
constructor(container, guest) { constructor(container, guest, { onSelectAnnotations }) {
this._bucketsContainer = document.createElement('div'); this._bucketsContainer = document.createElement('div');
container.appendChild(this._bucketsContainer); container.appendChild(this._bucketsContainer);
this._guest = guest; this._guest = guest;
this._onSelectAnnotations = onSelectAnnotations;
// Immediately render the buckets for the current anchors. // Immediately render the buckets for the current anchors.
this.update(); this.update();
...@@ -40,8 +43,8 @@ export default class BucketBar { ...@@ -40,8 +43,8 @@ export default class BucketBar {
above={buckets.above} above={buckets.above}
below={buckets.below} below={buckets.below}
buckets={buckets.buckets} buckets={buckets.buckets}
onSelectAnnotations={(annotations, toggle) => onSelectAnnotations={(tags, toogle) =>
this._guest.selectAnnotations(annotations, toggle) this._onSelectAnnotations(tags, toogle)
} }
scrollToAnchor={anchor => this._guest.scrollToAnchor(anchor)} scrollToAnchor={anchor => this._guest.scrollToAnchor(anchor)}
/>, />,
......
...@@ -15,14 +15,14 @@ import { findClosestOffscreenAnchor } from '../util/buckets'; ...@@ -15,14 +15,14 @@ import { findClosestOffscreenAnchor } from '../util/buckets';
* *
* @param {object} props * @param {object} props
* @param {Bucket} props.bucket * @param {Bucket} props.bucket
* @param {(annotations: AnnotationData[], toggle: boolean) => any} props.onSelectAnnotations * @param {(tags: string[], toggle: boolean) => void} props.onSelectAnnotations
*/ */
function BucketButton({ bucket, onSelectAnnotations }) { function BucketButton({ bucket, onSelectAnnotations }) {
const annotations = bucket.anchors.map(anchor => anchor.annotation);
const buttonTitle = `Select nearby annotations (${bucket.anchors.length})`; const buttonTitle = `Select nearby annotations (${bucket.anchors.length})`;
function selectAnnotations(event) { function selectAnnotations(event) {
onSelectAnnotations(annotations, event.metaKey || event.ctrlKey); const tags = bucket.anchors.map(anchor => anchor.annotation.$tag);
onSelectAnnotations(tags, event.metaKey || event.ctrlKey);
} }
function setFocus(focusState) { function setFocus(focusState) {
...@@ -86,7 +86,7 @@ function NavigationBucketButton({ bucket, direction, scrollToAnchor }) { ...@@ -86,7 +86,7 @@ function NavigationBucketButton({ bucket, direction, scrollToAnchor }) {
* @param {Bucket} props.above * @param {Bucket} props.above
* @param {Bucket} props.below * @param {Bucket} props.below
* @param {Bucket[]} props.buckets * @param {Bucket[]} props.buckets
* @param {(annotations: AnnotationData[], toggle: boolean) => any} props.onSelectAnnotations * @param {(tags: string[], toggle: boolean) => void} props.onSelectAnnotations
* @param {(a: Anchor) => void} props.scrollToAnchor - Callback invoked to * @param {(a: Anchor) => void} props.scrollToAnchor - Callback invoked to
* scroll the document to a given anchor * scroll the document to a given anchor
*/ */
......
...@@ -198,8 +198,8 @@ describe('Buckets', () => { ...@@ -198,8 +198,8 @@ describe('Buckets', () => {
assert.calledOnce(fakeOnSelectAnnotations); assert.calledOnce(fakeOnSelectAnnotations);
const call = fakeOnSelectAnnotations.getCall(0); const call = fakeOnSelectAnnotations.getCall(0);
assert.deepEqual(call.args[0], [ assert.deepEqual(call.args[0], [
fakeBuckets[0].anchors[0].annotation, fakeBuckets[0].anchors[0].annotation.$tag,
fakeBuckets[0].anchors[1].annotation, fakeBuckets[0].anchors[1].annotation.$tag,
]); ]);
assert.equal(call.args[1], false); assert.equal(call.args[1], false);
}); });
......
...@@ -40,34 +40,33 @@ import { normalizeURI } from './util/url'; ...@@ -40,34 +40,33 @@ import { normalizeURI } from './util/url';
*/ */
/** /**
* Return all the annotations associated with the selected text. * Return all the annotations tags associated with the selected text.
* *
* @return {AnnotationData[]} * @return {string[]}
*/ */
function annotationsForSelection() { function annotationsForSelection() {
const selection = /** @type {Selection} */ (window.getSelection()); const selection = /** @type {Selection} */ (window.getSelection());
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const items = rangeUtil.itemsForRange( const tags = rangeUtil.itemsForRange(
range, range,
node => /** @type {AnnotationHighlight} */ (node)._annotation?.$tag
// nb. Only non-nullish items are returned by `itemsForRange`.
node => /** @type {AnnotationHighlight} */ (node)._annotation
); );
return /** @type {AnnotationData[]} */ (items); return tags;
} }
/** /**
* Return the annotations associated with any highlights that contain a given * Return the annotation tags associated with any highlights that contain a given
* DOM node. * DOM node.
* *
* @param {Node} node * @param {Node} node
* @return {AnnotationData[]} * @return {string[]}
*/ */
function annotationsAt(node) { function annotationsAt(node) {
const items = getHighlightsContainingNode(node) const items = getHighlightsContainingNode(node)
.map(h => /** @type {AnnotationHighlight} */ (h)._annotation) .map(h => /** @type {AnnotationHighlight} */ (h)._annotation)
.filter(ann => ann !== undefined); .filter(ann => ann !== undefined)
return /** @type {AnnotationData[]} */ (items); .map(ann => ann?.$tag);
return /** @type {string[]} */ (items);
} }
/** /**
...@@ -137,15 +136,9 @@ export default class Guest { ...@@ -137,15 +136,9 @@ export default class Guest {
this.selectedRanges = []; this.selectedRanges = [];
this._adder = new Adder(this.element, { this._adder = new Adder(this.element, {
onAnnotate: async () => { onAnnotate: () => this.createAnnotation(),
await this.createAnnotation(); onHighlight: () => this.createAnnotation({ highlight: true }),
}, onShowAnnotations: tags => this.selectAnnotations(tags),
onHighlight: async () => {
await this.createAnnotation({ highlight: true });
},
onShowAnnotations: anns => {
this.selectAnnotations(anns);
},
}); });
this._selectionObserver = new SelectionObserver(range => { this._selectionObserver = new SelectionObserver(range => {
...@@ -256,10 +249,10 @@ export default class Guest { ...@@ -256,10 +249,10 @@ export default class Guest {
this._listeners.add(this.element, 'mouseup', event => { this._listeners.add(this.element, 'mouseup', event => {
const { target, metaKey, ctrlKey } = /** @type {MouseEvent} */ (event); const { target, metaKey, ctrlKey } = /** @type {MouseEvent} */ (event);
const annotations = annotationsAt(/** @type {Element} */ (target)); const tags = annotationsAt(/** @type {Element} */ (target));
if (annotations.length && this._highlightsVisible) { if (tags.length && this._highlightsVisible) {
const toggle = metaKey || ctrlKey; const toggle = metaKey || ctrlKey;
this.selectAnnotations(annotations, toggle); this.selectAnnotations(tags, toggle);
} }
}); });
...@@ -274,9 +267,9 @@ export default class Guest { ...@@ -274,9 +267,9 @@ export default class Guest {
}); });
this._listeners.add(this.element, 'mouseover', ({ target }) => { this._listeners.add(this.element, 'mouseover', ({ target }) => {
const annotations = annotationsAt(/** @type {Element} */ (target)); const tags = annotationsAt(/** @type {Element} */ (target));
if (annotations.length && this._highlightsVisible) { if (tags.length && this._highlightsVisible) {
this._focusAnnotations(annotations); this._focusAnnotations(tags);
} }
}); });
...@@ -351,6 +344,15 @@ export default class Guest { ...@@ -351,6 +344,15 @@ export default class Guest {
} }
); );
this._hostRPC.on(
'selectAnnotations',
/**
* @param {string[]} tags
* @param {boolean} toggle
*/
(tags, toggle) => this.selectAnnotations(tags, toggle)
);
this._hostRPC.on( this._hostRPC.on(
'sidebarLayoutChanged', 'sidebarLayoutChanged',
/** @param {SidebarLayout} sidebarLayout */ /** @param {SidebarLayout} sidebarLayout */
...@@ -666,10 +668,9 @@ export default class Guest { ...@@ -666,10 +668,9 @@ export default class Guest {
* Indicate in the sidebar that certain annotations are focused (ie. the * Indicate in the sidebar that certain annotations are focused (ie. the
* associated document region(s) is hovered). * associated document region(s) is hovered).
* *
* @param {AnnotationData[]} annotations * @param {string[]} tags
*/ */
_focusAnnotations(annotations) { _focusAnnotations(tags) {
const tags = annotations.map(a => a.$tag);
this._sidebarRPC.call('focusAnnotations', tags); this._sidebarRPC.call('focusAnnotations', tags);
} }
...@@ -717,12 +718,11 @@ export default class Guest { ...@@ -717,12 +718,11 @@ export default class Guest {
* This sets up a filter in the sidebar to show only the selected annotations * This sets up a filter in the sidebar to show only the selected annotations
* and opens the sidebar. * and opens the sidebar.
* *
* @param {AnnotationData[]} annotations * @param {string[]} tags
* @param {boolean} [toggle] - Toggle whether the annotations are selected * @param {boolean} [toggle] - Toggle whether the annotations are selected
* instead of showing them regardless of whether they are currently selected. * instead of showing them regardless of whether they are currently selected.
*/ */
selectAnnotations(annotations, toggle = false) { selectAnnotations(tags, toggle = false) {
const tags = annotations.map(a => a.$tag);
if (toggle) { if (toggle) {
this._sidebarRPC.call('toggleAnnotationSelection', tags); this._sidebarRPC.call('toggleAnnotationSelection', tags);
} else { } else {
......
...@@ -134,10 +134,12 @@ export function selectionFocusRect(selection) { ...@@ -134,10 +134,12 @@ export function selectionFocusRect(selection) {
* @template T * @template T
* @param {Range} range * @param {Range} range
* @param {(n: Node) => T} itemForNode - Callback returning the item for a given node * @param {(n: Node) => T} itemForNode - Callback returning the item for a given node
* @return {T[]} items * @return {NonNullable<T>[]} items
*/ */
export function itemsForRange(range, itemForNode) { export function itemsForRange(range, itemForNode) {
/** @type {Set<Node>} */
const checkedNodes = new Set(); const checkedNodes = new Set();
/** @type {Set<NonNullable<T>>} */
const items = new Set(); const items = new Set();
forEachNodeInRange(range, node => { forEachNodeInRange(range, node => {
...@@ -149,8 +151,10 @@ export function itemsForRange(range, itemForNode) { ...@@ -149,8 +151,10 @@ export function itemsForRange(range, itemForNode) {
} }
checkedNodes.add(current); checkedNodes.add(current);
const item = itemForNode(current); const item = /** @type {NonNullable<T>|null|undefined} */ (
if (item) { itemForNode(current)
);
if (item !== null && item !== undefined) {
items.add(item); items.add(item);
} }
......
...@@ -114,7 +114,12 @@ export default class Sidebar { ...@@ -114,7 +114,12 @@ export default class Sidebar {
if (config.theme === 'clean') { if (config.theme === 'clean') {
this.iframeContainer.classList.add('annotator-frame--theme-clean'); this.iframeContainer.classList.add('annotator-frame--theme-clean');
} else { } else {
this.bucketBar = new BucketBar(this.iframeContainer, guest); this.bucketBar = new BucketBar(this.iframeContainer, guest, {
onSelectAnnotations: (tags, toggle) =>
this._guestRPC.forEach(rpc =>
rpc.call('selectAnnotations', tags, toggle)
),
});
} }
this.iframeContainer.appendChild(this.iframe); this.iframeContainer.appendChild(this.iframe);
......
...@@ -6,6 +6,7 @@ describe('BucketBar', () => { ...@@ -6,6 +6,7 @@ describe('BucketBar', () => {
let container; let container;
let fakeBucketUtil; let fakeBucketUtil;
let fakeGuest; let fakeGuest;
let fakeOnSelectAnnotations;
beforeEach(() => { beforeEach(() => {
bucketBars = []; bucketBars = [];
...@@ -22,6 +23,8 @@ describe('BucketBar', () => { ...@@ -22,6 +23,8 @@ describe('BucketBar', () => {
selectAnnotations: sinon.stub(), selectAnnotations: sinon.stub(),
}; };
fakeOnSelectAnnotations = sinon.stub();
const FakeBuckets = props => { const FakeBuckets = props => {
bucketProps = props; bucketProps = props;
return <div className="FakeBuckets" />; return <div className="FakeBuckets" />;
...@@ -40,7 +43,9 @@ describe('BucketBar', () => { ...@@ -40,7 +43,9 @@ describe('BucketBar', () => {
}); });
const createBucketBar = () => { const createBucketBar = () => {
const bucketBar = new BucketBar(container, fakeGuest); const bucketBar = new BucketBar(container, fakeGuest, {
onSelectAnnotations: fakeOnSelectAnnotations,
});
bucketBars.push(bucketBar); bucketBars.push(bucketBar);
return bucketBar; return bucketBar;
}; };
...@@ -51,13 +56,13 @@ describe('BucketBar', () => { ...@@ -51,13 +56,13 @@ describe('BucketBar', () => {
assert.ok(bucketBar._bucketsContainer.querySelector('.FakeBuckets')); assert.ok(bucketBar._bucketsContainer.querySelector('.FakeBuckets'));
}); });
it('should select annotations when Buckets component invokes callback', () => { it('passes "onSelectAnnotations" to the Bucket component', () => {
createBucketBar(); createBucketBar();
const fakeAnnotations = ['hi', 'there']; const tags = ['t1', 't2'];
bucketProps.onSelectAnnotations(fakeAnnotations, true); bucketProps.onSelectAnnotations(tags, true);
assert.calledWith(fakeGuest.selectAnnotations, fakeAnnotations, true); assert.calledWith(fakeOnSelectAnnotations, tags, true);
}); });
it('should scroll to anchor when Buckets component invokes callback', () => { it('should scroll to anchor when Buckets component invokes callback', () => {
......
...@@ -202,6 +202,21 @@ describe('Guest', () => { ...@@ -202,6 +202,21 @@ describe('Guest', () => {
assert.notCalled(fakeIntegration.fitSideBySide); assert.notCalled(fakeIntegration.fitSideBySide);
}); });
}); });
describe('on "selectAnnotations" event', () => {
it('calls "Guest#selectAnnotations"', () => {
const guest = createGuest();
sandbox.stub(guest, 'selectAnnotations').callThrough();
const tags = ['t1', 't2'];
const toggle = true;
sidebarRPC().call.resetHistory();
emitHostEvent('selectAnnotations', tags, toggle);
assert.calledWith(guest.selectAnnotations, tags, toggle);
assert.calledWith(sidebarRPC().call, 'openSidebar');
});
});
}); });
describe('events from sidebar frame', () => { describe('events from sidebar frame', () => {
...@@ -590,14 +605,14 @@ describe('Guest', () => { ...@@ -590,14 +605,14 @@ describe('Guest', () => {
it('sets the annotations associated with the selection', () => { it('sets the annotations associated with the selection', () => {
createGuest(); createGuest();
const ann = {}; const ann = { $tag: 't1' };
container._annotation = ann; container._annotation = ann;
rangeUtil.itemsForRange.callsFake((range, callback) => [ rangeUtil.itemsForRange.callsFake((range, callback) => [
callback(range.startContainer), callback(range.startContainer),
]); ]);
simulateSelectionWithText(); simulateSelectionWithText();
assert.deepEqual(FakeAdder.instance.annotationsForSelection, [ann]); assert.deepEqual(FakeAdder.instance.annotationsForSelection, ['t1']);
}); });
it('hides the adder if the selection does not contain text', () => { it('hides the adder if the selection does not contain text', () => {
...@@ -718,34 +733,32 @@ describe('Guest', () => { ...@@ -718,34 +733,32 @@ describe('Guest', () => {
it('shows annotations if "Show" is clicked', () => { it('shows annotations if "Show" is clicked', () => {
createGuest(); createGuest();
const tags = ['t1', 't2'];
FakeAdder.instance.options.onShowAnnotations([{ $tag: 'ann1' }]); FakeAdder.instance.options.onShowAnnotations(tags);
assert.calledWith(sidebarRPC().call, 'openSidebar'); assert.calledWith(sidebarRPC().call, 'openSidebar');
assert.calledWith(sidebarRPC().call, 'showAnnotations', ['ann1']); assert.calledWith(sidebarRPC().call, 'showAnnotations', tags);
}); });
}); });
describe('#selectAnnotations', () => { describe('#selectAnnotations', () => {
it('selects the specified annotations in the sidebar', () => { it('selects the specified annotations in the sidebar', () => {
const guest = createGuest(); const guest = createGuest();
const annotations = [{ $tag: 'ann1' }, { $tag: 'ann2' }]; const tags = ['t1', 't2'];
guest.selectAnnotations(annotations); guest.selectAnnotations(tags);
assert.calledWith(sidebarRPC().call, 'showAnnotations', ['ann1', 'ann2']); assert.calledWith(sidebarRPC().call, 'showAnnotations', tags);
}); });
it('toggles the annotations if `toggle` is true', () => { it('toggles the annotations if `toggle` is true', () => {
const guest = createGuest(); const guest = createGuest();
const annotations = [{ $tag: 'ann1' }, { $tag: 'ann2' }]; const tags = ['t1', 't2'];
guest.selectAnnotations(annotations, true /* toggle */); guest.selectAnnotations(tags, true /* toggle */);
assert.calledWith(sidebarRPC().call, 'toggleAnnotationSelection', [ assert.calledWith(sidebarRPC().call, 'toggleAnnotationSelection', tags);
'ann1',
'ann2',
]);
}); });
it('opens the sidebar', () => { it('opens the sidebar', () => {
......
...@@ -944,5 +944,17 @@ describe('Sidebar', () => { ...@@ -944,5 +944,17 @@ describe('Sidebar', () => {
}); });
assert.isNull(sidebar.bucketBar); assert.isNull(sidebar.bucketBar);
}); });
it('calls the "selectAnnotations" RPC method', () => {
const sidebar = createSidebar();
connectGuest(sidebar);
const { onSelectAnnotations } = FakeBucketBar.getCall(0).args[2];
const tags = ['t1', 't2'];
const toggle = true;
onSelectAnnotations(tags, toggle);
assert.calledWith(guestRPC().call, 'selectAnnotations', tags, true);
});
}); });
}); });
...@@ -75,6 +75,11 @@ export type HostToGuestEvent = ...@@ -75,6 +75,11 @@ export type HostToGuestEvent =
*/ */
| 'clearSelectionExceptIn' | 'clearSelectionExceptIn'
/**
* The host informs guests to select/toggle on a set of annotations
*/
| 'selectAnnotations'
/** /**
* The host informs guests that the sidebar layout has been changed. * The host informs guests that the sidebar layout has been changed.
*/ */
......
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