Skip to content

Commit

Permalink
feat(controls): Create react core controls for image viewers (#1285)
Browse files Browse the repository at this point in the history
  • Loading branch information
jstoffan authored Nov 5, 2020
1 parent c6bdc5f commit ede8e42
Show file tree
Hide file tree
Showing 10 changed files with 217 additions and 20 deletions.
12 changes: 11 additions & 1 deletion src/lib/viewers/image/ImageBaseViewer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import BaseViewer from '../BaseViewer';
import Browser from '../../Browser';
import Controls from '../../Controls';
import ControlsRoot from '../controls/controls-root';
import PreviewError from '../../PreviewError';
import ZoomControls from '../../ZoomControls';

Expand Down Expand Up @@ -78,7 +79,12 @@ class ImageBaseViewer extends BaseViewer {
const loadOriginalDimensions = this.setOriginalImageSize(this.imageEl);
loadOriginalDimensions.then(() => {
this.zoom();
this.loadUI();

if (this.options.useReactControls) {
this.loadUIReact();
} else {
this.loadUI();
}

this.imageEl.classList.remove(CLASS_INVISIBLE);
this.loaded = true;
Expand Down Expand Up @@ -203,6 +209,10 @@ class ImageBaseViewer extends BaseViewer {
this.zoomControls.init(this.scale, { onZoomIn: this.zoomIn, onZoomOut: this.zoomOut });
}

loadUIReact() {
this.controls = new ControlsRoot({ containerEl: this.containerEl });
}

/**
* Sets the original image width and height on the img element. Can be removed when
* naturalHeight and naturalWidth attributes work correctly in IE 11.
Expand Down
17 changes: 17 additions & 0 deletions src/lib/viewers/image/ImageControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import ControlsBar from '../controls/controls-bar';
import FullscreenToggle, { Props as FullscreenToggleProps } from '../controls/fullscreen';
import ZoomControls, { Props as ZoomControlsProps } from '../controls/zoom';

export type Props = FullscreenToggleProps & ZoomControlsProps;

export default function ImageControls({ onFullscreenToggle, onZoomIn, onZoomOut, scale }: Props): JSX.Element {
return (
<ControlsBar>
<ZoomControls onZoomIn={onZoomIn} onZoomOut={onZoomOut} scale={scale} />
{/* TODO: RotateControl */}
<FullscreenToggle onFullscreenToggle={onFullscreenToggle} />
{/* TODO: AnnotationControls (separate group) */}
</ControlsBar>
);
}
31 changes: 27 additions & 4 deletions src/lib/viewers/image/ImageViewer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import ImageBaseViewer from './ImageBaseViewer';
import React from 'react';
import AnnotationControls, { AnnotationMode } from '../../AnnotationControls';
import AnnotationControlsFSM, { AnnotationInput, AnnotationState, stateModeMap } from '../../AnnotationControlsFSM';
import ImageBaseViewer from './ImageBaseViewer';
import ImageControls from './ImageControls';
import { CLASS_INVISIBLE, DISCOVERABILITY_ATTRIBUTE } from '../../constants';
import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, ICON_ROTATE_LEFT } from '../../icons/icons';
import './Image.scss';
Expand Down Expand Up @@ -338,9 +340,8 @@ class ImageViewer extends ImageBaseViewer {
? width / this.imageEl.getAttribute('originalWidth')
: height / this.imageEl.getAttribute('originalHeight');
this.rotationAngle = (this.currentRotationAngle % 3600) % 360;
if (this.zoomControls) {
this.zoomControls.setCurrentScale(this.scale);
}
this.renderUI();

this.emit('scale', {
scale: this.scale,
rotationAngle: this.rotationAngle,
Expand Down Expand Up @@ -378,6 +379,28 @@ class ImageViewer extends ImageBaseViewer {
}
}

loadUIReact() {
super.loadUIReact();
this.renderUI();
}

renderUI() {
if (this.zoomControls) {
this.zoomControls.setCurrentScale(this.scale);
}

if (this.controls && this.options.useReactControls) {
this.controls.render(
<ImageControls
onFullscreenToggle={this.toggleFullscreen}
onZoomIn={this.zoomIn}
onZoomOut={this.zoomOut}
scale={this.scale}
/>,
);
}
}

/**
* Determines if Image file has been rotated 90 or 270 degrees to the left
*
Expand Down
16 changes: 16 additions & 0 deletions src/lib/viewers/image/MultiImageControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';
import ControlsBar from '../controls/controls-bar';
import FullscreenToggle, { Props as FullscreenToggleProps } from '../controls/fullscreen';
import ZoomControls, { Props as ZoomControlsProps } from '../controls/zoom';

export type Props = FullscreenToggleProps & ZoomControlsProps;

export default function MultiImageControls({ onFullscreenToggle, onZoomIn, onZoomOut, scale }: Props): JSX.Element {
return (
<ControlsBar>
<ZoomControls onZoomIn={onZoomIn} onZoomOut={onZoomOut} scale={scale} />
{/* TODO: PageControls */}
<FullscreenToggle onFullscreenToggle={onFullscreenToggle} />
</ControlsBar>
);
}
31 changes: 26 additions & 5 deletions src/lib/viewers/image/MultiImageViewer.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from 'react';
import ImageBaseViewer from './ImageBaseViewer';
import MultiImageControls from './MultiImageControls';
import PageControls from '../../PageControls';
import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../icons/icons';
import { CLASS_INVISIBLE, CLASS_MULTI_IMAGE_PAGE, CLASS_IS_SCROLLABLE } from '../../constants';
import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../icons/icons';
import { pageNumberFromScroll } from '../../util';

import './MultiImage.scss';

const PADDING_BUFFER = 100;
Expand Down Expand Up @@ -246,9 +247,7 @@ class MultiImageViewer extends ImageBaseViewer {
// Grab the first page image dimensions
const imageEl = this.singleImageEls[0];
this.scale = width ? width / imageEl.naturalWidth : height / imageEl.naturalHeight;
if (this.zoomControls) {
this.zoomControls.setCurrentScale(this.scale);
}
this.renderUI();
this.emit('scale', { scale: this.scale });
}

Expand All @@ -265,6 +264,28 @@ class MultiImageViewer extends ImageBaseViewer {
this.bindPageControlListeners();
}

loadUIReact() {
super.loadUIReact();
this.renderUI();
}

renderUI() {
if (this.zoomControls) {
this.zoomControls.setCurrentScale(this.scale);
}

if (this.controls && this.options.useReactControls) {
this.controls.render(
<MultiImageControls
onFullscreenToggle={this.toggleFullscreen}
onZoomIn={this.zoomIn}
onZoomOut={this.zoomOut}
scale={this.scale}
/>,
);
}
}

/**
* Binds listeners for the page and the rest of the document controls.
*
Expand Down
24 changes: 22 additions & 2 deletions src/lib/viewers/image/__tests__/ImageBaseViewer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ describe('lib/viewers/image/ImageBaseViewer', () => {
imageBase.loaded = false;
stubs.zoom = jest.spyOn(imageBase, 'zoom');
stubs.loadUI = jest.spyOn(imageBase, 'loadUI');
stubs.loadUIReact = jest.spyOn(imageBase, 'loadUIReact');
stubs.setOriginalImageSize = jest.spyOn(imageBase, 'setOriginalImageSize');
imageBase.options = {
file: {
Expand All @@ -543,21 +544,40 @@ describe('lib/viewers/image/ImageBaseViewer', () => {
expect(stubs.zoom).not.toBeCalled();
expect(stubs.setOriginalImageSize).not.toBeCalled();
expect(stubs.loadUI).not.toBeCalled();
expect(stubs.loadUIReact).not.toBeCalled();
});

test('should load UI if not destroyed', done => {
stubs.setOriginalImageSize.mockResolvedValue(undefined);

imageBase.on(VIEWER_EVENT.load, () => {
expect(imageBase.loaded).toBe(true);
expect(stubs.zoom).toBeCalled();
expect(stubs.loadUI).toBeCalled();
expect(stubs.loadUIReact).not.toBeCalled();
done();
});
stubs.setOriginalImageSize.mockResolvedValue(undefined);
imageBase.destroyed = false;

imageBase.destroyed = false;
imageBase.finishLoading();

expect(stubs.setOriginalImageSize).toBeCalled();
});

test('should load react UI if option is provided', done => {
stubs.setOriginalImageSize.mockResolvedValue(undefined);

imageBase.on(VIEWER_EVENT.load, () => {
expect(imageBase.loaded).toBe(true);
expect(stubs.zoom).toBeCalled();
expect(stubs.loadUI).not.toBeCalled();
expect(stubs.loadUIReact).toBeCalled();
done();
});
imageBase.destroyed = false;
imageBase.options.useReactControls = true;
imageBase.finishLoading('', true);
});
});

describe('disableViewerControls()', () => {
Expand Down
24 changes: 24 additions & 0 deletions src/lib/viewers/image/__tests__/ImageControls-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { shallow } from 'enzyme';
import ControlsBar from '../../controls/controls-bar';
import FullscreenToggle from '../../controls/fullscreen';
import ImageControls from '../ImageControls';
import ZoomControls from '../../controls/zoom';

describe('ImageControls', () => {
describe('render', () => {
test('should return a valid wrapper', () => {
const onToggle = jest.fn();
const onZoomIn = jest.fn();
const onZoomOut = jest.fn();
const wrapper = shallow(
<ImageControls onFullscreenToggle={onToggle} onZoomIn={onZoomIn} onZoomOut={onZoomOut} />,
);

expect(wrapper.exists(ControlsBar));
expect(wrapper.find(FullscreenToggle).prop('onFullscreenToggle')).toEqual(onToggle);
expect(wrapper.find(ZoomControls).prop('onZoomIn')).toEqual(onZoomIn);
expect(wrapper.find(ZoomControls).prop('onZoomOut')).toEqual(onZoomOut);
});
});
});
25 changes: 23 additions & 2 deletions src/lib/viewers/image/__tests__/ImageViewer-test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/* eslint-disable no-unused-expressions */
import React from 'react';
import AnnotationControls, { AnnotationMode } from '../../../AnnotationControls';
import AnnotationControlsFSM, { AnnotationState, stateModeMap } from '../../../AnnotationControlsFSM';
import ImageViewer from '../ImageViewer';
import BaseViewer from '../../BaseViewer';
import Browser from '../../../Browser';
import ControlsRoot from '../../controls/controls-root';
import ImageControls from '../ImageControls';
import ImageViewer from '../ImageViewer';

jest.mock('../../controls/controls-root');

const imageUrl =
'data:image/png;base64,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';
Expand Down Expand Up @@ -411,6 +415,23 @@ describe('lib/viewers/image/ImageViewer', () => {
);
});

describe('loadUIReact()', () => {
test('should create controls root and render the react controls', () => {
image.options.useReactControls = true;
image.loadUIReact();

expect(image.controls).toBeInstanceOf(ControlsRoot);
expect(image.controls.render).toBeCalledWith(
<ImageControls
onFullscreenToggle={image.toggleFullscreen}
onZoomIn={image.zoomIn}
onZoomOut={image.zoomOut}
scale={1}
/>,
);
});
});

describe('isRotated()', () => {
test('should return false if image is not rotated', () => {
const result = image.isRotated();
Expand Down
24 changes: 24 additions & 0 deletions src/lib/viewers/image/__tests__/MultiImageControls-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { shallow } from 'enzyme';
import ControlsBar from '../../controls/controls-bar';
import FullscreenToggle from '../../controls/fullscreen';
import MultiImageControls from '../MultiImageControls';
import ZoomControls from '../../controls/zoom';

describe('MultiImageControls', () => {
describe('render', () => {
test('should return a valid wrapper', () => {
const onToggle = jest.fn();
const onZoomIn = jest.fn();
const onZoomOut = jest.fn();
const wrapper = shallow(
<MultiImageControls onFullscreenToggle={onToggle} onZoomIn={onZoomIn} onZoomOut={onZoomOut} />,
);

expect(wrapper.exists(ControlsBar));
expect(wrapper.find(FullscreenToggle).prop('onFullscreenToggle')).toEqual(onToggle);
expect(wrapper.find(ZoomControls).prop('onZoomIn')).toEqual(onZoomIn);
expect(wrapper.find(ZoomControls).prop('onZoomOut')).toEqual(onZoomOut);
});
});
});
33 changes: 27 additions & 6 deletions src/lib/viewers/image/__tests__/MultiImageViewer-test.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/* eslint-disable no-unused-expressions */
import React from 'react';
import * as util from '../../../util';
import BaseViewer from '../../BaseViewer';
import Browser from '../../../Browser';
import ControlsRoot from '../../controls/controls-root';
import ImageBaseViewer from '../ImageBaseViewer';
import MultiImageControls from '../MultiImageControls';
import MultiImageViewer from '../MultiImageViewer';
import PageControls from '../../../PageControls';
import ZoomControls from '../../../ZoomControls';
import fullscreen from '../../../Fullscreen';
import { CLASS_MULTI_IMAGE_PAGE } from '../../../constants';
import BaseViewer from '../../BaseViewer';
import ImageBaseViewer from '../ImageBaseViewer';
import Browser from '../../../Browser';
import * as util from '../../../util';
import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../../icons/icons';
import ZoomControls from '../../../ZoomControls';

jest.mock('../../controls/controls-root');

const CLASS_INVISIBLE = 'bp-is-invisible';

Expand Down Expand Up @@ -377,6 +381,23 @@ describe('lib/viewers/image/MultiImageViewer', () => {
});
});

describe('loadUIReact()', () => {
test('should create controls root and render the react controls', () => {
multiImage.options.useReactControls = true;
multiImage.loadUIReact();

expect(multiImage.controls).toBeInstanceOf(ControlsRoot);
expect(multiImage.controls.render).toBeCalledWith(
<MultiImageControls
onFullscreenToggle={multiImage.toggleFullscreen}
onZoomIn={multiImage.zoomIn}
onZoomOut={multiImage.zoomOut}
scale={1}
/>,
);
});
});

describe('bindPageControlListeners()', () => {
beforeEach(() => {
multiImage.currentPageNumber = 1;
Expand Down

0 comments on commit ede8e42

Please sign in to comment.