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.
@@ -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
+
package/src/constants.js DELETED
@@ -1,8 +0,0 @@
1
- export const ANNOTATOR_EVENT = {
2
- fetch: 'annotationsfetched',
3
- error: 'annotationerror',
4
- scale: 'scaleannotations',
5
- setVisibility: 'annotationsetvisibility',
6
- };
7
-
8
- export const MOUSE_PRIMARY = 1;