Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ const HotspotEditorModal = ({
switchCurrentType,
updateHotspotDataSource,
updateHotspotTooltip,
updateHotspotPosition,
updateTextHotspotStyle,
updateTextHotspotContent,
updateDynamicHotspotSourceX,
Expand Down Expand Up @@ -649,6 +650,7 @@ const HotspotEditorModal = ({
hotspotDefaults,
});
}}
onUpdateHotspotPosition={updateHotspotPosition}
onSelectHotspot={setSelectedHotspot}
selectedHotspots={getSelectedHotspotsList(selectedHotspot, hotspots)}
src={cardConfig.content.src}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const hotspotActionTypes = {
hotspotDataSourceChange: 'HOTSPOT_DATA_SOURCE_CHANGE',
hotspotDataSourceSettingsChange: 'HOTSPOT_DATA_SOURCE_SETTINGS_CHANGE',
hotspotTooltipChange: 'HOTSPOT_TOOLTIP_CHANGE',
hotspotPositionChange: 'HOTSPOT_POSITION_CHANGE',
hotspotSelect: 'HOTSPOT_SELECT',
hotspotsAdd: 'HOTSPOTS_ADD',
textHotspotStyleChange: 'TEXT_HOTSPOT_STYLE_CHANGE',
Expand Down Expand Up @@ -119,6 +120,17 @@ function hotspotEditorReducer(state, { type, payload }) {
: { $merge: payload };
return getHotspotUpdate(state, mergeSpec);
}
// HOTSPOT POSITION CHANGE
case hotspotActionTypes.hotspotPositionChange: {
const isPositionAvailable = !state.hotspots.find((hotspot) =>
isHotspotMatch(hotspot, payload.position)
);
return isPositionAvailable
? update(state, {
hotspots: { $set: payload.newHotspots },
})
: state;
}
// HOTSPOTS ADD
case hotspotActionTypes.hotspotsAdd: {
const isPositionAvailable = !state.hotspots.find((hotspot) =>
Expand Down Expand Up @@ -152,7 +164,7 @@ function hotspotEditorReducer(state, { type, payload }) {

return update(state, {
selectedHotspot: { $set: hotspot },
currentType: { $set: hotspot.type ?? defaultTypeWhenMissing },
currentType: { $set: hotspot?.type ?? defaultTypeWhenMissing },
});
}
// TEXT HOTSPOT STYLE CHANGE
Expand Down Expand Up @@ -314,6 +326,12 @@ function useHotspotEditorState({ reducer = hotspotEditorReducer, initialState =
payload: hotspotContent,
});

const updateHotspotPosition = (hotspotPosition) =>
dispatch({
type: hotspotActionTypes.hotspotPositionChange,
payload: hotspotPosition,
});

/** Updates the properties of the text hotspot, passes a payload like {color: 'blue'} */
const updateTextHotspotStyle = (textHotspotStyle) =>
dispatch({
Expand Down Expand Up @@ -397,6 +415,7 @@ function useHotspotEditorState({ reducer = hotspotEditorReducer, initialState =
switchCurrentType,
updateHotspotDataSource,
updateHotspotTooltip,
updateHotspotPosition,
updateTextHotspotStyle,
updateTextHotspotContent,
updateDynamicHotspotSourceX,
Expand Down
157 changes: 153 additions & 4 deletions packages/react/src/components/ImageCard/ImageHotspots.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import React, { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { InlineLoading } from '@carbon/react';
import { omit, isEmpty } from 'lodash-es';
Expand Down Expand Up @@ -63,6 +63,10 @@ const propTypes = {
* Emits position obj {x, y} of hotspot to be added.
*/
onAddHotspotPosition: PropTypes.func,
/** Callback when a hotspot is dragged to new position in isEditable mode
* Emits new hotspots and updated position obj {x, y} of hotspot.
*/
onUpdateHotspotPosition: PropTypes.func,
/** Callback when a hotspot is clicked in isEditable mode, emits position obj {x, y} */
onSelectHotspot: PropTypes.func,
/**
Expand Down Expand Up @@ -103,6 +107,7 @@ const defaultProps = {
isHotspotDataLoading: false,
isEditable: false,
onAddHotspotPosition: () => {},
onUpdateHotspotPosition: () => {},
onSelectHotspot: () => {},
onHotspotContentChanged: () => {},
background: '#eee',
Expand Down Expand Up @@ -245,37 +250,43 @@ export const calculateHotspotContainerLayout = (
let width;
let height;
let top;
let left;

// CONTAIN
if (objectFit === 'contain') {
if (imageOrientation === 'landscape') {
width = imageWidth;
height = imageWidth / imageRatio;
top = imageScale > 1 ? imageOffsetY : imageObjectFitOffsetY;
left = imageScale > 1 ? 0 : (containerWidth - imageWidth) / 2;
} else if (imageOrientation === 'portrait') {
width = imageHeight / imageRatio;
height = imageHeight;
top = imageOffsetY;
left = (containerWidth - width) / 2;
}
// FILL
} else if (objectFit === 'fill') {
width = imageScale > 1 ? imageWidth : containerWidth;
height = imageScale > 1 ? imageHeight : containerHeight;
top = imageOffsetY;
left = 0;
// NO OBJECT FIT
} else if (!objectFit) {
if (imageOrientation === 'landscape') {
width = imageWidth;
height = imageWidth / imageRatio;
top = imageOffsetY;
left = 0;
} else if (imageOrientation === 'portrait') {
width = imageHeight / imageRatio;
height = imageHeight;
top = imageOffsetY;
left = (containerWidth - width) / 2;
}
}

return { width, height, top };
return { width, height, top, left };
};

export const calculateObjectFitOffset = ({ displayOption, container, image }) => {
Expand Down Expand Up @@ -556,6 +567,7 @@ const ImageHotspots = ({
isEditable,
isHotspotDataLoading,
onAddHotspotPosition,
onUpdateHotspotPosition,
onSelectHotspot,
onHotspotContentChanged,
zoomMax,
Expand Down Expand Up @@ -583,9 +595,42 @@ const ImageHotspots = ({
hideHotspots: hideHotspotsProp,
hideMinimap: minimapBehavior !== 'show',
});
// Tracks if a hotspot is being dragged and which one.
const [draggingHotspotId, setDraggingHotspotId] = useState(null);

// Ref for outer container
const containerRef = useRef(null);
const dragStateRef = useRef({
// Flag to indicate if a drag is active
isDragging: false,
// Stores the starting mouse position of a drag
dragStartPosition: { x: 0, y: 0 },
// Tracks the current position during a drag
currentDargPosition: null,
// Stores layout of image and container
layout: { rect: null, hotspotLayout: null },
// Tracks Scheduled Animation Frames
frame: null,
});

// Minimum pixel movement before a drag is initiated
const DRAG_THRESHOLD = 5;

const mergedI18n = useMemo(() => ({ ...defaultProps.i18n, ...i18n }), [i18n]);

const hotspotsWithId = useMemo(() => {
return hotspots.map((hotspot, index) => ({
...hotspot,
id: `${hotspot.x}-${hotspot.y}-${index}`,
}));
}, [hotspots]);

const [editableHotspots, setEditableHotspots] = useState(hotspotsWithId);

useEffect(() => {
setEditableHotspots(hotspotsWithId);
}, [hotspotsWithId]);

const handleCtrlKeyUp = useCallback((event) => {
// Was the control key unpressed
if (event.key === keyboardKeys.CONTROL) {
Expand Down Expand Up @@ -698,6 +743,11 @@ const ImageHotspots = ({

const onHotspotClicked = useCallback(
(evt, position) => {
// prevent click behavior after drag
if (dragStateRef.current.isDragging) {
return;
}

// 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 https://github.com/carbon-design-system/carbon-addons-iot-react/issues/1803
Expand All @@ -709,6 +759,102 @@ const ImageHotspots = ({
[onSelectHotspot, isEditable]
);

const handleMouseDownHotspot = useCallback(
(e, id1) => {
if (!isEditable) return;
e.stopPropagation();
setDraggingHotspotId(id1);
dragStateRef.current.isDragging = true;
dragStateRef.current.dragStartPosition = { x: e.clientX, y: e.clientY };

// Calculate layout once at drag start
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const hotspotLayout = calculateHotspotContainerLayout(image, container, displayOption);
dragStateRef.current.layout = { rect, hotspotLayout };
}
},
[container, displayOption, image, isEditable]
);

const handleMouseMoveHotspot = useCallback(
(e) => {
if (draggingHotspotId !== null && containerRef.current) {
const { isDragging, dragStartPosition, layout } = dragStateRef.current;

const dx = e.clientX - dragStartPosition.x;
const dy = e.clientY - dragStartPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);

if (distance > DRAG_THRESHOLD && isDragging) {
const { rect, hotspotLayout } = layout;
let x = ((e.clientX - rect.left - hotspotLayout.left) / hotspotLayout.width) * 100;
let y = ((e.clientY - rect.top - hotspotLayout.top) / hotspotLayout.height) * 100;
// Clamp within image boundaries
x = Math.max(0, Math.min(100, x));
y = Math.max(0, Math.min(100, y));

dragStateRef.current.currentDargPosition = { x, y };

// throttle with requestAnimationFrame
if (dragStateRef.current.frame === null) {
dragStateRef.current.frame = requestAnimationFrame(() => {
setEditableHotspots((prev) =>
prev.map((item) =>
item.id === draggingHotspotId
? {
...item,
x: dragStateRef.current.currentDargPosition.x,
y: dragStateRef.current.currentDargPosition.y,
}
: item
)
);
dragStateRef.current.frame = null;
});
}
}
}
},
[draggingHotspotId]
);

const handleMouseUpHotspot = useCallback(
(e) => {
if (draggingHotspotId !== null && dragStateRef.current.isDragging) {
e.stopPropagation();
const { x, y } = dragStateRef.current.currentDargPosition || {};
onUpdateHotspotPosition({ newHotspots: editableHotspots, position: { x, y } });
setDraggingHotspotId(null);
dragStateRef.current.currentDargPosition = null;
dragStateRef.current.isDragging = false;
}
},
[editableHotspots, draggingHotspotId, onUpdateHotspotPosition]
);

useEffect(() => {
const dragState = dragStateRef.current;
return () => {
if (dragState.frame !== null) {
cancelAnimationFrame(dragState.frame);
}
};
}, []);

// Listens to mouse movement for dragging hotspot
useEffect(() => {
if (!isEditable) return undefined;

const containerElement = containerRef.current;
containerElement.addEventListener('mousemove', handleMouseMoveHotspot);
containerElement.addEventListener('mouseup', handleMouseUpHotspot);
return () => {
containerElement.removeEventListener('mousemove', handleMouseMoveHotspot);
containerElement.removeEventListener('mouseup', handleMouseUpHotspot);
};
}, [draggingHotspotId, handleMouseMoveHotspot, handleMouseUpHotspot, isEditable]);

const getIconRenderFunction = useCallback(() => {
return (
renderIconByName ||
Expand All @@ -733,7 +879,7 @@ const ImageHotspots = ({
// Performance improvement
const cachedHotspots = useMemo(
() =>
hotspots.map((hotspot, index) => {
editableHotspots.map((hotspot, index) => {
const { x, y } = hotspot;
const hotspotIsSelected = !!selectedHotspots.find((pos) => x === pos.x && y === pos.y);
// Determine whether the icon needs to be dynamically overridden by a threshold
Expand Down Expand Up @@ -782,18 +928,20 @@ const ImageHotspots = ({
renderIconByName={getIconRenderFunction()}
isSelected={hotspotIsSelected}
onClick={onHotspotClicked}
onMouseDown={(e) => handleMouseDownHotspot(e, hotspot.id)}
/>
);
}),
[
hotspots,
editableHotspots,
selectedHotspots,
locale,
getIconRenderFunction,
isEditable,
onHotspotContentChanged,
mergedI18n,
onHotspotClicked,
handleMouseDownHotspot,
]
);

Expand Down Expand Up @@ -851,6 +999,7 @@ const ImageHotspots = ({
stopDrag(cursor, setCursor);
}
}}
ref={containerRef}
>
{src ? (
<img
Expand Down