Skip to content

Commit

Permalink
feat(imagehotspots): add selecting and creating hotspots
Browse files Browse the repository at this point in the history
  • Loading branch information
bjornalm committed Nov 17, 2020
1 parent fc4adf7 commit 3133d03
Show file tree
Hide file tree
Showing 11 changed files with 1,263 additions and 91 deletions.
246 changes: 226 additions & 20 deletions src/components/Dashboard/__snapshots__/Dashboard.story.storyshot

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2495,6 +2495,7 @@ exports[`Storybook Snapshot tests and console checks Storyshots Watson IoT/Dashb
src="static/media/landscape.013ce39d.jpg"
style={
Object {
"cursor": "auto",
"left": undefined,
"position": "relative",
"top": undefined,
Expand All @@ -2514,7 +2515,17 @@ exports[`Storybook Snapshot tests and console checks Storyshots Watson IoT/Dashb
}
>
<div
className="Hotspot__StyledHotspot-nlpwmi-0 fxwZek"
className="iot--hotspot-container iot--hotspot-container--has-icon"
data-testid="hotspot-35-65"
icon="arrowDown"
style={
Object {
"--height": 25,
"--width": 25,
"--x-pos": 35,
"--y-pos": 65,
}
}
>
<div
aria-describedby={null}
Expand Down
77 changes: 39 additions & 38 deletions src/components/ImageCard/Hotspot.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import classNames from 'classnames';
import { Tooltip } from 'carbon-components-react';
import { spacing02 } from '@carbon/layout';

import { settings } from '../../constants/Settings';

import { HotspotContentPropTypes } from './HotspotContent';
import CardIcon from './CardIcon';

const { iotPrefix } = settings;

export const propTypes = {
/** percentage from the left of the image to show this hotspot */
x: PropTypes.number.isRequired,
Expand All @@ -30,6 +33,12 @@ export const propTypes = {
height: PropTypes.number,
/** optional function to provide icon based on name */
renderIconByName: PropTypes.func,
/**
* onClick callback for when the hotspot is clicked. Returns the event and an
* object width the x and y coordinates */
onClick: PropTypes.func,
/** shows a border with padding when set to true */
isSelected: PropTypes.bool,
};

const defaultProps = {
Expand All @@ -39,40 +48,10 @@ const defaultProps = {
width: 25,
height: 25,
renderIconByName: null,
onClick: null,
isSelected: false,
};

const StyledHotspot = styled(({ className, children }) => (
<div className={className}>{children}</div>
))`
position: absolute;
${(props) => `
top: calc(${props.y}% - ${props.height / 2}px);
left: calc(${props.x}% - ${props.width / 2}px);
`}
font-family: Sans-Serif;
pointer-events: auto;
.bx--tooltip__label {
${(props) =>
props.icon
? `
border: solid 1px #aaa;
cursor: pointer;
padding: ${spacing02};
background: white;
opacity: 0.9;
border-radius: 4px;
box-shadow: 0 0 8px #777;
`
: `
cursor: pointer;
box-shadow: 0 0 4px #999;
border-radius: 13px;
background: none;
`}
}
`;

/**
* This component renders a hotspot with content over an image
*/
Expand All @@ -86,6 +65,9 @@ const Hotspot = ({
width,
height,
renderIconByName,
onClick,
isSelected,
className,
...others
}) => {
const defaultIcon = (
Expand Down Expand Up @@ -123,17 +105,36 @@ const Hotspot = ({
defaultIcon
);

const id = `hotspot-${x}-${y}`;

return (
<StyledHotspot x={x} y={y} width={width} height={height} icon={icon}>
<div
data-testid={id}
className={classNames(`${iotPrefix}--hotspot-container`, {
[`${iotPrefix}--hotspot-container--selected`]: isSelected,
[`${iotPrefix}--hotspot-container--has-icon`]: icon,
})}
style={{
'--x-pos': x,
'--y-pos': y,
'--width': width,
'--height': height,
}}
icon={icon}>
<Tooltip
{...others}
triggerText={iconToRender}
showIcon={false}
triggerId={`hotspot-${x}-${y}`}
tooltipId={`hotspot-${x}-${y}`}>
triggerId={id}
tooltipId={id}
onChange={(evt) => {
if (evt.type === 'click' && onClick) {
onClick(evt, { x, y });
}
}}>
{content}
</Tooltip>
</StyledHotspot>
</div>
);
};

Expand Down
120 changes: 115 additions & 5 deletions src/components/ImageCard/ImageHotspots.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import useDeepCompareEffect from 'use-deep-compare-effect';
import PropTypes from 'prop-types';
import { InlineLoading } from 'carbon-components-react';
Expand All @@ -19,18 +19,28 @@ const propTypes = {
hideZoomControls: PropTypes.bool,
hideHotspots: PropTypes.bool,
hideMinimap: PropTypes.bool,
/** when true activates mouse event based create & select hotspot fuctionality */
isEditable: PropTypes.bool,
isHotspotDataLoading: PropTypes.bool,
/** Background color to display around the image */
background: PropTypes.string,
/** Current height in pixels */
height: PropTypes.number.isRequired,
/** Callback when an editable image is clicked without drag */
onAddHotspotPosition: PropTypes.func,
/** Callback when a hotspot is clicked in isEditable mode, emits position obj {x, y} */
onSelectHotspot: PropTypes.func,
/** Current width in pixels */
width: PropTypes.number.isRequired,
zoomMax: PropTypes.number,
renderIconByName: PropTypes.func,
i18n: PropTypes.objectOf(PropTypes.string),
/** locale string to pass for formatting */
locale: PropTypes.string,
/** The (unique) positions of the currently selected hotspots */
selectedHotspots: PropTypes.arrayOf(
PropTypes.shape({ x: PropTypes.number, y: PropTypes.number })
),
};

const defaultProps = {
Expand All @@ -41,6 +51,9 @@ const defaultProps = {
hideHotspots: false,
hideMinimap: false,
isHotspotDataLoading: false,
isEditable: false,
onAddHotspotPosition: () => {},
onSelectHotspot: () => {},
background: '#eee',
zoomMax: undefined,
renderIconByName: null,
Expand All @@ -50,6 +63,18 @@ const defaultProps = {
zoomToFit: 'Zoom to fit',
},
locale: null,
selectedHotspots: [],
};

export const prepareDrag = (event, element, cursor, setCursor) => {
if (element === 'image') {
setCursor({
...cursor,
dragging: false,
dragPrepared: true,
});
}
event.preventDefault();
};

export const startDrag = (event, element, cursor, setCursor) => {
Expand All @@ -61,6 +86,7 @@ export const startDrag = (event, element, cursor, setCursor) => {
cursorX,
cursorY,
dragging: true,
dragPrepared: false,
});
}
event.preventDefault();
Expand Down Expand Up @@ -322,6 +348,49 @@ export const zoom = (
}
};

const getAccumulatedOffset = (imageElement) => {
const offset = {
top: imageElement.offsetTop,
left: imageElement.offsetLeft,
};

let ancestor = imageElement.offsetParent;

while (ancestor) {
offset.top += ancestor.offsetTop;
offset.left += ancestor.offsetLeft;
ancestor = ancestor.offsetParent;
}

return offset;
};

/** Calculates the mouse click position in percentage and returns the
* result in a callback */
export const onAddHotspotPosition = ({
event,
image,
setCursor,
isEditable,
callback,
}) => {
setCursor((cursor) => {
return { ...cursor, dragPrepared: false };
});
if (isEditable) {
const accumelatedOffset = getAccumulatedOffset(event.currentTarget);
const relativePosition = {
x: event.pageX - accumelatedOffset.left,
y: event.pageY - accumelatedOffset.top,
};
const percentagePosition = {
x: (relativePosition.x / image.width) * 100,
y: (relativePosition.y / image.height) * 100,
};
callback(percentagePosition);
}
};

/** Parent smart component with local state that renders an image with its hotspots */
const ImageHotspots = ({
hideZoomControls: hideZoomControlsProp,
Expand All @@ -334,10 +403,14 @@ const ImageHotspots = ({
height,
width,
alt,
isEditable,
isHotspotDataLoading,
onAddHotspotPosition: onAddHotspotPositionCallback,
onSelectHotspot,
zoomMax,
renderIconByName,
locale,
selectedHotspots,
}) => {
// Image needs to be stored in state because we're dragging it around when zoomed in, and we need to keep track of when it loads
const [image, setImage] = useState({});
Expand Down Expand Up @@ -377,6 +450,7 @@ const ImageHotspots = ({
}, [container, zoomMax, image, minimap, options]);

const { dragging } = cursor;
const { dragPrepared } = cursor;
const { hideZoomControls, hideHotspots, hideMinimap, draggable } = options;
const imageLoaded = image.initialWidth && image.initialHeight;

Expand All @@ -390,6 +464,7 @@ const ImageHotspots = ({
};

const imageStyle = {
cursor: isEditable && !dragging ? 'crosshair' : 'auto',
position: 'relative',
left: image.offsetX,
top: image.offsetY,
Expand All @@ -404,10 +479,26 @@ const ImageHotspots = ({
pointerEvents: 'none',
};

const onHotspotClicked = useCallback(
(evt, position) => {
// It is possible to receive two events here, one Mouse event and one Pointer event.
// When used in the ImageHotspots component the Pointer event can somehow be from a
// previously clicked hotspot. See issue #1803
const isPointerEventOfTypeMouse = evt?.pointerType === 'mouse';
if (!isPointerEventOfTypeMouse && isEditable) {
onSelectHotspot(position);
}
},
[onSelectHotspot, isEditable]
);

// Performance improvement
const cachedHotspots = useMemo(
() =>
hotspots.map((hotspot) => {
const hotspotIsSelected = !!selectedHotspots.find(
(pos) => hotspot.x === pos.x && hotspot.y === pos.y
);
return (
<Hotspot
{...omit(hotspot, 'content')}
Expand All @@ -425,10 +516,19 @@ const ImageHotspots = ({
key={`${hotspot.x}-${hotspot.y}`}
style={hotspotsStyle}
renderIconByName={renderIconByName}
isSelected={hotspotIsSelected}
onClick={onHotspotClicked}
/>
);
}),
[hotspots, hotspotsStyle, locale, renderIconByName]
[
hotspots,
hotspotsStyle,
locale,
renderIconByName,
selectedHotspots,
onHotspotClicked,
]
);

if (imageLoaded) {
Expand Down Expand Up @@ -481,11 +581,13 @@ const ImageHotspots = ({
style={imageStyle}
onMouseDown={(evt) => {
if (!hideZoomControls && draggable) {
startDrag(evt, 'image', cursor, setCursor);
prepareDrag(evt, 'image', cursor, setCursor);
}
}}
onMouseMove={(evt) => {
if (!hideZoomControls && dragging) {
if (!hideZoomControls && draggable && dragPrepared) {
startDrag(evt, 'image', cursor, setCursor);
} else if (!hideZoomControls && dragging) {
whileDrag(
evt,
cursor,
Expand All @@ -497,9 +599,17 @@ const ImageHotspots = ({
);
}
}}
onMouseUp={() => {
onMouseUp={(event) => {
if (dragging) {
stopDrag(cursor, setCursor);
} else {
onAddHotspotPosition({
event,
image,
setCursor,
isEditable,
callback: onAddHotspotPositionCallback,
});
}
}}
/>
Expand Down
Loading

0 comments on commit 3133d03

Please sign in to comment.