diff --git a/package.json b/package.json index e3852409d..d10ad863e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@commitlint/config-conventional": "^8.2.0", "@commitlint/travis-cli": "^8.2.0", "@testing-library/jest-dom": "^5.11.4", + "@types/classnames": "^2.2.11", "@types/enzyme": "^3.10.8", "@types/lodash": "^4.14.149", "@types/react": "^16.9.0", @@ -35,6 +36,7 @@ "box-annotations": "^2.3.0", "box-ui-elements": "^12.0.0-beta.10", "chai": "^4.2.0", + "classnames": "^2.2.6", "conventional-changelog-cli": "^2.0.28", "conventional-github-releaser": "^3.1.3", "create-react-class": "^15.6.2", diff --git a/src/lib/AnnotationControlsFSM.ts b/src/lib/AnnotationControlsFSM.ts index 3ff6a1d86..b6ed824c9 100644 --- a/src/lib/AnnotationControlsFSM.ts +++ b/src/lib/AnnotationControlsFSM.ts @@ -49,6 +49,8 @@ export default class AnnotationControlsFSM { this.currentState = initialState; } + public getMode = (): AnnotationMode => stateModeMap[this.currentState]; + public getState = (): AnnotationState => this.currentState; public subscribe = (callback: Subscription): void => { diff --git a/src/lib/__tests__/AnnotationControlsFSM-test.js b/src/lib/__tests__/AnnotationControlsFSM-test.js index 697ff2f92..38c2ae8b6 100644 --- a/src/lib/__tests__/AnnotationControlsFSM-test.js +++ b/src/lib/__tests__/AnnotationControlsFSM-test.js @@ -46,6 +46,7 @@ describe('lib/AnnotationControlsFSM', () => { const annotationControlsFSM = new AnnotationControlsFSM(); expect(annotationControlsFSM.transition(input, mode)).toBe(output); + expect(annotationControlsFSM.getMode()).toBe(output); expect(annotationControlsFSM.getState()).toBe(nextState); }); }); @@ -56,6 +57,7 @@ describe('lib/AnnotationControlsFSM', () => { const annotationControlsFSM = new AnnotationControlsFSM(); expect(annotationControlsFSM.transition(input)).toBe(AnnotationMode.NONE); + expect(annotationControlsFSM.getMode()).toBe(AnnotationMode.NONE); expect(annotationControlsFSM.getState()).toBe(AnnotationState.NONE); }); }); @@ -65,6 +67,7 @@ describe('lib/AnnotationControlsFSM', () => { const annotationControlsFSM = new AnnotationControlsFSM(); expect(annotationControlsFSM.transition(AnnotationInput.RESET)).toEqual(AnnotationMode.NONE); + expect(annotationControlsFSM.getMode()).toBe(AnnotationMode.NONE); expect(annotationControlsFSM.getState()).toEqual(AnnotationState.NONE); }); }); @@ -112,6 +115,7 @@ describe('lib/AnnotationControlsFSM', () => { const annotationControlsFSM = new AnnotationControlsFSM(AnnotationState.HIGHLIGHT); expect(annotationControlsFSM.transition(input, mode)).toEqual(output); + expect(annotationControlsFSM.getMode()).toBe(output); expect(annotationControlsFSM.getState()).toEqual(output); }); }); @@ -145,6 +149,7 @@ describe('lib/AnnotationControlsFSM', () => { const annotationControlsFSM = new AnnotationControlsFSM(AnnotationState.REGION); expect(annotationControlsFSM.transition(input, mode)).toEqual(output); + expect(annotationControlsFSM.getMode()).toBe(output); expect(annotationControlsFSM.getState()).toEqual(output); }); }); @@ -227,6 +232,7 @@ describe('lib/AnnotationControlsFSM', () => { const annotationControlsFSM = new AnnotationControlsFSM(AnnotationState.HIGHLIGHT_TEMP); expect(annotationControlsFSM.transition(input, mode)).toEqual(output); + expect(annotationControlsFSM.getMode()).toBe(output); expect(annotationControlsFSM.getState()).toEqual(output); }); }); @@ -260,6 +266,7 @@ describe('lib/AnnotationControlsFSM', () => { const annotationControlsFSM = new AnnotationControlsFSM(AnnotationState.REGION_TEMP); expect(annotationControlsFSM.transition(input, mode)).toEqual(output); + expect(annotationControlsFSM.getMode()).toBe(output); expect(annotationControlsFSM.getState()).toEqual(output); }); }); diff --git a/src/lib/viewers/BaseViewer.js b/src/lib/viewers/BaseViewer.js index f2e1f375f..9533981b6 100644 --- a/src/lib/viewers/BaseViewer.js +++ b/src/lib/viewers/BaseViewer.js @@ -916,10 +916,18 @@ class BaseViewer extends EventEmitter { //-------------------------------------------------------------------------- disableAnnotationControls() { - if (this.annotator && this.annotationControls && this.areNewAnnotationsEnabled()) { + if (!this.areNewAnnotationsEnabled()) { + return; + } + + if (this.annotator) { this.annotator.toggleAnnotationMode(AnnotationMode.NONE); + } + + if (this.annotationControls) { this.annotationControls.toggle(false); } + this.processAnnotationModeChange(this.annotationControlsFSM.transition(AnnotationInput.RESET)); } @@ -1090,11 +1098,13 @@ class BaseViewer extends EventEmitter { * @param {AnnotationMode} mode Next annotation mode */ processAnnotationModeChange = mode => { - if (!this.annotationControls) { + if (!this.areNewAnnotationsEnabled()) { return; } - this.annotationControls.setMode(mode); + if (this.annotationControls) { + this.annotationControls.setMode(mode); + } if (this.containerEl) { // Remove all annotations create related classes diff --git a/src/lib/viewers/__tests__/BaseViewer-test.js b/src/lib/viewers/__tests__/BaseViewer-test.js index f450e0b59..ec16bcf9f 100644 --- a/src/lib/viewers/__tests__/BaseViewer-test.js +++ b/src/lib/viewers/__tests__/BaseViewer-test.js @@ -1776,13 +1776,14 @@ describe('lib/viewers/BaseViewer', () => { setMode: jest.fn(), }; base.containerEl = document.createElement('div'); + base.areNewAnnotationsEnabled = jest.fn().mockReturnValue(true); }); - test('should do nothing if no annotationControls', () => { + test('should do nothing if new annotations are not enabled', () => { jest.spyOn(base.containerEl.classList, 'add'); jest.spyOn(base.containerEl.classList, 'remove'); - base.annotationControls = undefined; + base.areNewAnnotationsEnabled.mockReturnValue(false); base.processAnnotationModeChange(AnnotationMode.REGION); expect(base.containerEl.classList.add).not.toBeCalled(); diff --git a/src/lib/viewers/controls/annotations/AnnotationsButton.scss b/src/lib/viewers/controls/annotations/AnnotationsButton.scss new file mode 100644 index 000000000..1c58717d5 --- /dev/null +++ b/src/lib/viewers/controls/annotations/AnnotationsButton.scss @@ -0,0 +1,21 @@ +@import '~box-ui-elements/es/styles/variables'; +@import '../styles'; + +.bp-AnnotationsButton { + @include bp-ControlButton; + + svg { + width: 34px; + height: 32px; + padding: 4px 5px; + border-radius: 4px; + fill: $white; + } + + &.bp-is-active { + svg { + background-color: $white; + fill: $black; + } + } +} diff --git a/src/lib/viewers/controls/annotations/AnnotationsButton.tsx b/src/lib/viewers/controls/annotations/AnnotationsButton.tsx new file mode 100644 index 000000000..c2a3f20d2 --- /dev/null +++ b/src/lib/viewers/controls/annotations/AnnotationsButton.tsx @@ -0,0 +1,41 @@ +import React, { ButtonHTMLAttributes } from 'react'; +import classNames from 'classnames'; +import noop from 'lodash/noop'; +import { AnnotationMode } from './types'; +import './AnnotationsButton.scss'; + +export type Props = Omit, 'onClick'> & { + children?: React.ReactNode; + className?: string; + isActive?: boolean; + isEnabled?: boolean; + mode: AnnotationMode; + onClick?: (mode: AnnotationMode) => void; +}; + +export default function AnnotationsButton({ + children, + className, + isActive = false, + isEnabled = true, + mode, + onClick = noop, + ...rest +}: Props): JSX.Element | null { + if (!isEnabled) { + return null; + } + + return ( + + ); +} diff --git a/src/lib/viewers/controls/annotations/AnnotationsControls.scss b/src/lib/viewers/controls/annotations/AnnotationsControls.scss new file mode 100644 index 000000000..92d04fb38 --- /dev/null +++ b/src/lib/viewers/controls/annotations/AnnotationsControls.scss @@ -0,0 +1,9 @@ +@import '~box-ui-elements/es/styles/variables'; +@import '../styles'; + +.bp-AnnotationsControls { + @include bp-ControlGroup; + + padding-left: 4px; + border-left: 1px solid $twos; +} diff --git a/src/lib/viewers/controls/annotations/AnnotationsControls.tsx b/src/lib/viewers/controls/annotations/AnnotationsControls.tsx new file mode 100644 index 000000000..55da65bfd --- /dev/null +++ b/src/lib/viewers/controls/annotations/AnnotationsControls.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import noop from 'lodash/noop'; +import AnnotationsButton from './AnnotationsButton'; +import IconHighlightText16 from '../icons/IconHighlightText16'; +import IconRegion24 from '../icons/IconRegion24'; +import useFullscreen from '../hooks/useFullscreen'; +import { AnnotationMode } from './types'; +import './AnnotationsControls.scss'; + +export type Props = { + annotationMode?: AnnotationMode; + hasHighlight?: boolean; + hasRegion?: boolean; + onAnnotationModeClick?: ({ mode }: { mode: AnnotationMode }) => void; + onAnnotationModeEscape?: () => void; +}; + +export default function AnnotationsControls({ + annotationMode = AnnotationMode.NONE, + hasHighlight = false, + hasRegion = false, + onAnnotationModeClick = noop, + onAnnotationModeEscape = noop, +}: Props): JSX.Element | null { + const isFullscreen = useFullscreen(); + const showHighlight = !isFullscreen && hasHighlight; + const showRegion = !isFullscreen && hasRegion; + + // Component event handlers + const handleModeClick = (mode: AnnotationMode): void => { + onAnnotationModeClick({ mode: annotationMode === mode ? AnnotationMode.NONE : mode }); + }; + + // Global event handlers + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + onAnnotationModeEscape(); + }; + + if (annotationMode !== AnnotationMode.NONE) { + document.addEventListener('keydown', handleKeyDown); + } + + return (): void => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [annotationMode, onAnnotationModeEscape]); + + // Prevent empty group from being displayed + if (!showHighlight && !showRegion) { + return null; + } + + return ( +
+ + + + + + +
+ ); +} diff --git a/src/lib/viewers/controls/annotations/__tests__/AnnotationsButton-test.tsx b/src/lib/viewers/controls/annotations/__tests__/AnnotationsButton-test.tsx new file mode 100644 index 000000000..8162253be --- /dev/null +++ b/src/lib/viewers/controls/annotations/__tests__/AnnotationsButton-test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import AnnotationsButton from '../AnnotationsButton'; +import { AnnotationMode } from '../types'; + +describe('AnnotationsButton', () => { + const getWrapper = (props = {}): ShallowWrapper => + shallow( + + Test + , + ); + + describe('event handlers', () => { + test('should call the onClick callback with the given mode', () => { + const mode = AnnotationMode.HIGHLIGHT; + const onClick = jest.fn(); + const wrapper = getWrapper({ mode, onClick }); + + wrapper.simulate('click'); + + expect(onClick).toBeCalledWith(mode); + }); + }); + + describe('render', () => { + test('should return nothing if not enabled', () => { + const wrapper = getWrapper({ isEnabled: false }); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(wrapper.hasClass('bp-AnnotationsButton')).toBe(true); + expect(wrapper.hasClass('bp-is-active')).toBe(false); // Default + expect(wrapper.text()).toBe('Test'); + }); + }); +}); diff --git a/src/lib/viewers/controls/annotations/__tests__/AnnotationsControls-test.tsx b/src/lib/viewers/controls/annotations/__tests__/AnnotationsControls-test.tsx new file mode 100644 index 000000000..d17fc66ed --- /dev/null +++ b/src/lib/viewers/controls/annotations/__tests__/AnnotationsControls-test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { ReactWrapper, mount } from 'enzyme'; +import AnnotationsControls from '../AnnotationsControls'; +import { AnnotationMode } from '../types'; + +describe('AnnotationsControls', () => { + const getWrapper = (props = {}): ReactWrapper => mount(); + const getElement = (props = {}): ReactWrapper => getWrapper(props).childAt(0); + + beforeEach(() => { + jest.spyOn(document, 'addEventListener'); + jest.spyOn(document, 'removeEventListener'); + }); + + describe('lifecycle', () => { + test('should add and remove its event handlers on mount and unmount', () => { + const wrapper = getWrapper({ + annotationMode: AnnotationMode.REGION, + hasHighlight: true, + hasRegion: true, + }); + expect(document.addEventListener).toBeCalledWith('keydown', expect.any(Function)); + + wrapper.unmount(); + expect(document.removeEventListener).toBeCalledWith('keydown', expect.any(Function)); + }); + + test('should not add a handler if the annotation mode is set to none', () => { + const wrapper = getWrapper({ hasHighlight: true, hasRegion: true }); + expect(document.addEventListener).not.toBeCalledWith('keydown', expect.any(Function)); + + wrapper.unmount(); + expect(document.removeEventListener).toBeCalledWith('keydown', expect.any(Function)); + }); + }); + + describe('event handlers', () => { + test.each` + current | selector | result + ${AnnotationMode.NONE} | ${'bp-AnnotationsControls-regionBtn'} | ${AnnotationMode.REGION} + ${AnnotationMode.REGION} | ${'bp-AnnotationsControls-regionBtn'} | ${AnnotationMode.NONE} + ${AnnotationMode.REGION} | ${'bp-AnnotationsControls-highlightBtn'} | ${AnnotationMode.HIGHLIGHT} + ${AnnotationMode.NONE} | ${'bp-AnnotationsControls-highlightBtn'} | ${AnnotationMode.HIGHLIGHT} + `('in $current mode returns $result when $selector is clicked', ({ current, result, selector }) => { + const onClick = jest.fn(); + const element = getElement({ + annotationMode: current, + hasHighlight: true, + hasRegion: true, + onAnnotationModeClick: onClick, + }); + + element.find(`button[data-testid="${selector}"]`).simulate('click'); + + expect(onClick).toBeCalledWith({ mode: result }); + }); + + test('should invoke the escape callback if the escape key is pressed while in a mode', () => { + const onEscape = jest.fn(); + + getWrapper({ + annotationMode: AnnotationMode.REGION, + hasHighlight: true, + hasRegion: true, + onAnnotationModeEscape: onEscape, + }); + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }); + + expect(onEscape).toBeCalled(); + }); + + test('should not invoke the escape callback if any key other than escape is pressed', () => { + const onEscape = jest.fn(); + + getWrapper({ + annotationMode: AnnotationMode.REGION, + hasHighlight: true, + hasRegion: true, + onAnnotationModeEscape: onEscape, + }); + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + }); + + expect(onEscape).not.toBeCalled(); + }); + }); + + describe('render', () => { + test('should return nothing if no mode is enabled', () => { + const wrapper = getWrapper(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + test('should return a valid wrapper', () => { + const element = getElement({ hasHighlight: true, hasRegion: true }); + + expect(element.hasClass('bp-AnnotationsControls')).toBe(true); + }); + }); +}); diff --git a/src/lib/viewers/controls/annotations/index.ts b/src/lib/viewers/controls/annotations/index.ts new file mode 100644 index 000000000..609c00fbd --- /dev/null +++ b/src/lib/viewers/controls/annotations/index.ts @@ -0,0 +1,2 @@ +export * from './AnnotationsControls'; +export { default } from './AnnotationsControls'; diff --git a/src/lib/viewers/controls/annotations/types.ts b/src/lib/viewers/controls/annotations/types.ts new file mode 100644 index 000000000..50b299c67 --- /dev/null +++ b/src/lib/viewers/controls/annotations/types.ts @@ -0,0 +1,6 @@ +// eslint-disable-next-line import/prefer-default-export +export enum AnnotationMode { + HIGHLIGHT = 'highlight', + NONE = 'none', + REGION = 'region', +} diff --git a/src/lib/viewers/controls/controls-root/ControlsRoot.tsx b/src/lib/viewers/controls/controls-root/ControlsRoot.tsx index ecf40eae0..28df320da 100644 --- a/src/lib/viewers/controls/controls-root/ControlsRoot.tsx +++ b/src/lib/viewers/controls/controls-root/ControlsRoot.tsx @@ -7,6 +7,7 @@ import './ControlsRoot.scss'; export type Options = { containerEl: HTMLElement; + fileId: string; }; export default class ControlsRoot { @@ -20,11 +21,12 @@ export default class ControlsRoot { show: noop, }; - constructor({ containerEl }: Options) { + constructor({ containerEl, fileId }: Options) { this.controlsEl = document.createElement('div'); this.controlsEl.setAttribute('class', 'bp-ControlsRoot'); this.controlsEl.setAttribute('data-testid', 'bp-controls'); this.controlsEl.setAttribute('data-resin-component', 'toolbar'); + this.controlsEl.setAttribute('data-resin-fileid', fileId); this.containerEl = containerEl; this.containerEl.addEventListener('mousemove', this.handleMouseMove); diff --git a/src/lib/viewers/controls/controls-root/__tests__/ControlsRoot-test.tsx b/src/lib/viewers/controls/controls-root/__tests__/ControlsRoot-test.tsx index 423d2d7a8..6ed3fe18a 100644 --- a/src/lib/viewers/controls/controls-root/__tests__/ControlsRoot-test.tsx +++ b/src/lib/viewers/controls/controls-root/__tests__/ControlsRoot-test.tsx @@ -1,21 +1,18 @@ import React from 'react'; +import ReactDOMServer from 'react-dom/server'; import ControlsRoot from '../ControlsRoot'; describe('ControlsRoot', () => { const getInstance = (options = {}): ControlsRoot => - new ControlsRoot({ containerEl: document.createElement('div'), ...options }); + new ControlsRoot({ containerEl: document.createElement('div'), fileId: '1', ...options }); describe('constructor', () => { test('should inject a controls root element into the container', () => { const instance = getInstance(); - expect(instance.containerEl.firstChild).toMatchInlineSnapshot(` -
- `); + expect(instance.controlsEl).toHaveClass('bp-ControlsRoot'); + expect(instance.controlsEl).toHaveAttribute('data-resin-component', 'toolbar'); + expect(instance.controlsEl).toHaveAttribute('data-resin-fileid', '1'); }); test('should attach event handlers to the container element', () => { @@ -106,17 +103,8 @@ describe('ControlsRoot', () => { instance.render(controls); - expect(instance.controlsEl.firstChild).toMatchInlineSnapshot(` -
-
- Controls -
-
- `); + expect(instance.controlsEl.firstChild).toHaveClass('bp-ControlsLayer'); + expect(instance.controlsEl.firstChild).toContainHTML(ReactDOMServer.renderToStaticMarkup(controls)); }); }); }); diff --git a/src/lib/viewers/controls/fullscreen/FullscreenToggle.tsx b/src/lib/viewers/controls/fullscreen/FullscreenToggle.tsx index b6109093c..55704e822 100644 --- a/src/lib/viewers/controls/fullscreen/FullscreenToggle.tsx +++ b/src/lib/viewers/controls/fullscreen/FullscreenToggle.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import fullscreen from '../../../Fullscreen'; import IconFullscreenIn24 from '../icons/IconFullscreenIn24'; import IconFullscreenOut24 from '../icons/IconFullscreenOut24'; +import useFullscreen from '../hooks/useFullscreen'; import './FullscreenToggle.scss'; export type Props = { @@ -9,7 +9,7 @@ export type Props = { }; export default function FullscreenToggle({ onFullscreenToggle }: Props): JSX.Element { - const [isFullscreen, setFullscreen] = React.useState(false); + const isFullscreen = useFullscreen(); const Icon = isFullscreen ? IconFullscreenOut24 : IconFullscreenIn24; const title = isFullscreen ? __('exit_fullscreen') : __('enter_fullscreen'); @@ -17,19 +17,6 @@ export default function FullscreenToggle({ onFullscreenToggle }: Props): JSX.Ele onFullscreenToggle(!isFullscreen); }; - React.useEffect(() => { - const handleFullscreenEnter = (): void => setFullscreen(true); - const handleFullscreenExit = (): void => setFullscreen(false); - - fullscreen.addListener('enter', handleFullscreenEnter); - fullscreen.addListener('exit', handleFullscreenExit); - - return (): void => { - fullscreen.removeListener('enter', handleFullscreenEnter); - fullscreen.removeListener('exit', handleFullscreenExit); - }; - }, []); - return (