Skip to content

Commit 5ba56de

Browse files
authored
feat(image): Add support for region annotations on image files (#508)
1 parent 7223fd4 commit 5ba56de

32 files changed

+732
-261
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"react-tether": "1.0.5",
3535
"react-textarea-autosize": "^7.1.2",
3636
"redux": "^4.0.5",
37+
"regenerator-runtime": "^0.13.5",
3738
"scroll-into-view-if-needed": "^2.2.24"
3839
},
3940
"devDependencies": {
@@ -100,7 +101,6 @@
100101
"postcss-loader": "^3.0.0",
101102
"prettier": "^1.19.1",
102103
"raw-loader": "^4.0.1",
103-
"regenerator-runtime": "^0.13.5",
104104
"sass-loader": "^8.0.2",
105105
"style-loader": "^1.1.4",
106106
"stylelint": "^12.0.0",

src/BoxAnnotations.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import 'regenerator-runtime/runtime';
12
import getProp from 'lodash/get';
3+
import BaseAnnotator from './common/BaseAnnotator';
4+
import ImageAnnotator from './image/ImageAnnotator';
25
import DocumentAnnotator from './document/DocumentAnnotator';
36
import { IntlOptions, Permissions, PERMISSIONS, Type } from './@types';
47

58
type Annotator = {
6-
CONSTRUCTOR: typeof DocumentAnnotator;
9+
CONSTRUCTOR: typeof BaseAnnotator;
710
NAME: string;
811
TYPES: string[];
912
VIEWERS: string[];
@@ -46,6 +49,12 @@ const ANNOTATORS: Annotator[] = [
4649
TYPES: [Type.region],
4750
VIEWERS: ['AutoCAD', 'Document', 'Presentation'],
4851
},
52+
{
53+
CONSTRUCTOR: ImageAnnotator,
54+
NAME: 'Image',
55+
TYPES: [Type.region],
56+
VIEWERS: ['Image'],
57+
},
4958
];
5059

5160
class BoxAnnotations {

src/common/BaseAnnotator.ts

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import EventEmitter from './EventEmitter';
55
import i18n from '../utils/i18n';
66
import messages from '../messages';
77
import { Event, IntlOptions, LegacyEvent, Permissions } from '../@types';
8-
import { getIsInitialized, setIsInitialized } from '../store';
8+
import { setIsInitialized } from '../store';
99
import './BaseAnnotator.scss';
1010

1111
export type Container = string | HTMLElement;
@@ -36,14 +36,17 @@ export type Options = {
3636
token: string;
3737
};
3838

39+
export const CSS_CONTAINER_CLASS = 'ba';
40+
export const CSS_LOADED_CLASS = 'ba-annotations-loaded';
41+
3942
export default class BaseAnnotator extends EventEmitter {
40-
container: Container;
43+
annotatedEl?: HTMLElement | null;
4144

42-
intl: IntlShape;
45+
containerEl?: HTMLElement | null;
4346

44-
rootEl?: HTMLElement | null;
47+
container: Container;
4548

46-
scale = 1;
49+
intl: IntlShape;
4750

4851
store: store.AppStore;
4952

@@ -79,8 +82,12 @@ export default class BaseAnnotator extends EventEmitter {
7982
}
8083

8184
public destroy(): void {
82-
if (this.rootEl) {
83-
this.rootEl.classList.remove('ba');
85+
if (this.containerEl) {
86+
this.containerEl.classList.remove(CSS_CONTAINER_CLASS);
87+
}
88+
89+
if (this.annotatedEl) {
90+
this.annotatedEl.classList.remove(CSS_LOADED_CLASS);
8491
}
8592

8693
this.removeListener(LegacyEvent.SCALE, this.handleScale);
@@ -89,16 +96,28 @@ export default class BaseAnnotator extends EventEmitter {
8996
this.removeListener(Event.VISIBLE_SET, this.handleSetVisible);
9097
}
9198

92-
public init(scale: number): void {
93-
this.rootEl = this.getElement(this.container);
94-
this.scale = scale;
99+
public init(scale = 1, rotation = 0): void {
100+
this.containerEl = this.getElement(this.container);
101+
this.annotatedEl = this.getAnnotatedElement();
95102

96-
if (!this.rootEl) {
103+
if (!this.annotatedEl || !this.containerEl) {
97104
this.emit(LegacyEvent.ERROR, this.intl.formatMessage(messages.annotationsLoadError));
98105
return;
99106
}
100107

101-
this.rootEl.classList.add('ba');
108+
// Add classes to the parent elements to support CSS scoping
109+
this.annotatedEl.classList.add(CSS_LOADED_CLASS);
110+
this.containerEl.classList.add(CSS_CONTAINER_CLASS);
111+
112+
// Update the store with the options provided by preview
113+
this.store.dispatch(store.setRotationAction(rotation));
114+
this.store.dispatch(store.setScaleAction(scale));
115+
116+
// Defer to the child class to render annotations
117+
this.render();
118+
119+
// Update the store now that annotations have been rendered
120+
this.store.dispatch(setIsInitialized());
102121
}
103122

104123
public removeAnnotation = (annotationId: string): void => {
@@ -115,20 +134,25 @@ export default class BaseAnnotator extends EventEmitter {
115134
}
116135

117136
public setVisibility(visibility: boolean): void {
118-
if (!this.rootEl) {
137+
if (!this.containerEl) {
119138
return;
120139
}
140+
121141
if (visibility) {
122-
this.rootEl.classList.remove('is-hidden');
142+
this.containerEl.classList.remove('is-hidden');
123143
} else {
124-
this.rootEl.classList.add('is-hidden');
144+
this.containerEl.classList.add('is-hidden');
125145
}
126146
}
127147

128148
public toggleAnnotationMode(mode: store.Mode): void {
129149
this.store.dispatch(store.toggleAnnotationModeAction(mode));
130150
}
131151

152+
protected getAnnotatedElement(): HTMLElement | null | undefined {
153+
return undefined; // Must be implemented in child class
154+
}
155+
132156
protected getElement(selector: HTMLElement | string): HTMLElement | null {
133157
return typeof selector === 'string' ? document.querySelector(selector) : selector;
134158
}
@@ -137,8 +161,8 @@ export default class BaseAnnotator extends EventEmitter {
137161
this.removeAnnotation(annotationId);
138162
};
139163

140-
protected handleScale = ({ scale }: { scale: number }): void => {
141-
this.init(scale);
164+
protected handleScale = ({ rotationAngle, scale }: { rotationAngle: number; scale: number }): void => {
165+
this.init(scale, rotationAngle);
142166
};
143167

144168
protected handleSetActive = (annotationId: string | null): void => {
@@ -155,9 +179,7 @@ export default class BaseAnnotator extends EventEmitter {
155179
this.store.dispatch<any>(store.fetchCollaboratorsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any
156180
}
157181

158-
protected handleInitialized(): void {
159-
if (!getIsInitialized(this.store.getState())) {
160-
this.store.dispatch(setIsInitialized());
161-
}
182+
protected render(): void {
183+
// Must be implemented in child class
162184
}
163185
}

src/common/BaseManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { IntlShape } from 'react-intl';
22
import { Store } from 'redux';
33

44
export type Options = {
5-
page: number;
6-
pageEl: HTMLElement;
5+
location?: number;
76
referenceEl: HTMLElement;
87
};
98

@@ -14,6 +13,7 @@ export type Props = {
1413

1514
export default interface BaseManager {
1615
destroy(): void;
17-
exists(pageEl: HTMLElement): boolean;
16+
exists(parentEl: HTMLElement): boolean;
1817
render(props: Props): void;
18+
style(styles: Partial<CSSStyleDeclaration>): CSSStyleDeclaration;
1919
}

src/common/__tests__/BaseAnnotator-test.ts

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
11
import * as store from '../../store';
22
import APIFactory from '../../api';
3-
import BaseAnnotator from '../BaseAnnotator';
3+
import BaseAnnotator, { CSS_CONTAINER_CLASS, CSS_LOADED_CLASS } from '../BaseAnnotator';
44
import { ANNOTATOR_EVENT } from '../../constants';
55
import { Event, LegacyEvent } from '../../@types';
66
import { Mode } from '../../store/common';
7+
import { setIsInitialized } from '../../store';
78

89
jest.mock('../../api');
9-
jest.mock('../../store', () => ({
10-
createStore: jest.fn(() => ({ dispatch: jest.fn() })),
11-
removeAnnotationAction: jest.fn(),
12-
fetchAnnotationsAction: jest.fn(),
13-
fetchCollaboratorsAction: jest.fn(),
14-
setActiveAnnotationIdAction: jest.fn(),
15-
setVisibilityAction: jest.fn(),
16-
toggleAnnotationModeAction: jest.fn(),
17-
}));
10+
jest.mock('../../store/createStore');
11+
12+
class MockAnnotator extends BaseAnnotator {
13+
protected getAnnotatedElement(): HTMLElement | null | undefined {
14+
return this.containerEl?.querySelector('.inner');
15+
}
16+
}
1817

1918
describe('BaseAnnotator', () => {
19+
const container = document.createElement('div');
20+
container.innerHTML = `<div class="inner" />`;
21+
2022
const defaults = {
2123
apiHost: 'https://api.box.com',
22-
container: document.createElement('div'),
24+
container,
2325
file: {
2426
id: '12345',
2527
file_version: { id: '98765' },
@@ -34,7 +36,7 @@ describe('BaseAnnotator', () => {
3436
locale: 'en-US',
3537
token: '1234567890',
3638
};
37-
const getAnnotator = (options = {}): BaseAnnotator => new BaseAnnotator({ ...defaults, ...options });
39+
const getAnnotator = (options = {}): MockAnnotator => new MockAnnotator({ ...defaults, ...options });
3840
let annotator = getAnnotator();
3941

4042
beforeEach(() => {
@@ -107,13 +109,13 @@ describe('BaseAnnotator', () => {
107109

108110
describe('destroy()', () => {
109111
test('should remove the base class name from the root element', () => {
110-
const rootEl = document.createElement('div');
111-
rootEl.classList.add('ba');
112+
const containerEl = document.createElement('div');
113+
containerEl.classList.add(CSS_CONTAINER_CLASS);
112114

113-
annotator.rootEl = rootEl;
115+
annotator.containerEl = containerEl;
114116
annotator.destroy();
115117

116-
expect(annotator.rootEl.classList).not.toContain('ba');
118+
expect(annotator.containerEl.classList).not.toContain(CSS_CONTAINER_CLASS);
117119
});
118120

119121
test('should remove proper event handlers', () => {
@@ -129,11 +131,20 @@ describe('BaseAnnotator', () => {
129131
});
130132

131133
describe('init()', () => {
132-
test('should set the root element based on class selector', () => {
134+
test('should set its reference elements based on class selector', () => {
133135
annotator.init(5);
134136

135-
expect(annotator.rootEl).toBe(defaults.container);
136-
expect(annotator.rootEl && annotator.rootEl.classList).toContain('ba');
137+
expect(annotator.containerEl).toBeDefined();
138+
expect(annotator.containerEl?.classList).toContain(CSS_CONTAINER_CLASS);
139+
expect(annotator.annotatedEl?.classList).toContain(CSS_LOADED_CLASS);
140+
});
141+
142+
test('should dispatch all necessary actions', () => {
143+
annotator.init(1, 180);
144+
145+
expect(annotator.store.dispatch).toHaveBeenCalledWith(store.setRotationAction(180));
146+
expect(annotator.store.dispatch).toHaveBeenCalledWith(store.setScaleAction(1));
147+
expect(annotator.store.dispatch).toHaveBeenCalledWith(setIsInitialized());
137148
});
138149

139150
test('should emit error if no root element exists', () => {
@@ -142,7 +153,7 @@ describe('BaseAnnotator', () => {
142153
annotator.init(5);
143154

144155
expect(annotator.emit).toBeCalledWith(ANNOTATOR_EVENT.error, expect.any(String));
145-
expect(annotator.rootEl).toBeNull();
156+
expect(annotator.containerEl).toBeNull();
146157
});
147158
});
148159

@@ -155,8 +166,8 @@ describe('BaseAnnotator', () => {
155166
});
156167

157168
test('should call their underlying methods', () => {
158-
annotator.emit(LegacyEvent.SCALE, { scale: 1 });
159-
expect(annotator.init).toHaveBeenCalledWith(1);
169+
annotator.emit(LegacyEvent.SCALE, { rotationAngle: 0, scale: 1 });
170+
expect(annotator.init).toHaveBeenCalledWith(1, 0);
160171

161172
annotator.emit(Event.ACTIVE_SET, 12345);
162173
expect(annotator.setActiveId).toHaveBeenCalledWith(12345);
@@ -177,36 +188,37 @@ describe('BaseAnnotator', () => {
177188

178189
describe('setVisibility()', () => {
179190
test.each([true, false])('should hide/show annotations if visibility is %p', visibility => {
180-
annotator.rootEl = defaults.container;
191+
annotator.init(1);
181192
annotator.setVisibility(visibility);
182-
expect(annotator.rootEl.classList.contains('is-hidden')).toEqual(!visibility);
193+
expect(annotator.containerEl?.classList.contains('is-hidden')).toEqual(!visibility);
194+
});
195+
196+
test('should do nothing if the root element is not defined', () => {
197+
annotator.containerEl = document.querySelector('nonsense') as HTMLElement;
198+
annotator.setVisibility(true);
199+
expect(annotator.containerEl?.classList).toBeFalsy();
183200
});
184201
});
185202

186203
describe('setActiveAnnotationId()', () => {
187204
test.each([null, '12345'])('should dispatch setActiveAnnotationIdAction with id %s', id => {
188205
annotator.setActiveId(id);
189-
expect(annotator.store.dispatch).toBeCalled();
190-
expect(store.setActiveAnnotationIdAction).toBeCalledWith(id);
206+
expect(annotator.store.dispatch).toBeCalledWith(store.setActiveAnnotationIdAction(id));
191207
});
192208
});
193209

194210
describe('removeAnnotation', () => {
195-
test('should dispatch deleteActiveAnnotationAction', () => {
211+
test('should dispatch removeActiveAnnotationAction with the specified id', () => {
196212
const id = '123';
197213
annotator.removeAnnotation(id);
198-
199-
expect(annotator.store.dispatch).toBeCalled();
200-
expect(store.removeAnnotationAction).toBeCalledWith(id);
214+
expect(annotator.store.dispatch).toBeCalledWith(store.removeAnnotationAction(id));
201215
});
202216
});
203217

204218
describe('toggleAnnotationMode()', () => {
205219
test('should dispatch toggleAnnotationModeAction with specified mode', () => {
206220
annotator.toggleAnnotationMode('region' as Mode);
207-
208-
expect(annotator.store.dispatch).toBeCalled();
209-
expect(store.toggleAnnotationModeAction).toBeCalledWith('region');
221+
expect(annotator.store.dispatch).toBeCalledWith(store.toggleAnnotationModeAction('region' as Mode));
210222
});
211223
});
212224
});

src/constants.js

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
1-
// Annotation CSS constants
2-
export const CLASS_ANNOTATIONS_LOADED = 'ba-annotations-loaded';
3-
41
export const ANNOTATOR_EVENT = {
52
fetch: 'annotationsfetched',
63
error: 'annotationerror',
74
scale: 'scaleannotations',
85
setVisibility: 'annotationsetvisibility',
96
};
10-
11-
export const PLACEHOLDER_USER = {
12-
type: 'user',
13-
id: '0',
14-
email: '',
15-
};

0 commit comments

Comments
 (0)