Skip to content

Commit 54a66f0

Browse files
author
Conrad Chan
authored
feat(drawing): Add PopupDrawingToolbar (#644)
1 parent 6145b83 commit 54a66f0

21 files changed

+582
-48
lines changed

i18n/en-US.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ ba.annotationsLoadError = We’re sorry, the annotations failed to load for this
88
ba.annotationsPost = Post
99
# Label for the save button
1010
ba.annotationsSave = Save
11+
# Button label for adding a comment in the drawing toolbar
12+
ba.popups.addComment = Add Comment
1113
# Button label for cancelling the creation of a description, comment, or reply
1214
ba.popups.cancel = Cancel
15+
# Button title for deleting a staged drawing
16+
ba.popups.deleteDrawing = Delete Drawing
1317
# Prompt message following cursor in region annotations mode
1418
ba.popups.popupCursor.regionPrompt = Draw a box to comment
1519
# Popup message for highlight promoter

src/components/Popups/Popper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import unionBy from 'lodash/unionBy';
44

55
export type Instance = popper.Instance;
66
export type Options = popper.Options;
7+
export type Rect = popper.Rect;
78
export type State = popper.State;
89
export type VirtualElement = popper.VirtualElement;
910

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
@import '~box-ui-elements/es/styles/variables';
2+
3+
@mixin ba-PopupDrawingToolbarButton($width: 32px, $height: 32px) {
4+
display: flex;
5+
align-items: center;
6+
justify-content: center;
7+
width: $width;
8+
height: $height;
9+
padding: 0;
10+
color: $white;
11+
background: transparent;
12+
border: none;
13+
border-radius: $bdl-border-radius-size;
14+
outline: none;
15+
cursor: pointer;
16+
opacity: .7;
17+
transition: opacity 200ms ease;
18+
19+
&:focus,
20+
&:hover {
21+
opacity: 1;
22+
}
23+
24+
&[disabled] {
25+
cursor: default;
26+
opacity: .2;
27+
}
28+
}
29+
30+
.ba-PopupDrawingToolbar {
31+
background-color: $bdl-gray;
32+
border-radius: $bdl-border-radius-size;
33+
34+
.ba-Popup-arrow {
35+
display: none;
36+
}
37+
38+
.ba-Popup-content {
39+
display: flex;
40+
background-color: $bdl-gray;
41+
border: none;
42+
border-radius: $bdl-border-radius-size;
43+
box-shadow: none;
44+
}
45+
}
46+
47+
.ba-PopupDrawingToolbar-group {
48+
position: relative;
49+
display: flex;
50+
align-items: center;
51+
justify-content: center;
52+
53+
& + & {
54+
border-left: 1px solid #000;
55+
}
56+
}
57+
58+
.ba-PopupDrawingToolbar-delete {
59+
@include ba-PopupDrawingToolbarButton;
60+
61+
path {
62+
fill: #fff;
63+
}
64+
}
65+
66+
.ba-PopupDrawingToolbar-comment {
67+
@include ba-PopupDrawingToolbarButton($width: auto);
68+
@include common-typography;
69+
70+
padding-right: 18px;
71+
padding-left: 18px;
72+
color: $white;
73+
font-weight: bold;
74+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React from 'react';
2+
import classNames from 'classnames';
3+
import { FormattedMessage, useIntl } from 'react-intl';
4+
import IconTrash from 'box-ui-elements/es/icon/line/Trash16';
5+
import messages from './messages';
6+
import PopupBase from './PopupBase';
7+
import { Options, PopupReference, Rect } from './Popper';
8+
import './PopupDrawingToolbar.scss';
9+
10+
export type Props = {
11+
className?: string;
12+
onDelete: () => void;
13+
onReply: () => void;
14+
reference: PopupReference;
15+
};
16+
17+
const options: Partial<Options> = {
18+
modifiers: [
19+
{
20+
name: 'eventListeners',
21+
options: {
22+
scroll: false,
23+
},
24+
},
25+
{
26+
name: 'offset',
27+
options: {
28+
offset: ({ popper }: { popper: Rect }) => [-popper.width / 2, 8],
29+
},
30+
},
31+
{
32+
name: 'preventOverflow',
33+
options: {
34+
padding: 5,
35+
},
36+
},
37+
],
38+
placement: 'top',
39+
};
40+
41+
const PopupDrawingToolbar = ({ className, onDelete, onReply, reference }: Props): JSX.Element => {
42+
const intl = useIntl();
43+
44+
return (
45+
<PopupBase
46+
className={classNames(className, 'ba-PopupDrawingToolbar')}
47+
data-resin-component="popupDrawingToolbar"
48+
options={options}
49+
reference={reference}
50+
>
51+
<div className="ba-PopupDrawingToolbar-group">
52+
{/* TODO: Add undo/redo support */}
53+
<button
54+
className="ba-PopupDrawingToolbar-delete"
55+
data-testid="ba-PopupDrawingToolbar-delete"
56+
onClick={() => onDelete()}
57+
title={intl.formatMessage(messages.buttonDeleteDrawing)}
58+
type="button"
59+
>
60+
<IconTrash />
61+
</button>
62+
</div>
63+
<div className="ba-PopupDrawingToolbar-group">
64+
<button
65+
className="ba-PopupDrawingToolbar-comment"
66+
data-testid="ba-PopupDrawingToolbar-comment"
67+
onClick={() => onReply()}
68+
type="button"
69+
>
70+
<FormattedMessage {...messages.buttonAddComent} />
71+
</button>
72+
</div>
73+
</PopupBase>
74+
);
75+
};
76+
77+
export default PopupDrawingToolbar;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React from 'react';
2+
import IconTrash from 'box-ui-elements/es/icon/line/Trash16';
3+
import { FormattedMessage } from 'react-intl';
4+
import { shallow, ShallowWrapper } from 'enzyme';
5+
import messages from '../messages';
6+
import PopupBase from '../PopupBase';
7+
import PopupDrawingToolbar, { Props } from '../PopupDrawingToolbar';
8+
9+
describe('PopupDrawingToolbar', () => {
10+
const getDOMRect = (x = 0, y = 0, height = 1000, width = 1000): DOMRect => ({
11+
bottom: y + height,
12+
top: y,
13+
left: x,
14+
right: x + width,
15+
height,
16+
width,
17+
toJSON: jest.fn(),
18+
x,
19+
y,
20+
});
21+
const getDefaults = (): Props => ({
22+
onDelete: jest.fn(),
23+
onReply: jest.fn(),
24+
reference: { getBoundingClientRect: () => getDOMRect() },
25+
});
26+
const getWrapper = (props?: Partial<Props>): ShallowWrapper =>
27+
shallow(<PopupDrawingToolbar {...getDefaults()} {...props} />);
28+
29+
describe('render', () => {
30+
test('should render popup and buttons', () => {
31+
const wrapper = getWrapper({ className: 'foo' });
32+
33+
expect(wrapper.find(PopupBase).props()).toMatchObject({
34+
className: 'foo ba-PopupDrawingToolbar',
35+
'data-resin-component': 'popupDrawingToolbar',
36+
});
37+
38+
// Delete button
39+
expect(wrapper.find('[data-testid="ba-PopupDrawingToolbar-delete"]').props()).toMatchObject({
40+
className: 'ba-PopupDrawingToolbar-delete',
41+
'data-testid': 'ba-PopupDrawingToolbar-delete',
42+
onClick: expect.any(Function),
43+
title: 'Delete Drawing',
44+
type: 'button',
45+
});
46+
expect(wrapper.exists(IconTrash)).toBe(true);
47+
48+
// Add comment button
49+
expect(wrapper.find('[data-testid="ba-PopupDrawingToolbar-comment"]').props()).toMatchObject({
50+
className: 'ba-PopupDrawingToolbar-comment',
51+
'data-testid': 'ba-PopupDrawingToolbar-comment',
52+
onClick: expect.any(Function),
53+
type: 'button',
54+
});
55+
expect(wrapper.find(FormattedMessage).props()).toMatchObject(messages.buttonAddComent);
56+
});
57+
});
58+
59+
describe('callbacks', () => {
60+
test.each`
61+
callback | button
62+
${'onDelete'} | ${'ba-PopupDrawingToolbar-delete'}
63+
${'onReply'} | ${'ba-PopupDrawingToolbar-comment'}
64+
`('should call $callback when $button is clicked', ({ callback, button }) => {
65+
const mockFn = jest.fn();
66+
const wrapper = getWrapper({ [callback]: mockFn });
67+
68+
wrapper.find(`[data-testid="${button}"]`).simulate('click');
69+
70+
expect(mockFn).toHaveBeenCalled();
71+
});
72+
});
73+
});

src/components/Popups/messages.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import { defineMessages } from 'react-intl';
22

33
export default defineMessages({
4+
buttonAddComent: {
5+
id: 'ba.popups.addComment',
6+
description: 'Button label for adding a comment in the drawing toolbar',
7+
defaultMessage: 'Add Comment',
8+
},
49
buttonCancel: {
510
id: 'ba.popups.cancel',
611
description: 'Button label for cancelling the creation of a description, comment, or reply',
712
defaultMessage: 'Cancel',
813
},
14+
buttonDeleteDrawing: {
15+
id: 'ba.popups.deleteDrawing',
16+
description: 'Button title for deleting a staged drawing',
17+
defaultMessage: 'Delete Drawing',
18+
},
919
buttonPost: {
1020
id: 'ba.popups.post',
1121
description: 'Button label for creating a description, comment, or reply',

src/drawing/DrawingAnnotations.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,14 @@
2626
pointer-events: auto;
2727
touch-action: none;
2828
}
29+
30+
.ba-DrawingAnnotations-toolbar {
31+
opacity: 1;
32+
transition: opacity 200ms ease;
33+
pointer-events: auto;
34+
35+
&.ba-is-drawing {
36+
opacity: .3;
37+
pointer-events: none;
38+
}
39+
}

src/drawing/DrawingAnnotations.tsx

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,75 @@
11
import React from 'react';
2-
import * as uuid from 'uuid';
2+
import classNames from 'classnames';
33
import DecoratedDrawingPath from './DecoratedDrawingPath';
44
import DrawingList from './DrawingList';
55
import DrawingCreator from './DrawingCreator';
66
import DrawingPathGroup from './DrawingPathGroup';
77
import DrawingSVG, { DrawingSVGRef } from './DrawingSVG';
8+
import DrawingSVGGroup from './DrawingSVGGroup';
9+
import PopupDrawingToolbar from '../components/Popups/PopupDrawingToolbar';
810
import { AnnotationDrawing, PathGroup } from '../@types';
9-
import { CreatorStatus } from '../store';
11+
import { CreatorItemDrawing, CreatorStatus } from '../store';
1012
import './DrawingAnnotations.scss';
1113

1214
export type Props = {
1315
activeAnnotationId: string | null;
1416
addDrawingPathGroup: (pathGroup: PathGroup) => void;
1517
annotations: AnnotationDrawing[];
18+
canShowPopupToolbar: boolean;
1619
drawnPathGroups: Array<PathGroup>;
1720
isCreating: boolean;
1821
location: number;
22+
resetDrawing: () => void;
1923
setActiveAnnotationId: (annotationId: string | null) => void;
20-
setDrawingLocation: (location: number) => void;
24+
setReferenceId: (uuid: string) => void;
25+
setStaged: (staged: CreatorItemDrawing | null) => void;
2126
setStatus: (status: CreatorStatus) => void;
27+
setupDrawing: (location: number) => void;
2228
};
2329

2430
const DrawingAnnotations = (props: Props): JSX.Element => {
2531
const {
2632
activeAnnotationId,
2733
addDrawingPathGroup,
2834
annotations,
35+
canShowPopupToolbar,
2936
drawnPathGroups,
3037
isCreating,
3138
location,
39+
resetDrawing,
3240
setActiveAnnotationId,
33-
setDrawingLocation,
41+
setReferenceId,
42+
setStaged,
3443
setStatus,
44+
setupDrawing,
3545
} = props;
46+
const [isDrawing, setIsDrawing] = React.useState<boolean>(false);
3647
const [stagedRootEl, setStagedRootEl] = React.useState<DrawingSVGRef | null>(null);
3748
const hasDrawnPathGroups = drawnPathGroups.length > 0;
38-
const uuidRef = React.useRef<string>(uuid.v4());
49+
const stagedGroupRef = React.useRef<SVGGElement>(null);
50+
const { current: drawingSVGGroup } = stagedGroupRef;
3951

4052
const handleAnnotationActive = (annotationId: string | null): void => {
4153
setActiveAnnotationId(annotationId);
4254
};
55+
const handleDelete = (): void => {
56+
resetDrawing();
57+
};
58+
const handleDrawingMount = (uuid: string): void => {
59+
setReferenceId(uuid);
60+
};
61+
const handleReply = (): void => {
62+
setStaged({ location, pathGroups: drawnPathGroups });
63+
setStatus(CreatorStatus.staged);
64+
};
4365
const handleStart = (): void => {
66+
setupDrawing(location);
4467
setStatus(CreatorStatus.started);
45-
setDrawingLocation(location);
68+
setIsDrawing(true);
4669
};
4770
const handleStop = (pathGroup: PathGroup): void => {
4871
addDrawingPathGroup(pathGroup);
72+
setIsDrawing(false);
4973
};
5074

5175
return (
@@ -60,7 +84,7 @@ const DrawingAnnotations = (props: Props): JSX.Element => {
6084
{hasDrawnPathGroups && (
6185
<DrawingSVG ref={setStagedRootEl} className="ba-DrawingAnnotations-target">
6286
{/* Group element to capture the bounding box around the drawn path groups */}
63-
<g data-ba-reference-id={uuidRef.current}>
87+
<DrawingSVGGroup ref={stagedGroupRef} onMount={handleDrawingMount}>
6488
{drawnPathGroups.map(({ clientId: pathGroupClientId, paths, stroke }) => (
6589
<DrawingPathGroup key={pathGroupClientId} rootEl={stagedRootEl} stroke={stroke}>
6690
{/* Use the children render function to pass down the calculated strokeWidthWithBorder value */}
@@ -76,13 +100,22 @@ const DrawingAnnotations = (props: Props): JSX.Element => {
76100
}
77101
</DrawingPathGroup>
78102
))}
79-
</g>
103+
</DrawingSVGGroup>
80104
</DrawingSVG>
81105
)}
82106

83107
{isCreating && (
84108
<DrawingCreator className="ba-DrawingAnnotations-creator" onStart={handleStart} onStop={handleStop} />
85109
)}
110+
111+
{isCreating && hasDrawnPathGroups && drawingSVGGroup && canShowPopupToolbar && (
112+
<PopupDrawingToolbar
113+
className={classNames('ba-DrawingAnnotations-toolbar', { 'ba-is-drawing': isDrawing })}
114+
onDelete={handleDelete}
115+
onReply={handleReply}
116+
reference={drawingSVGGroup}
117+
/>
118+
)}
86119
</>
87120
);
88121
};

0 commit comments

Comments
 (0)