Skip to content

Commit

Permalink
feat(discoverability): add image cursor experience (#1270)
Browse files Browse the repository at this point in the history
* feat(discoverability): add image cursor experience
  • Loading branch information
ChenCodes authored Oct 20, 2020
1 parent ad66320 commit b42768a
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 20 deletions.
11 changes: 10 additions & 1 deletion src/lib/AnnotationControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -210,5 +217,7 @@ export default class AnnotationControls {
document.addEventListener('keydown', this.handleKeyDown);

this.hasInit = true;

this.setMode(initialMode);
}
}
2 changes: 2 additions & 0 deletions src/lib/Preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
6 changes: 6 additions & 0 deletions src/lib/__tests__/AnnotationControls-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
71 changes: 65 additions & 6 deletions src/lib/viewers/image/ImageViewer.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
*/
Expand All @@ -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);
}
}

/**
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -251,6 +306,7 @@ class ImageViewer extends ImageBaseViewer {
newScale: [newWidth || width, newHeight || height],
canZoomIn: true,
canZoomOut: true,
type,
});
}

Expand Down Expand Up @@ -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,
});
Expand Down
131 changes: 118 additions & 13 deletions src/lib/viewers/image/__tests__/ImageViewer-test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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();
});
});
});

0 comments on commit b42768a

Please sign in to comment.