Skip to content

Commit 4303936

Browse files
authored
feat(bounding-box): support bounding box highlights in ImageAnnotator (#740)
* feat(bounding-box): support for rendering bounding boxes on images
1 parent 420ce05 commit 4303936

4 files changed

Lines changed: 193 additions & 28 deletions

File tree

src/common/BaseAnnotator.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import EventEmitter from './EventEmitter';
77
import i18n from '../utils/i18n';
88
import messages from '../messages';
99
import { Event, IntlOptions, LegacyEvent, Permissions } from '../@types';
10-
import { BoundingBox } from '../store/boundingBoxHighlights/types';
10+
import { BoundingBox, getBoundingBoxHighlights } from '../store/boundingBoxHighlights';
1111
import { ViewMode } from '../store/options/types';
1212
import { Features } from '../BoxAnnotations';
13+
import { scrollToLocation } from '../utils/scroll';
1314
import './BaseAnnotator.scss';
1415

1516
export type Container = string | HTMLElement;
@@ -185,9 +186,43 @@ export default class BaseAnnotator extends EventEmitter {
185186
// Called by box-content-preview
186187
}
187188

188-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
189189
public scrollToBoundingBoxHighlight(highlightId: string | null): void {
190-
// Implemented in DocumentAnnotator
190+
if (!highlightId || !this.annotatedEl) {
191+
return;
192+
}
193+
194+
const highlights = getBoundingBoxHighlights(this.store.getState());
195+
const highlight = highlights.find((h: BoundingBox) => h.id === highlightId);
196+
197+
if (!highlight) {
198+
return;
199+
}
200+
201+
const referenceEl = this.getScrollReferenceForHighlight(highlight);
202+
if (!referenceEl) {
203+
return;
204+
}
205+
206+
const offsets = {
207+
x: highlight.x + highlight.width / 2,
208+
y: highlight.y + highlight.height / 2,
209+
};
210+
211+
scrollToLocation(this.annotatedEl, referenceEl, {
212+
offsets: this.getAdjustedScrollOffsets(offsets),
213+
smooth: true,
214+
});
215+
}
216+
217+
/** Returns the element to scroll relative to for the given bounding box highlight. */
218+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
219+
protected getScrollReferenceForHighlight(_highlight: BoundingBox): HTMLElement | null | undefined {
220+
return undefined; // Must be implemented in child class
221+
}
222+
223+
/** Adjusts scroll offsets before passing to scrollToLocation (e.g. to apply rotation). */
224+
protected getAdjustedScrollOffsets(offsets: { x: number; y: number }): { x: number; y: number } {
225+
return offsets;
191226
}
192227

193228
/**

src/document/DocumentAnnotator.ts

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { BoundingBoxHighlightManager } from '../boundingBoxHighlight';
1212
import { centerRegion, isRegion, RegionCreationManager, RegionManager } from '../region';
1313
import { Event } from '../@types';
1414
import { getAnnotation } from '../store/annotations';
15-
import { getBoundingBoxHighlights, BoundingBox } from '../store/boundingBoxHighlights';
15+
import { BoundingBox } from '../store/boundingBoxHighlights';
1616
import { getSelection } from './docUtil';
1717
import { Manager } from '../common/BaseManager';
1818
import { getFileId, getIsCurrentFileVersion, getViewMode, Mode } from '../store';
@@ -235,28 +235,7 @@ export default class DocumentAnnotator extends BaseAnnotator {
235235
}
236236
}
237237

238-
scrollToBoundingBoxHighlight(highlightId: string | null): void {
239-
if (!highlightId || !this.annotatedEl) {
240-
return;
241-
}
242-
243-
const highlights = getBoundingBoxHighlights(this.store.getState());
244-
const highlight = highlights.find((h: BoundingBox) => h.id === highlightId);
245-
246-
if (!highlight) {
247-
return;
248-
}
249-
250-
const pageEl = this.getPage(highlight.pageNumber);
251-
if (!pageEl) {
252-
return;
253-
}
254-
255-
const offsets = {
256-
x: highlight.x + highlight.width / 2,
257-
y: highlight.y + highlight.height / 2,
258-
};
259-
260-
scrollToLocation(this.annotatedEl, pageEl, { offsets, smooth: true });
238+
protected getScrollReferenceForHighlight(highlight: BoundingBox): HTMLElement | undefined {
239+
return this.getPage(highlight.pageNumber);
261240
}
262241
}

src/image/ImageAnnotator.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
import { Unsubscribe } from 'redux';
22
import BaseAnnotator, { Options } from '../common/BaseAnnotator';
33
import PopupManager from '../popup/PopupManager';
4+
import { BoundingBoxHighlightManager } from '../boundingBoxHighlight';
45
import { centerDrawing, DrawingManager, isDrawing } from '../drawing';
56
import { centerRegion, isRegion, RegionCreationManager, RegionManager } from '../region';
67
import { CreatorStatus, getCreatorStatus } from '../store/creator';
7-
import { getAnnotation, getFileId, getIsCurrentFileVersion, getRotation } from '../store';
8+
import { getAnnotation, getFileId, getIsCurrentFileVersion, getRotation, getViewMode } from '../store';
89
import { getRotatedPosition } from '../utils/rotate';
910
import { Manager } from '../common/BaseManager';
1011
import { scrollToLocation } from '../utils/scroll';
1112
import './ImageAnnotator.scss';
1213

14+
import type { ViewMode } from '../store/options/types';
15+
1316
export const CSS_IS_DRAWING_CLASS = 'ba-is-drawing';
1417

1518
export default class ImageAnnotator extends BaseAnnotator {
1619
annotatedEl?: HTMLElement;
1720

1821
managers: Set<Manager> = new Set();
1922

23+
/** Tracks which view mode the current managers were created for. Used to destroy/recreate when switching. */
24+
private managersViewMode: ViewMode | null = null;
25+
2026
storeHandler?: Unsubscribe;
2127

2228
constructor(options: Options) {
@@ -41,10 +47,18 @@ export default class ImageAnnotator extends BaseAnnotator {
4147
return this.containerEl?.querySelector('.bp-image');
4248
}
4349

50+
/** Destroys all managers and clears the cache. Call when switching view modes. */
51+
private clearManagers(): void {
52+
this.managers.forEach(manager => manager.destroy());
53+
this.managers.clear();
54+
this.managersViewMode = null;
55+
}
56+
4457
getManagers(parentEl: HTMLElement, referenceEl: HTMLElement): Set<Manager> {
4558
const fileId = getFileId(this.store.getState());
4659
const isCurrentFileVersion = getIsCurrentFileVersion(this.store.getState());
4760
const resinTags = { fileid: fileId, iscurrent: isCurrentFileVersion };
61+
const viewMode = getViewMode(this.store.getState());
4862

4963
this.managers.forEach(manager => {
5064
if (!manager.exists(parentEl)) {
@@ -54,6 +68,11 @@ export default class ImageAnnotator extends BaseAnnotator {
5468
});
5569

5670
if (this.managers.size === 0) {
71+
if (viewMode === 'boundingBoxes') {
72+
this.managers.add(new BoundingBoxHighlightManager({ location: 1, referenceEl, resinTags }));
73+
return this.managers;
74+
}
75+
5776
this.managers.add(new PopupManager({ referenceEl, resinTags }));
5877
this.managers.add(new DrawingManager({ referenceEl, resinTags }));
5978
this.managers.add(new RegionManager({ referenceEl, resinTags }));
@@ -84,11 +103,17 @@ export default class ImageAnnotator extends BaseAnnotator {
84103
render(): void {
85104
const referenceEl = this.getReference();
86105
const rotation = getRotation(this.store.getState()) || 0;
106+
const viewMode = getViewMode(this.store.getState());
87107

88108
if (!this.annotatedEl || !referenceEl) {
89109
return;
90110
}
91111

112+
if (this.managersViewMode !== null && this.managersViewMode !== viewMode) {
113+
this.clearManagers();
114+
}
115+
this.managersViewMode = viewMode;
116+
92117
this.getManagers(this.annotatedEl, referenceEl).forEach(manager => {
93118
manager.style({
94119
height: `${referenceEl.offsetHeight}px`,
@@ -107,6 +132,27 @@ export default class ImageAnnotator extends BaseAnnotator {
107132
this.postRender();
108133
}
109134

135+
public postRender(): void {
136+
// DeselectManager is only needed for annotation creation; skip in bounding box mode.
137+
if (getViewMode(this.store.getState()) === 'boundingBoxes') {
138+
if (this.deselectManager) {
139+
this.deselectManager.destroy();
140+
this.deselectManager = null;
141+
}
142+
return;
143+
}
144+
super.postRender();
145+
}
146+
147+
protected getScrollReferenceForHighlight(): HTMLElement | null | undefined {
148+
return this.getReference();
149+
}
150+
151+
protected getAdjustedScrollOffsets(offsets: { x: number; y: number }): { x: number; y: number } {
152+
const rotation = getRotation(this.store.getState()) || 0;
153+
return getRotatedPosition(offsets, rotation);
154+
}
155+
110156
scrollToAnnotation(annotationId: string | null): void {
111157
if (!annotationId) {
112158
return;

src/image/__tests__/ImageAnnotator-test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import ImageAnnotator, { CSS_IS_DRAWING_CLASS } from '../ImageAnnotator';
44
import PopupManager from '../../popup/PopupManager';
55
import RegionCreationManager from '../../region/RegionCreationManager';
66
import RegionManager from '../../region/RegionManager';
7+
import { BoundingBoxHighlightManager } from '../../boundingBoxHighlight';
78
import { Annotation } from '../../@types';
89
import { CreatorStatus, fetchAnnotationsAction, setStatusAction } from '../../store';
10+
import { setViewModeAction } from '../../store/options';
11+
import { setBoundingBoxHighlightsAction } from '../../store/boundingBoxHighlights';
912
import { annotations as drawings } from '../../drawing/__mocks__/drawingData';
1013
import { annotations as regions } from '../../region/__mocks__/data';
1114
import { scrollToLocation } from '../../utils/scroll';
@@ -14,6 +17,7 @@ jest.mock('../../common/DeselectManager');
1417
jest.mock('../../popup/PopupManager');
1518
jest.mock('../../region/RegionCreationManager');
1619
jest.mock('../../region/RegionManager');
20+
jest.mock('../../boundingBoxHighlight');
1721
jest.mock('../../utils/scroll');
1822

1923
describe('ImageAnnotator', () => {
@@ -123,6 +127,16 @@ describe('ImageAnnotator', () => {
123127
expect(mockManager.destroy).not.toHaveBeenCalled();
124128
expect(managers.values().next().value).toEqual(mockManager);
125129
});
130+
131+
test('should create only BoundingBoxHighlightManager when viewMode is boundingBoxes', () => {
132+
annotator.store.dispatch(setViewModeAction('boundingBoxes'));
133+
134+
const managers = annotator.getManagers(getParent(), getImage());
135+
const managerArray = Array.from(managers);
136+
137+
expect(managerArray).toHaveLength(1);
138+
expect(managerArray[0]).toBeInstanceOf(BoundingBoxHighlightManager);
139+
});
126140
});
127141

128142
describe('getReference()', () => {
@@ -227,6 +241,97 @@ describe('ImageAnnotator', () => {
227241
expect(annotator.deselectManager).toBeInstanceOf(DeselectManager);
228242
expect(annotator.deselectManager!.render).toHaveBeenCalled();
229243
});
244+
245+
test('should not instantiate DeselectManager when in bounding box mode', () => {
246+
annotator.annotatedEl = getParent();
247+
annotator.store.dispatch(setViewModeAction('boundingBoxes'));
248+
249+
annotator.render();
250+
251+
expect(annotator.deselectManager).toBeNull();
252+
});
253+
254+
test('should destroy existing DeselectManager when switching to bounding box mode', () => {
255+
annotator.annotatedEl = getParent();
256+
257+
annotator.render();
258+
expect(annotator.deselectManager).toBeInstanceOf(DeselectManager);
259+
260+
const destroySpy = annotator.deselectManager!.destroy as jest.Mock;
261+
262+
annotator.store.dispatch(setViewModeAction('boundingBoxes'));
263+
annotator.render();
264+
265+
expect(destroySpy).toHaveBeenCalled();
266+
expect(annotator.deselectManager).toBeNull();
267+
});
268+
269+
test('should clear all managers when view mode changes', () => {
270+
annotator.annotatedEl = getParent();
271+
272+
const destroySpy = jest.fn();
273+
const existingSpy = jest.fn().mockReturnValue(true);
274+
const mgr = { destroy: destroySpy, exists: existingSpy, render: jest.fn(), style: jest.fn() };
275+
annotator.managers = new Set([mgr]);
276+
277+
// First render keeps managers because exists() returns true
278+
annotator.render();
279+
280+
expect(annotator.managers.size).toBe(1);
281+
282+
annotator.store.dispatch(setViewModeAction('boundingBoxes'));
283+
annotator.render();
284+
285+
expect(destroySpy).toHaveBeenCalled();
286+
});
287+
});
288+
289+
describe('scrollToBoundingBoxHighlight()', () => {
290+
const boundingBoxes = [
291+
{ id: 'box-1', x: 10, y: 20, width: 30, height: 40, pageNumber: 1 },
292+
{ id: 'box-2', x: 50, y: 60, width: 70, height: 80, pageNumber: 1 },
293+
];
294+
295+
beforeEach(() => {
296+
annotator.annotatedEl = getParent();
297+
annotator.store.dispatch(setBoundingBoxHighlightsAction(boundingBoxes));
298+
});
299+
300+
test('should call scrollToLocation with center offsets and smooth scrolling', () => {
301+
jest.spyOn(annotator, 'getReference').mockImplementation(getImage);
302+
303+
annotator.scrollToBoundingBoxHighlight('box-1');
304+
305+
expect(scrollToLocation).toHaveBeenCalledWith(getParent(), getImage(), {
306+
offsets: { x: 25, y: 40 },
307+
smooth: true,
308+
});
309+
});
310+
311+
test('should do nothing if highlightId is null', () => {
312+
annotator.scrollToBoundingBoxHighlight(null);
313+
expect(scrollToLocation).not.toHaveBeenCalled();
314+
});
315+
316+
test('should do nothing if annotatedEl is not defined', () => {
317+
annotator.annotatedEl = undefined;
318+
annotator.scrollToBoundingBoxHighlight('box-1');
319+
expect(scrollToLocation).not.toHaveBeenCalled();
320+
});
321+
322+
test('should do nothing if highlight is not found in the store', () => {
323+
jest.spyOn(annotator, 'getReference').mockImplementation(getImage);
324+
325+
annotator.scrollToBoundingBoxHighlight('nonexistent');
326+
expect(scrollToLocation).not.toHaveBeenCalled();
327+
});
328+
329+
test('should do nothing if reference element is not defined', () => {
330+
jest.spyOn(annotator, 'getReference').mockReturnValue(undefined);
331+
332+
annotator.scrollToBoundingBoxHighlight('box-1');
333+
expect(scrollToLocation).not.toHaveBeenCalled();
334+
});
230335
});
231336

232337
describe('scrollToAnnotation()', () => {

0 commit comments

Comments
 (0)