diff --git a/src/highlight/HighlightAnnotation.scss b/src/highlight/HighlightAnnotation.scss index e3f54053c..0a08a073e 100644 --- a/src/highlight/HighlightAnnotation.scss +++ b/src/highlight/HighlightAnnotation.scss @@ -1,47 +1,5 @@ -@import '~box-ui-elements/es/styles/variables'; - -@mixin ba-HighlightAnnotation-ie-edge { - .ba-HighlightAnnotation { - &.is-active { - .ba-HighlightAnnotation-rect { - filter: none; - } - } - } -} - .ba-HighlightAnnotation { &:focus { outline: none; } - - &.is-active, - &.is-hover { - .ba-HighlightAnnotation-rect { - fill-opacity: .66; - } - } - - &.is-active { - .ba-HighlightAnnotation-rect { - filter: url(#shadow); - } - } -} - -.ba-HighlightAnnotation-rect { - fill-opacity: .33; - transition: fill-opacity 200ms ease 25ms; - stroke: $white; - stroke-width: 1; -} - -/* Disable the is-active filter applied to the ba-HighlightAnnotation-rect when active for IE11 */ -@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { - @include ba-HighlightAnnotation-ie-edge; -} - -/* Disable the is-active filter applied to the ba-HighlightAnnotation-rect when active for Edge */ -@supports (-ms-ime-align: auto) { - @include ba-HighlightAnnotation-ie-edge; } diff --git a/src/highlight/HighlightAnnotation.tsx b/src/highlight/HighlightAnnotation.tsx index 390b7d996..1a8817db2 100644 --- a/src/highlight/HighlightAnnotation.tsx +++ b/src/highlight/HighlightAnnotation.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import * as ReactRedux from 'react-redux'; import classNames from 'classnames'; import noop from 'lodash/noop'; -import { bdlYellorange } from 'box-ui-elements/es/styles/variables'; import { getIsCurrentFileVersion } from '../store'; import { MOUSE_PRIMARY } from '../constants'; import { Rect } from '../@types/model'; @@ -79,7 +78,7 @@ const HighlightAnnotation = (props: Props, ref: React.Ref { render(): JSX.Element { const { activeAnnotationId, annotations, isCreating } = this.props; + const sortedAnnotations = annotations.filter(isValidHighlight).sort(sortHighlight); return ( <> - {/* Layer 1: Saved annotations */} + {/* Layer 1: Saved annotations -- visual highlights */} + + + {/* Layer 2: Saved annotations -- interactable highlights */} - {/* Layer 2: Drawn (unsaved) incomplete annotation target, if any */} + {/* Layer 3: Drawn (unsaved) incomplete annotation target, if any */} {isCreating && } ); diff --git a/src/highlight/HighlightCanvas.scss b/src/highlight/HighlightCanvas.scss new file mode 100644 index 000000000..30aad9c3a --- /dev/null +++ b/src/highlight/HighlightCanvas.scss @@ -0,0 +1,7 @@ +.ba-HighlightCanvas { + position: absolute; + top: 0; + left: 0; + mix-blend-mode: multiply; + pointer-events: none; +} diff --git a/src/highlight/HighlightCanvas.tsx b/src/highlight/HighlightCanvas.tsx new file mode 100644 index 000000000..af04cd653 --- /dev/null +++ b/src/highlight/HighlightCanvas.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import { bdlYellorange, black, white } from 'box-ui-elements/es/styles/variables'; +import { AnnotationHighlight, Rect } from '../@types'; +import './HighlightCanvas.scss'; + +export type Props = { + activeId: string | null; + annotations: AnnotationHighlight[]; +}; + +export default class HighlightCanvas extends React.Component { + static defaultProps = { + annotations: [], + }; + + canvasRef: React.RefObject = React.createRef(); + + componentDidMount(): void { + this.scaleCanvas(); + this.renderRects(); + } + + componentDidUpdate(): void { + this.clearRects(); + this.renderRects(); + } + + getContext(): CanvasRenderingContext2D | null { + const { current: canvasRef } = this.canvasRef; + return canvasRef?.getContext('2d') ?? null; + } + + scaleCanvas(): void { + const { current: canvasRef } = this.canvasRef; + + if (!canvasRef) { + return; + } + + canvasRef.style.width = '100%'; + canvasRef.style.height = '100%'; + canvasRef.width = canvasRef.offsetWidth; + canvasRef.height = canvasRef.offsetHeight; + } + + clearRects(): void { + const { current: canvasRef } = this.canvasRef; + const context = canvasRef && canvasRef.getContext('2d'); + + if (!canvasRef || !context) { + return; + } + + context.clearRect(0, 0, canvasRef.width, canvasRef.height); + } + + roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius = 5, + fill = false, + stroke = true, + ): void { + const radii = { tl: radius, tr: radius, br: radius, bl: radius }; + ctx.beginPath(); + ctx.moveTo(x + radii.tl, y); + ctx.lineTo(x + width - radii.tr, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radii.tr); + ctx.lineTo(x + width, y + height - radii.br); + ctx.quadraticCurveTo(x + width, y + height, x + width - radii.br, y + height); + ctx.lineTo(x + radii.bl, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radii.bl); + ctx.lineTo(x, y + radii.tl); + ctx.quadraticCurveTo(x, y, x + radii.tl, y); + ctx.closePath(); + + if (fill) { + ctx.fill(); + } + + if (stroke) { + ctx.stroke(); + } + } + + renderRects(): void { + const { activeId, annotations } = this.props; + const { current: canvasRef } = this.canvasRef; + const context = canvasRef && canvasRef.getContext('2d'); + const canvasHeight = canvasRef?.height ?? 0; + const canvasWidth = canvasRef?.width ?? 0; + + if (!context) { + return; + } + + annotations.forEach(annotation => { + const { id, target } = annotation; + const { shapes } = target; + + shapes.forEach(({ height, width, x, y }: Rect) => { + const isActive = activeId === id; + const rectHeight = (height / 100) * canvasHeight; + const rectWidth = (width / 100) * canvasWidth; + const x1 = (x / 100) * canvasWidth; + const y1 = (y / 100) * canvasHeight; + + context.save(); + + // Draw the highlight rect + context.fillStyle = bdlYellorange; + context.globalAlpha = isActive ? 0.66 : 0.33; + this.roundRect(context, x1, y1, rectWidth, rectHeight, 5, true, false); + context.restore(); + context.save(); + + // Draw the white border + context.strokeStyle = white; + context.lineWidth = 1; + this.roundRect(context, x1, y1, rectWidth, rectHeight, 5, false, true); + + // If annotation is active, apply a shadow + if (isActive) { + const imgdata = context.getImageData(x1 - 1, y1 - 1, rectWidth + 2, rectHeight + 2); + + context.save(); + context.shadowColor = black; + context.shadowBlur = 10; + + this.roundRect(context, x1, y1, rectWidth, rectHeight, 5, false, true); + context.putImageData(imgdata, x1 - 1, y1 - 1); + context.restore(); + } + + context.restore(); + }); + }); + } + + render(): JSX.Element { + return ; + } +} diff --git a/src/highlight/HighlightList.tsx b/src/highlight/HighlightList.tsx index fa39eb26e..d35f89060 100644 --- a/src/highlight/HighlightList.tsx +++ b/src/highlight/HighlightList.tsx @@ -2,8 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import HighlightAnnotation from './HighlightAnnotation'; import useOutsideEvent from '../common/useOutsideEvent'; -import { AnnotationHighlight, Rect } from '../@types'; -import { checkValue } from '../utils/util'; +import { AnnotationHighlight } from '../@types'; import './HighlightList.scss'; @@ -14,30 +13,6 @@ export type Props = { onSelect?: (annotationId: string | null) => void; }; -export function isValidHighlight({ target }: AnnotationHighlight): boolean { - const { shapes = [] } = target; - - return shapes.reduce((isValid: boolean, rect: Rect) => { - const { height, width, x, y } = rect; - return isValid && checkValue(height) && checkValue(width) && checkValue(x) && checkValue(y); - }, true); -} - -export function getHighlightArea(shapes: Rect[]): number { - return shapes.reduce((area, { height, width }) => area + height * width, 0); -} - -export function sortHighlight( - { target: targetA }: AnnotationHighlight, - { target: targetB }: AnnotationHighlight, -): number { - const { shapes: shapesA } = targetA; - const { shapes: shapesB } = targetB; - - // Render the smallest highlights last to ensure they are always clickable - return getHighlightArea(shapesA) > getHighlightArea(shapesB) ? -1 : 1; -} - export function HighlightList({ activeId, annotations, className, onSelect }: Props): JSX.Element { const [isListening, setIsListening] = React.useState(true); const rootElRef = React.createRef(); @@ -59,18 +34,15 @@ export function HighlightList({ activeId, annotations, className, onSelect }: Pr - {annotations - .filter(isValidHighlight) - .sort(sortHighlight) - .map(({ id, target }) => ( - - ))} + {annotations.map(({ id, target }) => ( + + ))} ); } diff --git a/src/highlight/__tests__/HighlightCanvas-test.tsx b/src/highlight/__tests__/HighlightCanvas-test.tsx new file mode 100644 index 000000000..530bc0165 --- /dev/null +++ b/src/highlight/__tests__/HighlightCanvas-test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import HighlightCanvas, { Props } from '../HighlightCanvas'; +import { annotation as mockAnnotation } from '../__mocks__/data'; + +describe('highlight/HighlightCanvas', () => { + const defaults: Props = { + activeId: null, + annotations: [mockAnnotation], + }; + const getWrapper = (props?: Props): ShallowWrapper => + shallow(); + + test('componentDidMount()', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance() as HighlightCanvas; + + jest.spyOn(instance, 'scaleCanvas'); + jest.spyOn(instance, 'renderRects'); + + instance.componentDidMount(); + + expect(instance.scaleCanvas).toHaveBeenCalledTimes(1); + expect(instance.renderRects).toHaveBeenCalledTimes(1); + }); + + test('componentDidUpdate()', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance() as HighlightCanvas; + + jest.spyOn(instance, 'clearRects'); + jest.spyOn(instance, 'renderRects'); + + wrapper.setProps({ activeId: '123' }); + + expect(instance.clearRects).toHaveBeenCalledTimes(1); + expect(instance.renderRects).toHaveBeenCalledTimes(1); + }); + + describe('getContext()', () => { + test.each` + canvasRef | expectedResult + ${null} | ${null} + ${{ getContext: () => 'ctx' }} | ${'ctx'} + `('should return $expectedResult as context if canvasRef is $canvasRef', ({ canvasRef, expectedResult }) => { + const wrapper = getWrapper(); + const instance = wrapper.instance() as HighlightCanvas; + + instance.canvasRef = { current: canvasRef }; + + expect(instance.getContext()).toBe(expectedResult); + }); + }); + + describe('render()', () => { + test('should render', () => { + const wrapper = getWrapper(); + expect(wrapper.find('canvas').hasClass('ba-HighlightCanvas')).toBe(true); + }); + }); +}); diff --git a/src/highlight/__tests__/HighlightList-test.tsx b/src/highlight/__tests__/HighlightList-test.tsx index 5a2dc70cf..a1e1b1f5e 100644 --- a/src/highlight/__tests__/HighlightList-test.tsx +++ b/src/highlight/__tests__/HighlightList-test.tsx @@ -1,10 +1,9 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import HighlightAnnotation from '../HighlightAnnotation'; -import HighlightList, { isValidHighlight, getHighlightArea, Props, sortHighlight } from '../HighlightList'; +import HighlightList, { Props } from '../HighlightList'; import useOutsideEvent from '../../common/useOutsideEvent'; -import { annotation as mockAnnotation, rect as mockRect, target as mockTarget } from '../__mocks__/data'; -import { Rect, AnnotationHighlight } from '../../@types'; +import { annotation as mockAnnotation } from '../__mocks__/data'; jest.mock('react', () => ({ ...jest.requireActual('react'), @@ -21,73 +20,6 @@ describe('highlight/HighlightList', () => { }; const getWrapper = (props?: Partial): ShallowWrapper => shallow(); - describe('isValidHighlight()', () => { - test.each` - height | width | x | y | isValid - ${10} | ${20} | ${5} | ${5} | ${true} - ${-1} | ${20} | ${5} | ${5} | ${false} - ${10} | ${-1} | ${5} | ${5} | ${false} - ${10} | ${20} | ${-1} | ${5} | ${false} - ${10} | ${20} | ${5} | ${-1} | ${false} - `('should return $isValid based on the rect properties', ({ height, width, x, y, isValid }) => { - const rect: Rect = { - ...mockRect, - height, - width, - x, - y, - }; - const target = { ...mockTarget, shapes: [rect] }; - const highlight: AnnotationHighlight = { - ...mockAnnotation, - target, - }; - - expect(isValidHighlight(highlight)).toBe(isValid); - }); - }); - - describe('getHighlightArea()', () => { - test('should get total highlighted area', () => { - const shapes = [mockRect, mockRect]; - expect(getHighlightArea(shapes)).toBe(400); - }); - }); - - describe('sortHighlight()', () => { - test.each` - widthA | heightA | widthB | heightB | returnValue - ${1} | ${1} | ${1} | ${2} | ${1} - ${2} | ${1} | ${1} | ${1} | ${-1} - ${1} | ${1} | ${1} | ${1} | ${1} - `( - 'should compare highlights based on area $areaA and $areaB with return $returnValue', - ({ widthA, heightA, widthB, heightB, returnValue }) => { - const rectA = { - ...mockRect, - height: heightA, - width: widthA, - }; - const rectB = { - ...mockRect, - height: heightB, - width: widthB, - }; - const targetA = { - ...mockTarget, - shapes: [rectA], - }; - const targetB = { - ...mockTarget, - shapes: [rectB], - }; - expect( - sortHighlight({ ...mockAnnotation, target: targetA }, { ...mockAnnotation, target: targetB }), - ).toBe(returnValue); - }, - ); - }); - describe('render()', () => { const mockSetIsListening = jest.fn(); diff --git a/src/highlight/__tests__/highlightUtil-test.ts b/src/highlight/__tests__/highlightUtil-test.ts new file mode 100644 index 000000000..3f20b87b9 --- /dev/null +++ b/src/highlight/__tests__/highlightUtil-test.ts @@ -0,0 +1,72 @@ +import { isValidHighlight, getHighlightArea, sortHighlight } from '../highlightUtil'; +import { annotation as mockAnnotation, rect as mockRect, target as mockTarget } from '../__mocks__/data'; +import { Rect, AnnotationHighlight } from '../../@types'; + +describe('highlight/highlightUtil', () => { + describe('isValidHighlight()', () => { + test.each` + height | width | x | y | isValid + ${10} | ${20} | ${5} | ${5} | ${true} + ${-1} | ${20} | ${5} | ${5} | ${false} + ${10} | ${-1} | ${5} | ${5} | ${false} + ${10} | ${20} | ${-1} | ${5} | ${false} + ${10} | ${20} | ${5} | ${-1} | ${false} + `('should return $isValid based on the rect properties', ({ height, width, x, y, isValid }) => { + const rect: Rect = { + ...mockRect, + height, + width, + x, + y, + }; + const target = { ...mockTarget, shapes: [rect] }; + const highlight: AnnotationHighlight = { + ...mockAnnotation, + target, + }; + + expect(isValidHighlight(highlight)).toBe(isValid); + }); + }); + + describe('getHighlightArea()', () => { + test('should get total highlighted area', () => { + const shapes = [mockRect, mockRect]; + expect(getHighlightArea(shapes)).toBe(400); + }); + }); + + describe('sortHighlight()', () => { + test.each` + widthA | heightA | widthB | heightB | returnValue + ${1} | ${1} | ${1} | ${2} | ${1} + ${2} | ${1} | ${1} | ${1} | ${-1} + ${1} | ${1} | ${1} | ${1} | ${1} + `( + 'should compare highlights based on area $areaA and $areaB with return $returnValue', + ({ widthA, heightA, widthB, heightB, returnValue }) => { + const rectA = { + ...mockRect, + height: heightA, + width: widthA, + }; + const rectB = { + ...mockRect, + height: heightB, + width: widthB, + }; + const targetA = { + ...mockTarget, + shapes: [rectA], + }; + const targetB = { + ...mockTarget, + shapes: [rectB], + }; + expect( + sortHighlight({ ...mockAnnotation, target: targetA }, { ...mockAnnotation, target: targetB }), + ).toBe(returnValue); + }, + ); + }); +}); diff --git a/src/highlight/highlightUtil.ts b/src/highlight/highlightUtil.ts index 47e4528bb..e7a52003a 100644 --- a/src/highlight/highlightUtil.ts +++ b/src/highlight/highlightUtil.ts @@ -1,5 +1,30 @@ -import { Annotation, AnnotationHighlight, Type } from '../@types'; +import { Annotation, AnnotationHighlight, Rect, Type } from '../@types'; +import { checkValue } from '../utils/util'; export function isHighlight(annotation: Annotation): annotation is AnnotationHighlight { return annotation?.target?.type === Type.highlight; } + +export function isValidHighlight({ target }: AnnotationHighlight): boolean { + const { shapes = [] } = target; + + return shapes.reduce((isValid: boolean, rect: Rect) => { + const { height, width, x, y } = rect; + return isValid && checkValue(height) && checkValue(width) && checkValue(x) && checkValue(y); + }, true); +} + +export function getHighlightArea(shapes: Rect[]): number { + return shapes.reduce((area, { height, width }) => area + height * width, 0); +} + +export function sortHighlight( + { target: targetA }: AnnotationHighlight, + { target: targetB }: AnnotationHighlight, +): number { + const { shapes: shapesA } = targetA; + const { shapes: shapesB } = targetB; + + // Render the smallest highlights last to ensure they are always clickable + return getHighlightArea(shapesA) > getHighlightArea(shapesB) ? -1 : 1; +}