Skip to content

Commit 121de79

Browse files
authored
fix(video-annotations): fix video annotation version change issue (#4380)
* fix(video-annotations): fix issue with video annotations not working on version switching * fix(video-annotations): fixing unit tests * fix(video-annotations): fixing version change issue with video annoations * fix(video-annotations): refactoring * feat(video-annotations): refactor * feat(video-annotations): refactor * feat(video-annotations): fix flow type * feat(video-annotations): addressing pr comments * feat(video-annotations): addressing PR comments * feat(video-annotation): fix flow error * feat(video-annotations): addressing PR comments * feat(video-annotations): fixing flow errors * feat(video-annotations): addressing PR comment * feat(video-annotations): fixng tests
1 parent 72e7fbf commit 121de79

File tree

4 files changed

+321
-10
lines changed

4 files changed

+321
-10
lines changed

src/elements/content-preview/ContentPreview.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
ERROR_CODE_UNKNOWN,
6262
} from '../../constants';
6363
import type { Annotation } from '../../common/types/feed';
64+
import type { Target } from '../../common/types/annotations';
6465
import type { TargetingApi } from '../../features/targeting/types';
6566
import type { ErrorType, AdditionalVersionInfo } from '../common/flowTypes';
6667
import type { WithLoggerProps } from '../../common/types/logging';
@@ -193,6 +194,7 @@ const startAtTypes = {
193194
const InvalidIdError = new Error('Invalid id for Preview!');
194195
const PREVIEW_LOAD_METRIC_EVENT = 'load';
195196
const MARK_NAME_JS_READY = `${ORIGIN_CONTENT_PREVIEW}_${EVENT_JS_READY}`;
197+
const SCROLL_TO_ANNOTATION_EVENT = 'scrolltoannotation';
196198

197199
mark(MARK_NAME_JS_READY);
198200

@@ -228,6 +230,8 @@ class ContentPreview extends React.PureComponent<Props, State> {
228230

229231
updateVersionToCurrent: ?() => void;
230232

233+
dynamicOnPreviewLoadAction: ?() => void;
234+
231235
initialState: State = {
232236
canPrint: false,
233237
error: undefined,
@@ -734,6 +738,10 @@ class ContentPreview extends React.PureComponent<Props, State> {
734738
}
735739

736740
this.handleCanPrint();
741+
742+
if (this.dynamicOnPreviewLoadAction) {
743+
this.dynamicOnPreviewLoadAction();
744+
}
737745
};
738746

739747
/**
@@ -847,6 +855,7 @@ class ContentPreview extends React.PureComponent<Props, State> {
847855
const { Preview } = global.Box;
848856
this.preview = new Preview();
849857
this.preview.addListener('load', this.onPreviewLoad);
858+
850859
this.preview.addListener('preview_error', this.onPreviewError);
851860
this.preview.addListener('preview_metric', this.onPreviewMetric);
852861
this.preview.addListener('thumbnailsOpen', () => this.setState({ isThumbnailSidebarOpen: true }));
@@ -1209,7 +1218,40 @@ class ContentPreview extends React.PureComponent<Props, State> {
12091218
});
12101219
};
12111220

1212-
handleAnnotationSelect = ({ file_version, id, target }: Annotation) => {
1221+
emitScrollToAnnotation = (id: string, target: Target) => {
1222+
const newViewer = this.getViewer();
1223+
// $FlowFixMe - Flow doesn't support optional chaining with method calls
1224+
newViewer?.emit(SCROLL_TO_ANNOTATION_EVENT, { id, target });
1225+
};
1226+
1227+
/**
1228+
* Handles scrolling to a frame-based annotation by waiting for video player to load first
1229+
*
1230+
* @param {string} id - The annotation ID
1231+
* @param {object} target - The annotation target
1232+
* @return {void}
1233+
*/
1234+
scrollToFrameAnnotation = (id: string, target: Target): void => {
1235+
// $FlowFixMe: querySelector('video') returns an HTMLVideoElement
1236+
const videoPlayer: HTMLVideoElement = document.querySelector('.bp-media-container video');
1237+
if (!videoPlayer) {
1238+
this.dynamicOnPreviewLoadAction = null;
1239+
return;
1240+
}
1241+
1242+
const videoReadyToScroll = videoPlayer.readyState === 4;
1243+
if (videoReadyToScroll) {
1244+
this.emitScrollToAnnotation(id, target);
1245+
return;
1246+
}
1247+
const handleLoadedData = () => {
1248+
this.emitScrollToAnnotation(id, target);
1249+
videoPlayer.removeEventListener('loadeddata', handleLoadedData);
1250+
};
1251+
videoPlayer.addEventListener('loadeddata', handleLoadedData);
1252+
};
1253+
1254+
handleAnnotationSelect = ({ file_version, id, target }: Annotation, deferScrollToOnload: boolean = false) => {
12131255
const { location = {} } = target;
12141256
const { file, selectedVersion } = this.state;
12151257
const annotationFileVersionId = getProp(file_version, 'id');
@@ -1227,8 +1269,19 @@ class ContentPreview extends React.PureComponent<Props, State> {
12271269
});
12281270
}
12291271

1230-
if (viewer) {
1231-
viewer.emit('scrolltoannotation', { id, target });
1272+
if (viewer && !deferScrollToOnload) {
1273+
viewer.emit(SCROLL_TO_ANNOTATION_EVENT, { id, target });
1274+
} else if (viewer && deferScrollToOnload) {
1275+
this.dynamicOnPreviewLoadAction = () => {
1276+
if (target?.location?.type === 'frame') {
1277+
this.scrollToFrameAnnotation(id, target);
1278+
} else {
1279+
const newViewer = this.getViewer();
1280+
// $FlowFixMe - Flow doesn't support optional chaining with method calls
1281+
newViewer?.emit(SCROLL_TO_ANNOTATION_EVENT, { id, target });
1282+
}
1283+
this.dynamicOnPreviewLoadAction = null;
1284+
};
12321285
}
12331286
};
12341287

src/elements/content-preview/__tests__/ContentPreview.test.js

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@ describe('elements/content-preview/ContentPreview', () => {
791791
beforeEach(() => {
792792
props = {
793793
collection: [{}, {}],
794-
fileId: file.id,
794+
fileId: 123,
795795
onLoad: jest.fn(),
796796
token: 'token',
797797
};
@@ -824,6 +824,16 @@ describe('elements/content-preview/ContentPreview', () => {
824824
instance.onPreviewLoad(data);
825825
expect(instance.state.isLoading).toBe(false);
826826
});
827+
828+
test('should call dynamicOnPreviewLoadAction if it is defined', () => {
829+
instance.dynamicOnPreviewLoadAction = jest.fn();
830+
instance.onPreviewLoad(data);
831+
expect(instance.dynamicOnPreviewLoadAction).toBeCalled();
832+
});
833+
834+
test('should not call dynamicOnPreviewLoadAction if it is not defined', () => {
835+
expect(() => instance.onPreviewLoad(data)).not.toThrow();
836+
});
827837
});
828838

829839
describe('onPreviewMetric()', () => {
@@ -1285,6 +1295,7 @@ describe('elements/content-preview/ContentPreview', () => {
12851295
annotationFileVersionId | selectedVersionId | locationType | setStateCount
12861296
${'123'} | ${'124'} | ${'page'} | ${1}
12871297
${'124'} | ${'124'} | ${'page'} | ${0}
1298+
${'123'} | ${'124'} | ${'frame'} | ${0}
12881299
${'123'} | ${'124'} | ${''} | ${0}
12891300
${undefined} | ${'124'} | ${'page'} | ${0}
12901301
`(
@@ -1317,6 +1328,112 @@ describe('elements/content-preview/ContentPreview', () => {
13171328
expect(emit).toBeCalledWith('scrolltoannotation', { id: annotation.id, target: annotation.target });
13181329
},
13191330
);
1331+
1332+
test.each`
1333+
annotationFileVersionId | selectedVersionId | locationType | deferScrollToOnload
1334+
${'123'} | ${'124'} | ${'frame'} | ${true}
1335+
${'123'} | ${'124'} | ${'page'} | ${false}
1336+
`(
1337+
'should not call emit scrolltoannotation if deferScrollToOnload is $deferScrollToOnload',
1338+
({ annotationFileVersionId, selectedVersionId, locationType, deferScrollToOnload }) => {
1339+
const annotation = {
1340+
id: '123',
1341+
file_version: { id: annotationFileVersionId },
1342+
target: { location: { type: locationType } },
1343+
};
1344+
const wrapper = getWrapper();
1345+
const instance = wrapper.instance();
1346+
wrapper.setState({ selectedVersion: { id: selectedVersionId } });
1347+
const emit = jest.fn();
1348+
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit });
1349+
instance.setState = jest.fn();
1350+
1351+
instance.handleAnnotationSelect(annotation, deferScrollToOnload);
1352+
if (deferScrollToOnload) {
1353+
expect(emit).not.toBeCalled();
1354+
expect(instance.dynamicOnPreviewLoadAction).toBeDefined();
1355+
} else {
1356+
expect(emit).toBeCalledWith('scrolltoannotation', { id: annotation.id, target: annotation.target });
1357+
expect(instance.dynamicOnPreviewLoadAction).not.toBeDefined();
1358+
}
1359+
},
1360+
);
1361+
test('should set dynamicOnPreviewLoadAction and execute scrollToFrameAnnotation properly if deferScrollToOnload is true', () => {
1362+
const annotation = {
1363+
id: '123',
1364+
file_version: { id: '123' },
1365+
target: { location: { type: 'frame' } },
1366+
};
1367+
const wrapper = getWrapper();
1368+
const instance = wrapper.instance();
1369+
wrapper.setState({ selectedVersion: { id: 123 } });
1370+
const emit = jest.fn();
1371+
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit });
1372+
instance.setState = jest.fn();
1373+
instance.handleAnnotationSelect(annotation, true);
1374+
expect(instance.dynamicOnPreviewLoadAction).toBeDefined();
1375+
let handleLoadedData;
1376+
const mockAddEventListener = jest.fn().mockImplementation((listener, callback) => {
1377+
expect(listener).toBe('loadeddata');
1378+
expect(callback).toBeDefined();
1379+
handleLoadedData = callback;
1380+
});
1381+
const mockRemoveEventListener = jest.fn().mockImplementation((listener, callback) => {
1382+
expect(listener).toBe('loadeddata');
1383+
expect(callback).toEqual(handleLoadedData);
1384+
handleLoadedData = null;
1385+
});
1386+
jest.spyOn(document, 'querySelector').mockReturnValue({
1387+
addEventListener: mockAddEventListener,
1388+
removeEventListener: mockRemoveEventListener,
1389+
});
1390+
instance.dynamicOnPreviewLoadAction();
1391+
expect(mockAddEventListener).toBeCalledWith('loadeddata', handleLoadedData);
1392+
handleLoadedData();
1393+
expect(emit).toBeCalledWith('scrolltoannotation', { id: annotation.id, target: annotation.target });
1394+
expect(instance.dynamicOnPreviewLoadAction).toBeNull();
1395+
expect(mockRemoveEventListener).toHaveBeenCalledTimes(1);
1396+
});
1397+
1398+
test('dynamicOnPreviewLoadAction should return if video player does not exist', () => {
1399+
const annotation = {
1400+
id: '123',
1401+
file_version: { id: '123' },
1402+
target: { location: { type: 'frame' } },
1403+
};
1404+
const wrapper = getWrapper();
1405+
const instance = wrapper.instance();
1406+
wrapper.setState({ selectedVersion: { id: 123 } });
1407+
const emit = jest.fn();
1408+
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit });
1409+
instance.setState = jest.fn();
1410+
jest.spyOn(document, 'querySelector').mockReturnValue(null);
1411+
instance.handleAnnotationSelect(annotation, true);
1412+
instance.dynamicOnPreviewLoadAction();
1413+
expect(instance.dynamicOnPreviewLoadAction).toBeNull();
1414+
expect(emit).not.toBeCalled();
1415+
});
1416+
1417+
test('dynamicOnPreviewLoadAction should still call emit scrolltoannotation if target location type is not frame', () => {
1418+
const annotation = {
1419+
id: '123',
1420+
file_version: { id: '123' },
1421+
target: { location: { type: 'page' } },
1422+
};
1423+
const wrapper = getWrapper();
1424+
const instance = wrapper.instance();
1425+
wrapper.setState({ selectedVersion: { id: 123 } });
1426+
const emit = jest.fn();
1427+
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit });
1428+
const scrollToFrameAnnotation = jest.fn();
1429+
jest.spyOn(instance, 'scrollToFrameAnnotation').mockReturnValue(scrollToFrameAnnotation);
1430+
instance.setState = jest.fn();
1431+
instance.handleAnnotationSelect(annotation, true);
1432+
instance.dynamicOnPreviewLoadAction();
1433+
expect(scrollToFrameAnnotation).not.toBeCalled();
1434+
expect(emit).toBeCalledWith('scrolltoannotation', { id: annotation.id, target: annotation.target });
1435+
expect(instance.dynamicOnPreviewLoadAction).toBeNull();
1436+
});
13201437
});
13211438

13221439
describe('getThumbnail()', () => {
@@ -1351,4 +1468,77 @@ describe('elements/content-preview/ContentPreview', () => {
13511468
expect(pageThumbnail).toBeNull();
13521469
});
13531470
});
1471+
1472+
describe('scrollToFrameAnnotation()', () => {
1473+
let annotation;
1474+
let addEventListener;
1475+
let removeEventListener;
1476+
let mockVideoPlayer;
1477+
beforeEach(() => {
1478+
addEventListener = jest.fn();
1479+
removeEventListener = jest.fn();
1480+
mockVideoPlayer = {
1481+
addEventListener,
1482+
removeEventListener,
1483+
readyState: 0,
1484+
};
1485+
1486+
annotation = {
1487+
id: '123',
1488+
file_version: { id: '123' },
1489+
target: { location: { type: 'frame' } },
1490+
};
1491+
1492+
jest.spyOn(document, 'querySelector').mockImplementation(selector => {
1493+
expect(selector).toBe('.bp-media-container video');
1494+
return mockVideoPlayer;
1495+
});
1496+
});
1497+
1498+
afterEach(() => {
1499+
jest.clearAllMocks();
1500+
});
1501+
1502+
test('should return if the video player is not found', () => {
1503+
const wrapper = getWrapper();
1504+
const instance = wrapper.instance();
1505+
const emit = jest.fn();
1506+
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit });
1507+
jest.spyOn(document, 'querySelector').mockReturnValue(null);
1508+
instance.scrollToFrameAnnotation(annotation.id, annotation.target);
1509+
expect(instance.dynamicOnPreviewLoadAction).toBeNull();
1510+
expect(emit).not.toBeCalled();
1511+
});
1512+
1513+
test('should emit scrolltoannotation if video player is ready to seek and do not set video player event listener', () => {
1514+
const wrapper = getWrapper();
1515+
const instance = wrapper.instance();
1516+
const emit = jest.fn();
1517+
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit });
1518+
mockVideoPlayer.readyState = 4;
1519+
instance.scrollToFrameAnnotation(annotation.id, annotation.target);
1520+
expect(emit).toBeCalledWith('scrolltoannotation', { id: annotation.id, target: annotation.target });
1521+
expect(mockVideoPlayer.addEventListener).not.toBeCalled();
1522+
});
1523+
1524+
test('should not throw an error if the video player is ready to seek and the viewer is not found', () => {
1525+
const wrapper = getWrapper();
1526+
const instance = wrapper.instance();
1527+
jest.spyOn(instance, 'getViewer').mockReturnValue(null);
1528+
mockVideoPlayer.readyState = 4;
1529+
instance.scrollToFrameAnnotation(annotation.id, annotation.target);
1530+
});
1531+
1532+
test('should add loadeddata event listener to video player if video player is not ready to seek', () => {
1533+
const wrapper = getWrapper();
1534+
const instance = wrapper.instance();
1535+
wrapper.setState({ selectedVersion: { id: 123 } });
1536+
const emit = jest.fn();
1537+
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit });
1538+
mockVideoPlayer.readyState = 0;
1539+
instance.scrollToFrameAnnotation(annotation.id, annotation.target);
1540+
expect(emit).not.toBeCalled();
1541+
expect(addEventListener).toBeCalledWith('loadeddata', expect.any(Function));
1542+
});
1543+
});
13541544
});

src/elements/content-sidebar/ActivitySidebar.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,9 +1153,12 @@ class ActivitySidebar extends React.PureComponent<Props, State> {
11531153
} else {
11541154
history.push(getAnnotationsPath(annotationFileVersionId, nextActiveAnnotationId));
11551155
}
1156-
}
11571156

1158-
onAnnotationSelect(annotation);
1157+
const deferScrollToOnload = annotation?.target?.location?.type === 'frame';
1158+
onAnnotationSelect(annotation, deferScrollToOnload);
1159+
} else {
1160+
onAnnotationSelect(annotation);
1161+
}
11591162
};
11601163

11611164
handleItemsFiltered = (status?: ActivityFilterItemType) => {

0 commit comments

Comments
 (0)