Commit a0cb5684 authored by Robert Knight's avatar Robert Knight

Handle hovering annotations which are not loaded in guest frame

When a user hovers an annotation card for an EPUB chapter that is different than
the one currently loaded in the guest, and then clicks on that annotation to
navigate the guest to that chapter, ensure that the highlights in the document
are displayed in a hovered state once anchoring completes.

This is handled similar to `FrameSyncService.scrollToAnnotation`, by setting a
flag if the annotation is not loaded in the guest, which triggers a
"hoverAnnotations" RPC call once the annotation is anchored.
parent bb8dd834
......@@ -55,13 +55,6 @@ function SidebarView({
const sidebarHasOpened = store.hasSidebarOpened();
const userId = store.profile().userid;
// The local `$tag` of a direct-linked annotation; populated once it
// has anchored: meaning that it's ready to be focused and scrolled to
const linkedAnnotationAnchorTag =
linkedAnnotation && linkedAnnotation.$orphan === false
? linkedAnnotation.$tag
: null;
// If, after loading completes, no `linkedAnnotation` object is present when
// a `linkedAnnotationId` is set, that indicates an error
const hasDirectLinkedAnnotationError =
......@@ -116,21 +109,15 @@ function SidebarView({
// When a `linkedAnnotationAnchorTag` becomes available, scroll to it
// and focus it
useEffect(() => {
if (linkedAnnotation && linkedAnnotationAnchorTag) {
frameSync.hoverAnnotations([linkedAnnotationAnchorTag]);
if (linkedAnnotation && linkedAnnotation.$orphan === false) {
frameSync.hoverAnnotation(linkedAnnotation);
frameSync.scrollToAnnotation(linkedAnnotation);
store.selectTab(directLinkedTab);
} else if (linkedAnnotation) {
// Make sure to allow for orphaned annotations (which won't have an anchor)
store.selectTab(directLinkedTab);
}
}, [
directLinkedTab,
frameSync,
linkedAnnotation,
linkedAnnotationAnchorTag,
store,
]);
}, [directLinkedTab, frameSync, linkedAnnotation, store]);
// Connect to the streamer when the sidebar has opened or if user is logged in
const hasFetchedProfile = store.hasFetchedProfile();
......
......@@ -32,11 +32,8 @@ function ThreadCard({ frameSync, thread }) {
const focusThreadAnnotation = useMemo(
() =>
debounce(
/** @param {string|null} tag */
tag => {
const focusTags = tag ? [tag] : [];
frameSync.hoverAnnotations(focusTags);
},
/** @param {Annotation|null} ann */
ann => frameSync.hoverAnnotation(ann),
10
),
[frameSync]
......@@ -94,7 +91,7 @@ function ThreadCard({ frameSync, thread }) {
scrollToAnnotation(thread.annotation);
}
}}
onMouseEnter={() => focusThreadAnnotation(threadTag ?? null)}
onMouseEnter={() => focusThreadAnnotation(thread.annotation ?? null)}
onMouseLeave={() => focusThreadAnnotation(null)}
key={thread.id}
>
......
......@@ -26,7 +26,7 @@ describe('SidebarView', () => {
beforeEach(() => {
fakeFrameSync = {
hoverAnnotations: sinon.stub(),
hoverAnnotation: sinon.stub(),
scrollToAnnotation: sinon.stub(),
};
fakeLoadAnnotationsService = {
......@@ -148,11 +148,8 @@ describe('SidebarView', () => {
createComponent();
assert.calledOnce(fakeFrameSync.scrollToAnnotation);
assert.calledWith(fakeFrameSync.scrollToAnnotation, fakeAnnotation);
assert.calledOnce(fakeFrameSync.hoverAnnotations);
assert.calledWith(
fakeFrameSync.hoverAnnotations,
sinon.match(['myTag'])
);
assert.calledOnce(fakeFrameSync.hoverAnnotation);
assert.calledWith(fakeFrameSync.hoverAnnotation, fakeAnnotation);
});
it('selects the correct tab for direct-linked annotations once anchored', () => {
......
......@@ -27,7 +27,7 @@ describe('ThreadCard', () => {
fakeDebounce = sinon.stub().returnsArg(0);
fakeFrameSync = {
hoverAnnotations: sinon.stub(),
hoverAnnotation: sinon.stub(),
scrollToAnnotation: sinon.stub(),
};
fakeStore = {
......@@ -84,7 +84,7 @@ describe('ThreadCard', () => {
wrapper.find(threadCardSelector).simulate('mouseenter');
assert.calledWith(fakeFrameSync.hoverAnnotations, sinon.match(['myTag']));
assert.calledWith(fakeFrameSync.hoverAnnotation, fakeThread.annotation);
});
it('unfocuses the annotation thread when mouse exits', () => {
......@@ -92,7 +92,7 @@ describe('ThreadCard', () => {
wrapper.find(threadCardSelector).simulate('mouseleave');
assert.calledWith(fakeFrameSync.hoverAnnotations, sinon.match([]));
assert.calledWith(fakeFrameSync.hoverAnnotation, null);
});
['button', 'a'].forEach(tag => {
......
......@@ -137,6 +137,13 @@ export class FrameSyncService {
*/
private _pendingScrollToId: string | null;
/**
* ID of an annotation that should be hovered after anchoring completes.
*
* See notes for {@link _pendingScrollToId}.
*/
private _pendingHoverId: string | null;
// Test seam
private _window: Window;
......@@ -158,7 +165,9 @@ export class FrameSyncService {
this._guestRPC = new Map();
this._inFrame = new Set<string>();
this._highlightsVisible = false;
this._pendingScrollToId = null;
this._pendingHoverId = null;
this._setupSyncToGuests();
this._setupHostEvents();
......@@ -367,8 +376,12 @@ export class FrameSyncService {
anchoringStatusUpdates[$tag] = $orphan ? 'orphan' : 'anchored';
scheduleAnchoringStatusUpdate();
if (this._pendingScrollToId) {
const [id] = this._store.findIDsForTags([$tag]);
if (id === this._pendingHoverId) {
this._pendingHoverId = null;
guestRPC.call('hoverAnnotations', [$tag]);
}
if (this._pendingScrollToId) {
if (id === this._pendingScrollToId) {
this._pendingScrollToId = null;
guestRPC.call('scrollToAnnotation', $tag);
......@@ -508,15 +521,41 @@ export class FrameSyncService {
}
/**
* Mark annotations as hovered.
* Mark annotation as hovered.
*
* This is used to indicate the highlights in the document that correspond
* to hovered annotations in the sidebar.
* to a hovered annotation in the sidebar.
*
* @param tags - annotation $tags
* This function only accepts a single annotation because the user can only
* hover one annotation card in the sidebar at a time. Hover updates in the
* other direction (guest to sidebar) support multiple annotations since a
* user can hover multiple highlights in the document at once.
*/
hoverAnnotations(tags: string[]) {
hoverAnnotation(ann: Annotation | null) {
this._pendingHoverId = null;
if (!ann) {
this._guestRPC.forEach(rpc => rpc.call('hoverAnnotations', []));
return;
}
const tags = ann ? [ann.$tag] : [];
this._store.hoverAnnotations(tags);
// If annotation is not currently anchored in a guest, schedule hover for
// when annotation is anchored. This can happen if an annotation is for a
// different chapter of an EPUB than the currently loaded one. See notes in
// `scrollToAnnotation`.
const frame = frameForAnnotation(this._store.frames(), ann);
if (
!frame ||
(frame.segment && !annotationMatchesSegment(ann, frame.segment))
) {
if (ann.id) {
this._pendingHoverId = ann.id;
}
return;
}
this._guestRPC.forEach(rpc => rpc.call('hoverAnnotations', tags));
}
......
......@@ -147,7 +147,7 @@ describe('FrameSyncService', () => {
this.setState({ contentInfo: info });
},
findIDsForTags: sinon.stub(),
findIDsForTags: sinon.stub().returns([]),
hoverAnnotations: sinon.stub(),
isLoggedIn: sinon.stub().returns(false),
openSidebarPanel: sinon.stub(),
......@@ -226,6 +226,29 @@ describe('FrameSyncService', () => {
return port1;
}
/**
* Create a fake annotation for an EPUB book.
*
* @param {string} cfi - Canonical Fragment Identifier indicating which chapter
* the annotation relates to
*/
function createEPUBAnnotation(cfi) {
return {
id: 'epub-id-1',
$tag: 'epub-tag-1',
target: [
{
selector: [
{
type: 'EPUBContentSelector',
cfi,
},
],
},
],
};
}
describe('#connect', () => {
it('discovers and connects to the host frame', async () => {
await frameSync.connect();
......@@ -890,27 +913,63 @@ describe('FrameSyncService', () => {
});
});
describe('#hoverAnnotations', () => {
describe('#hoverAnnotation', () => {
beforeEach(async () => {
frameSync.connect();
await connectGuest();
emitGuestEvent('documentInfoChanged', fixtures.htmlDocumentInfo);
});
it('should update the focused annotations in the store', () => {
frameSync.hoverAnnotations(['a1', 'a2']);
it('updates the focused annotations in the store', () => {
frameSync.hoverAnnotation(fixtures.ann);
assert.calledWith(
fakeStore.hoverAnnotations,
sinon.match.array.deepEquals(['a1', 'a2'])
sinon.match.array.deepEquals([fixtures.ann.$tag])
);
});
it('should focus the associated highlights in the guest', () => {
frameSync.hoverAnnotations([1, 2]);
it('focuses the associated highlights in the guest', () => {
frameSync.hoverAnnotation(fixtures.ann);
assert.calledWith(
guestRPC().call,
'hoverAnnotations',
sinon.match.array.deepEquals([1, 2])
sinon.match.array.deepEquals([fixtures.ann.$tag])
);
});
it('clears focused annotations in guest if argument is `null`', () => {
frameSync.hoverAnnotation(null);
assert.calledWith(guestRPC().call, 'hoverAnnotations', []);
});
it('defers focusing highlights when annotation is in a different EPUB chapter', async () => {
emitGuestEvent('documentInfoChanged', fixtures.epubDocumentInfo);
// Create an annotation with a CFI that doesn't match `fixtures.epubDocumentInfo`.
const ann = createEPUBAnnotation('/4/8');
fakeStore.findIDsForTags.withArgs([ann.$tag]).returns([ann.id]);
// Request hover of annotation. The annotation is marked as hovered in
// the sidebar, but nothing is sent to the guest since the annotation's
// book chapter is not loaded.
frameSync.hoverAnnotation(ann);
assert.calledWith(
fakeStore.hoverAnnotations,
sinon.match.array.deepEquals([ann.$tag])
);
assert.isFalse(guestRPC().call.calledWith('hoverAnnotations'));
// Simulate annotation anchoring at a later point, after a chapter
// navigation.
emitGuestEvent('syncAnchoringStatus', { $tag: ann.$tag, $orphan: false });
assert.calledWith(guestRPC().call, 'hoverAnnotations', [ann.$tag]);
// After the `hoverAnnotations` call has been sent, the pending-hover
// state internally should be cleared and a later `syncAnchoringStatus`
// event should not re-hover.
guestRPC().call.resetHistory();
emitGuestEvent('syncAnchoringStatus', { $tag: ann.$tag, $orphan: false });
assert.notCalled(guestRPC().call);
});
});
......@@ -923,23 +982,6 @@ describe('FrameSyncService', () => {
frameSync.scrollToAnnotation(fixtures.ann);
});
function createEPUBAnnotation(cfi) {
return {
id: 'epub-id-1',
$tag: 'epub-tag-1',
target: [
{
selector: [
{
type: 'EPUBContentSelector',
cfi,
},
],
},
],
};
}
it('should scroll to the annotation in the correct guest', async () => {
await connectGuest();
emitGuestEvent('documentInfoChanged', fixtures.htmlDocumentInfo);
......
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