Skip to content

Commit

Permalink
chore: add HighlightCanvas
Browse files Browse the repository at this point in the history
  • Loading branch information
Conrad Chan committed Aug 6, 2020
1 parent 813ab6f commit 18ef97a
Show file tree
Hide file tree
Showing 10 changed files with 334 additions and 156 deletions.
42 changes: 0 additions & 42 deletions src/highlight/HighlightAnnotation.scss
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 1 addition & 2 deletions src/highlight/HighlightAnnotation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -79,7 +78,7 @@ const HighlightAnnotation = (props: Props, ref: React.Ref<HighlightAnnotationRef
<rect
key={`${annotationId}-${x}-${y}-${width}-${height}`}
className="ba-HighlightAnnotation-rect"
fill={bdlYellorange}
fill="none"
height={`${height}%`}
onMouseOut={handleMouseOut}
onMouseOver={handleMouseOver}
Expand Down
12 changes: 9 additions & 3 deletions src/highlight/HighlightAnnotations.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as React from 'react';
import HighlightCanvas from './HighlightCanvas';
import HighlightCreator from './HighlightCreator';
import HighlightList from './HighlightList';
import { AnnotationHighlight } from '../@types';
import { isValidHighlight, sortHighlight } from './highlightUtil';

import './HighlightAnnotations.scss';

Expand All @@ -26,17 +28,21 @@ export default class HighlightAnnotations extends React.PureComponent<Props> {

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 */}
<HighlightCanvas activeId={activeAnnotationId} annotations={sortedAnnotations} />

{/* Layer 2: Saved annotations -- interactable highlights */}
<HighlightList
activeId={activeAnnotationId}
annotations={annotations}
annotations={sortedAnnotations}
onSelect={this.handleAnnotationActive}
/>

{/* Layer 2: Drawn (unsaved) incomplete annotation target, if any */}
{/* Layer 3: Drawn (unsaved) incomplete annotation target, if any */}
{isCreating && <HighlightCreator className="ba-HighlightAnnotations-creator" />}
</>
);
Expand Down
7 changes: 7 additions & 0 deletions src/highlight/HighlightCanvas.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.ba-HighlightCanvas {
position: absolute;
top: 0;
left: 0;
mix-blend-mode: multiply;
pointer-events: none;
}
146 changes: 146 additions & 0 deletions src/highlight/HighlightCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> {
static defaultProps = {
annotations: [],
};

canvasRef: React.RefObject<HTMLCanvasElement> = 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 <canvas ref={this.canvasRef} className="ba-HighlightCanvas" />;
}
}
48 changes: 10 additions & 38 deletions src/highlight/HighlightList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<SVGSVGElement>();
Expand All @@ -59,18 +34,15 @@ export function HighlightList({ activeId, annotations, className, onSelect }: Pr
<feDropShadow dx="0" dy="0" floodColor="rgba(0, 0, 0, 1)" floodOpacity="0.2" stdDeviation="2" />
</filter>
</defs>
{annotations
.filter(isValidHighlight)
.sort(sortHighlight)
.map(({ id, target }) => (
<HighlightAnnotation
key={id}
annotationId={id}
isActive={activeId === id}
onSelect={onSelect}
rects={target.shapes}
/>
))}
{annotations.map(({ id, target }) => (
<HighlightAnnotation
key={id}
annotationId={id}
isActive={activeId === id}
onSelect={onSelect}
rects={target.shapes}
/>
))}
</svg>
);
}
Expand Down
61 changes: 61 additions & 0 deletions src/highlight/__tests__/HighlightCanvas-test.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, {}, HighlightCanvas> =>
shallow(<HighlightCanvas {...defaults} {...props} />);

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);
});
});
});
Loading

0 comments on commit 18ef97a

Please sign in to comment.