diff --git a/src/lib/AnnotationControls.ts b/src/lib/AnnotationControls.ts index 0faff5dfd..5bbd68cc3 100644 --- a/src/lib/AnnotationControls.ts +++ b/src/lib/AnnotationControls.ts @@ -19,6 +19,7 @@ export enum AnnotationMode { export type ClickHandler = ({ event, mode }: { event: MouseEvent; mode: AnnotationMode }) => void; export type Options = { fileId: string; + initialMode?: AnnotationMode; onClick?: ClickHandler; onEscape?: () => void; showHighlightText: boolean; @@ -194,7 +195,13 @@ export default class AnnotationControls { /** * Initialize the annotation controls with options. */ - public init({ fileId, onEscape = noop, onClick = noop, showHighlightText = false }: Options): void { + public init({ + fileId, + initialMode = AnnotationMode.NONE, + onEscape = noop, + onClick = noop, + showHighlightText = false, + }: Options): void { if (this.hasInit) { return; } @@ -210,5 +217,7 @@ export default class AnnotationControls { document.addEventListener('keydown', this.handleKeyDown); this.hasInit = true; + + this.setMode(initialMode); } } diff --git a/src/lib/Preview.js b/src/lib/Preview.js index dc2204049..3a54f65ca 100644 --- a/src/lib/Preview.js +++ b/src/lib/Preview.js @@ -932,6 +932,8 @@ class Preview extends EventEmitter { this.options.enableAnnotationsDiscoverability = !!options.enableAnnotationsDiscoverability; + this.options.enableAnnotationsImageDiscoverability = !!options.enableAnnotationsImageDiscoverability; + // Enable or disable hotkeys this.options.useHotkeys = options.useHotkeys !== false; diff --git a/src/lib/__tests__/AnnotationControls-test.js b/src/lib/__tests__/AnnotationControls-test.js index 894abb5e2..ca02efb62 100644 --- a/src/lib/__tests__/AnnotationControls-test.js +++ b/src/lib/__tests__/AnnotationControls-test.js @@ -123,6 +123,12 @@ describe('lib/AnnotationControls', () => { expect(annotationControls.addButton).not.toBeCalled(); expect(document.addEventListener).not.toBeCalled(); }); + + test('should set annotationControls currentMode to be REGION', () => { + annotationControls.init({ initialMode: AnnotationMode.REGION }); + + expect(annotationControls.currentMode).toBe(AnnotationMode.REGION); + }); }); describe('handleKeyDown', () => { diff --git a/src/lib/viewers/image/ImageViewer.js b/src/lib/viewers/image/ImageViewer.js index b713ae5c3..07cd7854a 100644 --- a/src/lib/viewers/image/ImageViewer.js +++ b/src/lib/viewers/image/ImageViewer.js @@ -1,6 +1,6 @@ -import AnnotationControls from '../../AnnotationControls'; import ImageBaseViewer from './ImageBaseViewer'; -import { AnnotationInput } from '../../AnnotationControlsFSM'; +import AnnotationControls, { AnnotationMode } from '../../AnnotationControls'; +import AnnotationControlsFSM, { AnnotationInput, AnnotationState, stateModeMap } from '../../AnnotationControlsFSM'; import { CLASS_INVISIBLE } from '../../constants'; import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, ICON_ROTATE_LEFT } from '../../icons/icons'; import './Image.scss'; @@ -19,12 +19,30 @@ class ImageViewer extends ImageBaseViewer { this.handleAnnotationControlsClick = this.handleAnnotationControlsClick.bind(this); this.handleAssetAndRepLoad = this.handleAssetAndRepLoad.bind(this); this.handleImageDownloadError = this.handleImageDownloadError.bind(this); + this.getViewportDimensions = this.getViewportDimensions.bind(this); + this.handleZoomEvent = this.handleZoomEvent.bind(this); + this.annotationControlsFSM = new AnnotationControlsFSM( + this.options.enableAnnotationsImageDiscoverability ? AnnotationState.REGION_TEMP : AnnotationState.NONE, + ); if (this.isMobile) { this.handleOrientationChange = this.handleOrientationChange.bind(this); } } + /** + * [destructor] + * + * @return {void} + */ + destroy() { + if (this.options.enableAnnotationsImageDiscoverability) { + this.removeListener('zoom', this.handleZoomEvent); + } + + super.destroy(); + } + /** * @inheritdoc */ @@ -46,6 +64,10 @@ class ImageViewer extends ImageBaseViewer { this.imageEl.classList.add(CLASS_INVISIBLE); this.currentRotationAngle = 0; + + if (this.options.enableAnnotationsImageDiscoverability) { + this.addListener('zoom', this.handleZoomEvent); + } } /** @@ -167,6 +189,41 @@ class ImageViewer extends ImageBaseViewer { }; } + /** + * Gets the viewport dimensions. + * + * @return {Object} the width & height of the viewport + */ + getViewportDimensions() { + return { + width: this.wrapperEl.clientWidth - 2 * IMAGE_PADDING, + height: this.wrapperEl.clientHeight - 2 * IMAGE_PADDING, + }; + } + + /** + * Sets mode to be AnnotationMode.NONE if the zoomed image overflows the viewport. + * + * @return {void} + */ + handleZoomEvent({ newScale, type }) { + const [width, height] = newScale; + + // type is undefined on initial render, we only want below logic to execute on user initiated actions + if (!type) { + return; + } + + const viewport = this.getViewportDimensions(); + + // We only set AnnotationMode to be NONE if the image overflows the viewport and the state is not explicit region creation + const currentState = this.annotationControlsFSM.getState(); + if (currentState === AnnotationState.REGION_TEMP && (width > viewport.width || height > viewport.height)) { + this.annotator.toggleAnnotationMode(AnnotationMode.NONE); + this.processAnnotationModeChange(this.annotationControlsFSM.transition(AnnotationInput.CANCEL)); + } + } + /** * Handles zoom * @@ -202,10 +259,8 @@ class ImageViewer extends ImageBaseViewer { ({ width, height } = this.getTransformWidthAndHeight(origWidth, origHeight, isRotated)); const modifyWidthInsteadOfHeight = width >= height; - const viewport = { - width: this.wrapperEl.clientWidth - 2 * IMAGE_PADDING, - height: this.wrapperEl.clientHeight - 2 * IMAGE_PADDING, - }; + const viewport = this.getViewportDimensions(); + // If the image is overflowing the viewport, figure out by how much // Then take that aspect that reduces the image the maximum (hence min ratio) to fit both width and height if (width > viewport.width || height > viewport.height) { @@ -251,6 +306,7 @@ class ImageViewer extends ImageBaseViewer { newScale: [newWidth || width, newHeight || height], canZoomIn: true, canZoomOut: true, + type, }); } @@ -299,6 +355,9 @@ class ImageViewer extends ImageBaseViewer { this.annotationControls = new AnnotationControls(this.controls); this.annotationControls.init({ fileId: this.options.file.id, + initialMode: this.options.enableAnnotationsImageDiscoverability + ? stateModeMap[AnnotationState.REGION_TEMP] + : stateModeMap[AnnotationState.NONE], onClick: this.handleAnnotationControlsClick, onEscape: this.handleAnnotationControlsEscape, }); diff --git a/src/lib/viewers/image/__tests__/ImageViewer-test.js b/src/lib/viewers/image/__tests__/ImageViewer-test.js index 404e1ffa3..4cd778f1f 100644 --- a/src/lib/viewers/image/__tests__/ImageViewer-test.js +++ b/src/lib/viewers/image/__tests__/ImageViewer-test.js @@ -1,5 +1,6 @@ /* eslint-disable no-unused-expressions */ import AnnotationControls, { AnnotationMode } from '../../../AnnotationControls'; +import AnnotationControlsFSM, { AnnotationState, stateModeMap } from '../../../AnnotationControlsFSM'; import ImageViewer from '../ImageViewer'; import BaseViewer from '../../BaseViewer'; import Browser from '../../../Browser'; @@ -52,11 +53,45 @@ describe('lib/viewers/image/ImageViewer', () => { stubs = {}; }); + describe('destroy()', () => { + test.each` + enableAnnotationsImageDiscoverability | numberOfCalls + ${true} | ${1} + ${false} | ${0} + `( + 'should call removeListener $numberOfCalls times if enableAnnotationsImageDiscoverability is $enableAnnotationsImageDiscoverability', + ({ enableAnnotationsImageDiscoverability, numberOfCalls }) => { + image.options.enableAnnotationsImageDiscoverability = enableAnnotationsImageDiscoverability; + jest.spyOn(image, 'removeListener'); + + image.destroy(); + + expect(image.removeListener).toBeCalledTimes(numberOfCalls); + }, + ); + }); + describe('setup()', () => { test('should set up layout', () => { expect(image.wrapperEl).toHaveClass('bp-image'); expect(image.imageEl).toHaveClass('bp-is-invisible'); }); + + test.each` + enableAnnotationsImageDiscoverability | numberOfCalls + ${true} | ${1} + ${false} | ${0} + `( + 'should call addListener $numberOfCalls times if enableAnnotationsImageDiscoverability is $enableAnnotationsImageDiscoverability', + ({ enableAnnotationsImageDiscoverability, numberOfCalls }) => { + image.options.enableAnnotationsImageDiscoverability = enableAnnotationsImageDiscoverability; + jest.spyOn(image, 'addListener'); + + image.setup(); + + expect(image.addListener).toBeCalledTimes(numberOfCalls); + }, + ); }); describe('load()', () => { @@ -351,19 +386,28 @@ describe('lib/viewers/image/ImageViewer', () => { expect(image.annotationControls).toBeInstanceOf(AnnotationControls); }); - test('should call annotations controls init with callbacks', () => { - jest.spyOn(image, 'areNewAnnotationsEnabled').mockReturnValue(true); - jest.spyOn(image, 'hasAnnotationCreatePermission').mockReturnValue(true); - jest.spyOn(AnnotationControls.prototype, 'init').mockImplementation(); - - image.loadUI(); - - expect(AnnotationControls.prototype.init).toBeCalledWith({ - fileId: image.options.file.id, - onClick: image.handleAnnotationControlsClick, - onEscape: image.handleAnnotationControlsEscape, - }); - }); + test.each` + enableAnnotationsImageDiscoverability | initialMode + ${false} | ${stateModeMap[AnnotationState.NONE]} + ${true} | ${stateModeMap[AnnotationState.REGION_TEMP]} + `( + 'should call annotation controls init with $initialMode when enableAnnotationsImageDiscoverability is $enableAnnotationsImageDiscoverability', + ({ enableAnnotationsImageDiscoverability, initialMode }) => { + image.options.enableAnnotationsImageDiscoverability = enableAnnotationsImageDiscoverability; + jest.spyOn(image, 'areNewAnnotationsEnabled').mockReturnValue(true); + jest.spyOn(image, 'hasAnnotationCreatePermission').mockReturnValue(true); + jest.spyOn(AnnotationControls.prototype, 'init').mockImplementation(); + + image.loadUI(); + + expect(AnnotationControls.prototype.init).toBeCalledWith({ + fileId: image.options.file.id, + initialMode, + onClick: image.handleAnnotationControlsClick, + onEscape: image.handleAnnotationControlsEscape, + }); + }, + ); }); describe('isRotated()', () => { @@ -597,4 +641,65 @@ describe('lib/viewers/image/ImageViewer', () => { expect(image.processAnnotationModeChange).toBeCalledWith(AnnotationMode.NONE); }); }); + + describe('getViewportDimensions', () => { + test('should return width and height', () => { + image.wrapperEl = document.createElement('img'); + Object.defineProperty(image.wrapperEl, 'clientWidth', { value: 100 }); + Object.defineProperty(image.wrapperEl, 'clientHeight', { value: 100 }); + + const result = image.getViewportDimensions(); + + expect(result).toEqual({ width: 70, height: 70 }); + }); + }); + + describe('handleZoomEvent', () => { + beforeEach(() => { + image.wrapperEl = document.createElement('img'); + Object.defineProperty(image.wrapperEl, 'clientWidth', { value: 100 }); + Object.defineProperty(image.wrapperEl, 'clientHeight', { value: 100 }); + jest.spyOn(image, 'processAnnotationModeChange'); + }); + + test('should not call getViewportDimensions if type is undefined', () => { + const width = 100; + const height = 100; + image.getViewportDimensions = jest.fn(); + + image.handleZoomEvent({ newScale: [width, height], type: undefined }); + + expect(image.getViewportDimensions).not.toHaveBeenCalled(); + }); + + test.each` + currentState | height | width + ${AnnotationState.REGION} | ${110} | ${110} + ${AnnotationState.REGION} | ${60} | ${60} + ${AnnotationState.REGION_TEMP} | ${60} | ${60} + `( + 'should not call processAnnotationModeChange when height is $height and width is $width and currentState is $currentState', + ({ currentState, height, width }) => { + image.annotationControlsFSM = new AnnotationControlsFSM(currentState); + + image.handleZoomEvent({ newScale: [width, height], type: 'in' }); + + expect(image.processAnnotationModeChange).not.toHaveBeenCalled(); + }, + ); + + test('should call processAnnotationModeChange and toggleAnnotationMode if image does overflow the viewport and currentState is REGION_TEMP', () => { + const width = 110; + const height = 110; + image.annotator = { + toggleAnnotationMode: jest.fn(), + }; + image.annotationControlsFSM = new AnnotationControlsFSM(AnnotationState.REGION_TEMP); + + image.handleZoomEvent({ newScale: [width, height], type: 'in' }); + + expect(image.processAnnotationModeChange).toHaveBeenCalled(); + expect(image.annotator.toggleAnnotationMode).toHaveBeenCalled(); + }); + }); });