box-annotations 5.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotations.css +1 -1
- package/dist/annotations.js +1 -1
- package/package.json +1 -1
- package/src/@types/model.ts +12 -4
- package/src/BoxAnnotations.ts +7 -0
- package/src/common/BaseManager.ts +10 -2
- package/src/constants.ts +23 -0
- package/src/drawing/DrawingAnnotations.tsx +39 -2
- package/src/drawing/DrawingManager.tsx +1 -1
- package/src/drawing/actions.ts +4 -2
- package/src/highlight/actions.ts +2 -1
- package/src/media/MediaAnnotator.scss +20 -0
- package/src/media/MediaAnnotator.ts +140 -0
- package/src/popup/PopupLayer.tsx +7 -3
- package/src/popup/PopupManager.tsx +2 -2
- package/src/region/RegionAnnotations.tsx +24 -3
- package/src/region/RegionCreation.tsx +8 -2
- package/src/region/RegionCreationManager.tsx +1 -1
- package/src/region/RegionManager.tsx +1 -1
- package/src/region/actions.ts +4 -2
- package/src/store/annotations/selectors.ts +10 -2
- package/src/store/creator/selectors.ts +13 -0
- package/src/test-utils/videoAnnotations.tsx +178 -0
- package/src/utils/useVideoTiming.ts +112 -0
- package/src/constants.js +0 -8
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AppState } from '../types';
|
|
2
|
+
import { MEDIA_LOCATION_INDEX } from '../../constants';
|
|
2
3
|
import { CreatorItem, CreatorItemDrawing, CreatorItemHighlight, CreatorItemRegion, CreatorStatus } from './types';
|
|
3
4
|
|
|
4
5
|
type State = Pick<AppState, 'creator'>;
|
|
@@ -9,6 +10,18 @@ export const getCreatorReferenceId = (state: State): string | null => state.crea
|
|
|
9
10
|
export const getCreatorStaged = (state: State): CreatorItem | null => state.creator.staged;
|
|
10
11
|
export const getCreatorStagedForLocation = (state: State, location: number): CreatorItem | null => {
|
|
11
12
|
const staged = getCreatorStaged(state);
|
|
13
|
+
// The location is typically a page and this selector was originally meant to only return the staged annotation
|
|
14
|
+
// (those that have been drawn but not saved) that belong to the page specified by the location. For documents,
|
|
15
|
+
// the annotator creates new sets of managers(highlights, drawings, regions) for each page with the current
|
|
16
|
+
// page number being the location e.g. page 4 of 10. This selector prevents a staged annotation from staying on
|
|
17
|
+
// the screen when a user navigates to a different page. By default, the location value for managers is 1 which is
|
|
18
|
+
// why this selector works for images. Video annotations will not work this way because they reference a timestamp, not a page,
|
|
19
|
+
// and the drawing canvas is the video player which has no pages. In this case, we just need to get the staged annotation and do the
|
|
20
|
+
// locaton check for MEDIA_LOCATION_INDEX. The MEDIA_LOCATION_INDEX is a special value, set in the manager, that is used to indicate that
|
|
21
|
+
// the this is actually a staged video annotation with a timestamp as its location.
|
|
22
|
+
if (location === MEDIA_LOCATION_INDEX) {
|
|
23
|
+
return staged;
|
|
24
|
+
}
|
|
12
25
|
return staged && staged.location === location ? staged : null;
|
|
13
26
|
};
|
|
14
27
|
export const getCreatorStatus = (state: State): CreatorStatus => state.creator.status;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { ReactWrapper } from 'enzyme';
|
|
2
|
+
import { TARGET_TYPE } from '../constants';
|
|
3
|
+
import useVideoTiming from '../utils/useVideoTiming';
|
|
4
|
+
import { AnnotationDrawing, AnnotationRegion } from '../@types/model';
|
|
5
|
+
|
|
6
|
+
// Type alias for the list component wrapper
|
|
7
|
+
type ListComponentWrapper = ReactWrapper;
|
|
8
|
+
|
|
9
|
+
// Mock useVideoTiming hook
|
|
10
|
+
const mockUseVideoTiming = useVideoTiming as jest.MockedFunction<typeof useVideoTiming>;
|
|
11
|
+
|
|
12
|
+
const mockVideoTimingReturn = {
|
|
13
|
+
isVideoSeeking: false,
|
|
14
|
+
getCurrentVideoLocation: jest.fn(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface VideoAnnotationTestConfig {
|
|
18
|
+
componentName: string;
|
|
19
|
+
getWrapper: (props: unknown) => ReactWrapper;
|
|
20
|
+
findListComponent: (wrapper: ReactWrapper) => ListComponentWrapper;
|
|
21
|
+
videoAnnotations: AnnotationDrawing[]|AnnotationRegion[] ;
|
|
22
|
+
regularAnnotations: AnnotationDrawing[]|AnnotationRegion[];
|
|
23
|
+
activeAnnotationId: string;
|
|
24
|
+
nonExistentAnnotationId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const createVideoAnnotationTests = (config: VideoAnnotationTestConfig): void => {
|
|
28
|
+
const {
|
|
29
|
+
componentName,
|
|
30
|
+
getWrapper,
|
|
31
|
+
findListComponent,
|
|
32
|
+
videoAnnotations,
|
|
33
|
+
regularAnnotations,
|
|
34
|
+
activeAnnotationId,
|
|
35
|
+
nonExistentAnnotationId,
|
|
36
|
+
} = config;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
mockUseVideoTiming.mockReturnValue(mockVideoTimingReturn);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe(`${componentName} video annotations`, () => {
|
|
43
|
+
describe('TARGET_TYPE.FRAME target type', () => {
|
|
44
|
+
test('should show only active annotation when not seeking and active annotation exists', () => {
|
|
45
|
+
mockVideoTimingReturn.isVideoSeeking = false;
|
|
46
|
+
|
|
47
|
+
const wrapper = getWrapper({
|
|
48
|
+
targetType: TARGET_TYPE.FRAME,
|
|
49
|
+
annotations: videoAnnotations,
|
|
50
|
+
activeAnnotationId,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const listComponent = findListComponent(wrapper);
|
|
54
|
+
const annotations = listComponent.prop('annotations');
|
|
55
|
+
expect(annotations).toHaveLength(1);
|
|
56
|
+
expect((annotations as AnnotationDrawing[] | AnnotationRegion[])[0].id).toBe(activeAnnotationId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('should show no annotations when seeking and active annotation exists', () => {
|
|
60
|
+
mockVideoTimingReturn.isVideoSeeking = true;
|
|
61
|
+
|
|
62
|
+
const wrapper = getWrapper({
|
|
63
|
+
targetType: TARGET_TYPE.FRAME,
|
|
64
|
+
annotations: videoAnnotations,
|
|
65
|
+
activeAnnotationId,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const listComponent = findListComponent(wrapper);
|
|
69
|
+
expect(listComponent.prop('annotations')).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should show no annotations when not seeking but no active annotation', () => {
|
|
73
|
+
mockVideoTimingReturn.isVideoSeeking = false;
|
|
74
|
+
|
|
75
|
+
const wrapper = getWrapper({
|
|
76
|
+
targetType: TARGET_TYPE.FRAME,
|
|
77
|
+
annotations: videoAnnotations,
|
|
78
|
+
activeAnnotationId: null,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const listComponent = findListComponent(wrapper);
|
|
82
|
+
expect(listComponent.prop('annotations')).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('should show no annotations when seeking and no active annotation', () => {
|
|
86
|
+
mockVideoTimingReturn.isVideoSeeking = true;
|
|
87
|
+
|
|
88
|
+
const wrapper = getWrapper({
|
|
89
|
+
targetType: TARGET_TYPE.FRAME,
|
|
90
|
+
annotations: videoAnnotations,
|
|
91
|
+
activeAnnotationId: null,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const listComponent = findListComponent(wrapper);
|
|
95
|
+
expect(listComponent.prop('annotations')).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('should call useVideoTiming with correct parameters for TARGET_TYPE.FRAME type', () => {
|
|
99
|
+
const referenceEl = document.createElement('video');
|
|
100
|
+
|
|
101
|
+
getWrapper({
|
|
102
|
+
targetType: TARGET_TYPE.FRAME,
|
|
103
|
+
annotations: videoAnnotations,
|
|
104
|
+
activeAnnotationId,
|
|
105
|
+
referenceEl,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(mockUseVideoTiming).toHaveBeenCalledWith({
|
|
109
|
+
targetType: TARGET_TYPE.FRAME,
|
|
110
|
+
referenceEl,
|
|
111
|
+
activeAnnotationId,
|
|
112
|
+
annotations: videoAnnotations,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('useVideoTiming integration', () => {
|
|
118
|
+
test('should call useVideoTiming hook on component mount', () => {
|
|
119
|
+
const referenceEl = document.createElement('video');
|
|
120
|
+
|
|
121
|
+
getWrapper({
|
|
122
|
+
targetType: TARGET_TYPE.FRAME,
|
|
123
|
+
annotations: videoAnnotations,
|
|
124
|
+
activeAnnotationId,
|
|
125
|
+
referenceEl,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(mockUseVideoTiming).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('should pass correct parameters to useVideoTiming for TARGET_TYPE.PAGE type', () => {
|
|
132
|
+
const referenceEl = document.createElement('div');
|
|
133
|
+
|
|
134
|
+
getWrapper({
|
|
135
|
+
targetType: TARGET_TYPE.PAGE,
|
|
136
|
+
annotations: regularAnnotations,
|
|
137
|
+
activeAnnotationId,
|
|
138
|
+
referenceEl,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(mockUseVideoTiming).toHaveBeenCalledWith({
|
|
142
|
+
targetType: TARGET_TYPE.PAGE,
|
|
143
|
+
referenceEl,
|
|
144
|
+
activeAnnotationId,
|
|
145
|
+
annotations: regularAnnotations,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('annotation filtering edge cases', () => {
|
|
151
|
+
test('should handle empty annotations array for TARGET_TYPE.FRAME type', () => {
|
|
152
|
+
mockVideoTimingReturn.isVideoSeeking = false;
|
|
153
|
+
|
|
154
|
+
const wrapper = getWrapper({
|
|
155
|
+
targetType: TARGET_TYPE.FRAME,
|
|
156
|
+
annotations: [],
|
|
157
|
+
activeAnnotationId: nonExistentAnnotationId,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const listComponent = findListComponent(wrapper);
|
|
161
|
+
expect(listComponent.prop('annotations')).toHaveLength(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('should handle active annotation that does not exist in annotations array', () => {
|
|
165
|
+
mockVideoTimingReturn.isVideoSeeking = false;
|
|
166
|
+
|
|
167
|
+
const wrapper = getWrapper({
|
|
168
|
+
targetType: TARGET_TYPE.FRAME,
|
|
169
|
+
annotations: videoAnnotations,
|
|
170
|
+
activeAnnotationId: nonExistentAnnotationId,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const listComponent = findListComponent(wrapper);
|
|
174
|
+
expect(listComponent.prop('annotations')).toHaveLength(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { TARGET_TYPE } from "../constants";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export interface UseVideoTimingProps {
|
|
7
|
+
targetType: TARGET_TYPE;
|
|
8
|
+
referenceEl?: HTMLElement;
|
|
9
|
+
activeAnnotationId: string | null;
|
|
10
|
+
annotations: Array<{ id: string; target: { location: { value: number } } }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UseVideoTimingReturnType {
|
|
14
|
+
isVideoSeeking: boolean;
|
|
15
|
+
getCurrentVideoLocation: () => number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getVideoCurrentTimeInMilliseconds(video: HTMLVideoElement): number {
|
|
19
|
+
return (video?.currentTime ?? 0) * 1000;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* This hook is used to keep track of when the video is currently seeking and when it has reached
|
|
24
|
+
* the target time definined in the annotation. This is necessary because we cannot control how long it
|
|
25
|
+
* takes for the video to seek to the target time and we do not want to display an annotation on the video player
|
|
26
|
+
* at the wrong time. For example, if an annotation is defined at being at 20 seconds into the video and
|
|
27
|
+
* the video just loaded and needs to buffer in order to get to 20 seconds, we do not want the annotation
|
|
28
|
+
* drawing or region to be shown until the video has finished seeking and is at the correct time. The hook
|
|
29
|
+
* works by listenting the video seeking, seeked, and timeupdate events and setting the isVideoSeeking state
|
|
30
|
+
* based on these events. The hook will be used with both video drawing and video region annotations.
|
|
31
|
+
*/
|
|
32
|
+
const useVideoTiming = ({
|
|
33
|
+
targetType,
|
|
34
|
+
referenceEl,
|
|
35
|
+
activeAnnotationId,
|
|
36
|
+
annotations,
|
|
37
|
+
}: UseVideoTimingProps): UseVideoTimingReturnType => {
|
|
38
|
+
const [isVideoSeeking, setIsVideoSeeking] = useState<boolean>(false);
|
|
39
|
+
const [targetVideoTime, setTargetVideoTime] = useState<number | null>(null);
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
const getCurrentVideoTimeStamp = useCallback((): number => {
|
|
43
|
+
return getVideoCurrentTimeInMilliseconds(referenceEl as HTMLVideoElement);
|
|
44
|
+
}, [referenceEl]);
|
|
45
|
+
|
|
46
|
+
// Handle video seeking events
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (targetType !== TARGET_TYPE.FRAME || !referenceEl) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handleSeeking = (): void => {
|
|
53
|
+
setIsVideoSeeking(true);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleSeeked = (): void => {
|
|
57
|
+
setIsVideoSeeking(false);
|
|
58
|
+
setTargetVideoTime(null);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleTimeUpdate = (): void => {
|
|
62
|
+
if (isVideoSeeking && targetVideoTime !== null) {
|
|
63
|
+
const currentVideoTimePosition = getCurrentVideoTimeStamp();
|
|
64
|
+
const timeDiff = Math.abs(currentVideoTimePosition - targetVideoTime);
|
|
65
|
+
|
|
66
|
+
// Consider the video has reached the target time if within 100ms
|
|
67
|
+
if (timeDiff <= 100) {
|
|
68
|
+
setIsVideoSeeking(false);
|
|
69
|
+
setTargetVideoTime(null);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
referenceEl.addEventListener('seeking', handleSeeking);
|
|
75
|
+
referenceEl.addEventListener('seeked', handleSeeked);
|
|
76
|
+
referenceEl.addEventListener('timeupdate', handleTimeUpdate);
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
referenceEl.removeEventListener('seeking', handleSeeking);
|
|
80
|
+
referenceEl.removeEventListener('seeked', handleSeeked);
|
|
81
|
+
referenceEl.removeEventListener('timeupdate', handleTimeUpdate);
|
|
82
|
+
};
|
|
83
|
+
}, [targetType, referenceEl, isVideoSeeking, targetVideoTime, getCurrentVideoTimeStamp]);
|
|
84
|
+
|
|
85
|
+
// Set target video time when activeAnnotationId changes
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (targetType === TARGET_TYPE.FRAME && activeAnnotationId && referenceEl) {
|
|
88
|
+
const currentVideoTimePosition = getCurrentVideoTimeStamp();
|
|
89
|
+
|
|
90
|
+
// Find the annotation to get its target time
|
|
91
|
+
const annotation = annotations.find(ann => ann.id === activeAnnotationId);
|
|
92
|
+
if (annotation) {
|
|
93
|
+
const annotationTimePosition = annotation.target.location.value;
|
|
94
|
+
const timeDiff = Math.abs(currentVideoTimePosition - annotationTimePosition);
|
|
95
|
+
|
|
96
|
+
// If the video is not at the annotation's time, set seeking state
|
|
97
|
+
if (timeDiff > 100) {
|
|
98
|
+
setTargetVideoTime(annotationTimePosition);
|
|
99
|
+
setIsVideoSeeking(true);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}, [activeAnnotationId, targetType, referenceEl, annotations, getCurrentVideoTimeStamp]);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
isVideoSeeking,
|
|
107
|
+
getCurrentVideoLocation: getCurrentVideoTimeStamp,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export default useVideoTiming;
|
|
112
|
+
|