diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx index 4b5587bf4de..72f7bdb2923 100644 --- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx +++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; import { GrafanaTheme2 } from '@grafana/data'; @@ -8,16 +8,26 @@ import { useDispatch, useSelector } from 'app/types/store'; import { useTransformContext } from '../context/TransformContext'; import { useMockCursors } from '../hooks/useMockCursors'; -import { selectPanel, updateViewport } from '../state/exploreMapSlice'; +import { selectMultiplePanels, selectPanel, updateViewport } from '../state/exploreMapSlice'; import { ExploreMapPanelContainer } from './ExploreMapPanelContainer'; import { UserCursor } from './UserCursor'; +interface SelectionRect { + startX: number; + startY: number; + currentX: number; + currentY: number; +} + export function ExploreMapCanvas() { const styles = useStyles2(getStyles); const dispatch = useDispatch(); const canvasRef = useRef(null); const { transformRef: contextTransformRef } = useTransformContext(); + const [selectionRect, setSelectionRect] = useState(null); + const [isSelecting, setIsSelecting] = useState(false); + const justCompletedSelectionRef = useRef(false); const panels = useSelector((state) => state.exploreMap.panels); const viewport = useSelector((state) => state.exploreMap.viewport); @@ -28,6 +38,12 @@ export function ExploreMapCanvas() { const handleCanvasClick = useCallback( (e: React.MouseEvent) => { + // Don't deselect if we just finished a selection drag + if (justCompletedSelectionRef.current) { + justCompletedSelectionRef.current = false; + return; + } + // Only deselect if clicking directly on canvas (not on panels) if (e.target === e.currentTarget) { dispatch(selectPanel({ panelId: undefined })); @@ -36,6 +52,101 @@ export function ExploreMapCanvas() { [dispatch] ); + const handleCanvasMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only start selection if clicking directly on canvas (not on panels) + if (e.target !== e.currentTarget) { + return; + } + + // Don't start selection on middle or right click + if (e.button !== 0) { + return; + } + + const canvasX = e.nativeEvent.offsetX; + const canvasY = e.nativeEvent.offsetY; + + setSelectionRect({ + startX: canvasX, + startY: canvasY, + currentX: canvasX, + currentY: canvasY, + }); + setIsSelecting(true); + }, + [] + ); + + const handleCanvasMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isSelecting || !selectionRect) { + return; + } + + const canvasX = e.nativeEvent.offsetX; + const canvasY = e.nativeEvent.offsetY; + + setSelectionRect({ + ...selectionRect, + currentX: canvasX, + currentY: canvasY, + }); + }, + [isSelecting, selectionRect] + ); + + const handleCanvasMouseUp = useCallback( + (e: React.MouseEvent) => { + if (!isSelecting || !selectionRect) { + return; + } + + console.log('Selection end:', selectionRect); + + // Calculate selection rectangle bounds + const minX = Math.min(selectionRect.startX, selectionRect.currentX); + const maxX = Math.max(selectionRect.startX, selectionRect.currentX); + const minY = Math.min(selectionRect.startY, selectionRect.currentY); + const maxY = Math.max(selectionRect.startY, selectionRect.currentY); + + console.log('Selection bounds:', { minX, maxX, minY, maxY }); + + // Find panels that intersect with selection rectangle + const selectedPanelIds = Object.values(panels).filter((panel) => { + const panelLeft = panel.position.x; + const panelRight = panel.position.x + panel.position.width; + const panelTop = panel.position.y; + const panelBottom = panel.position.y + panel.position.height; + + const intersects = !(panelRight < minX || panelLeft > maxX || panelBottom < minY || panelTop > maxY); + console.log('Panel', panel.id, 'bounds:', { panelLeft, panelRight, panelTop, panelBottom }, 'intersects:', intersects); + + return intersects; + }).map((panel) => panel.id); + + console.log('Selected panel IDs:', selectedPanelIds); + + // Check if Cmd/Ctrl is held for additive selection + const isAdditive = e.metaKey || e.ctrlKey; + + if (selectedPanelIds.length > 0) { + // Select all panels at once + console.log('Dispatching selectMultiplePanels with:', { panelIds: selectedPanelIds, addToSelection: isAdditive }); + dispatch(selectMultiplePanels({ panelIds: selectedPanelIds, addToSelection: isAdditive })); + console.log('After dispatch'); + justCompletedSelectionRef.current = true; + } else if (!isAdditive) { + // Clear selection if no panels selected and not holding modifier + dispatch(selectPanel({ panelId: undefined })); + } + + setSelectionRect(null); + setIsSelecting(false); + }, + [isSelecting, selectionRect, panels, dispatch] + ); + const handleTransformChange = useCallback( (ref: ReactZoomPanPinchRef) => { dispatch( @@ -105,6 +216,9 @@ export function ExploreMapCanvas() { panning={{ disabled: false, excluded: ['panel-drag-handle', 'react-rnd'], + allowLeftClickPan: false, + allowRightClickPan: false, + allowMiddleClickPan: true, }} onTransformed={handleTransformChange} doubleClick={{ disabled: true }} @@ -115,6 +229,9 @@ export function ExploreMapCanvas() { ref={canvasRef} className={styles.canvas} onClick={handleCanvasClick} + onMouseDown={handleCanvasMouseDown} + onMouseMove={handleCanvasMouseMove} + onMouseUp={handleCanvasMouseUp} onKeyDown={(e) => { if (e.key === 'Escape') { dispatch(selectPanel({ panelId: undefined })); @@ -129,6 +246,17 @@ export function ExploreMapCanvas() { {Object.values(cursors).map((cursor) => ( ))} + {selectionRect && ( +
+ )}
@@ -168,5 +296,12 @@ const getStyles = (theme: GrafanaTheme2) => { backgroundSize: '20px 20px', backgroundPosition: '-1px -1px', }), + selectionRect: css({ + position: 'absolute', + border: `2px solid ${theme.colors.primary.border}`, + backgroundColor: theme.colors.primary.transparent, + pointerEvents: 'none', + zIndex: 9999, + }), }; }; diff --git a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx index 8109f315e45..2fdc17e5704 100644 --- a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx +++ b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd'; import { GrafanaTheme2 } from '@grafana/data'; @@ -13,6 +13,7 @@ import { duplicatePanel, removePanel, selectPanel, + updateMultiplePanelPositions, updatePanelPosition, } from '../state/exploreMapSlice'; import { ExploreMapPanel } from '../state/types'; @@ -27,21 +28,79 @@ export function ExploreMapPanelContainer({ panel }: ExploreMapPanelContainerProp const styles = useStyles2(getStyles); const dispatch = useDispatch(); const rndRef = useRef(null); + const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null); - const selectedPanelId = useSelector((state) => state.exploreMap.selectedPanelId); + const selectedPanelIds = useSelector((state) => state.exploreMap.selectedPanelIds || []); const viewport = useSelector((state) => state.exploreMap.viewport); - const isSelected = selectedPanelId === panel.id; + const isSelected = selectedPanelIds.includes(panel.id); + + const handleDragStart: RndDragCallback = useCallback( + (_e, data) => { + setDragStartPos({ x: data.x, y: data.y }); + }, + [] + ); + + const handleDrag: RndDragCallback = useCallback( + (_e, data) => { + if (!dragStartPos) { + return; + } + + const deltaX = data.x - dragStartPos.x; + const deltaY = data.y - dragStartPos.y; + + // If this panel is selected and there are multiple selections, move all others + if (isSelected && selectedPanelIds.length > 1) { + dispatch( + updateMultiplePanelPositions({ + panelId: panel.id, + deltaX, + deltaY, + }) + ); + setDragStartPos({ x: data.x, y: data.y }); + } + }, + [dispatch, panel.id, dragStartPos, isSelected, selectedPanelIds.length] + ); const handleDragStop: RndDragCallback = useCallback( (_e, data) => { - dispatch( - updatePanelPosition({ - panelId: panel.id, - position: { x: data.x, y: data.y }, - }) - ); + if (dragStartPos) { + const deltaX = data.x - dragStartPos.x; + const deltaY = data.y - dragStartPos.y; + + // Final position update for all panels + if (isSelected && selectedPanelIds.length > 1) { + // Update other panels with the remaining delta + dispatch( + updateMultiplePanelPositions({ + panelId: panel.id, + deltaX, + deltaY, + }) + ); + // Update the dragged panel's position in Redux + dispatch( + updatePanelPosition({ + panelId: panel.id, + position: { x: data.x, y: data.y }, + }) + ); + } else { + // Single panel drag + dispatch( + updatePanelPosition({ + panelId: panel.id, + position: { x: data.x, y: data.y }, + }) + ); + } + } + setDragStartPos(null); }, - [dispatch, panel.id] + [dispatch, panel.id, dragStartPos, isSelected, selectedPanelIds.length] ); const handleResizeStop: RndResizeCallback = useCallback( @@ -67,10 +126,28 @@ export function ExploreMapPanelContainer({ panel }: ExploreMapPanelContainerProp [dispatch, panel.id] ); - const handleMouseDown = useCallback(() => { - dispatch(selectPanel({ panelId: panel.id })); - dispatch(bringPanelToFront({ panelId: panel.id })); - }, [dispatch, panel.id]); + const handleMouseDown = useCallback( + (e: MouseEvent) => { + // Check for Cmd (Mac) or Ctrl (Windows/Linux) key + const isMultiSelect = e.metaKey || e.ctrlKey; + + // If this panel is already selected and we're not multi-selecting, + // don't change selection (allows dragging multiple selected panels) + if (isSelected && !isMultiSelect) { + // Just bring to front, don't change selection + dispatch(bringPanelToFront({ panelId: panel.id })); + return; + } + + dispatch(selectPanel({ panelId: panel.id, addToSelection: isMultiSelect })); + + // Only bring to front if not multi-selecting + if (!isMultiSelect) { + dispatch(bringPanelToFront({ panelId: panel.id })); + } + }, + [dispatch, panel.id, isSelected] + ); const handleRemove = useCallback( (e: React.MouseEvent) => { @@ -97,6 +174,8 @@ export function ExploreMapPanelContainer({ panel }: ExploreMapPanelContainerProp position={{ x: panel.position.x, y: panel.position.y }} size={{ width: panel.position.width, height: panel.position.height }} scale={viewport.zoom} + onDragStart={handleDragStart} + onDrag={handleDrag} onDragStop={handleDragStop} onResizeStop={handleResizeStop} onMouseDown={handleMouseDown} diff --git a/public/app/features/explore-map/hooks/useCanvasPersistence.ts b/public/app/features/explore-map/hooks/useCanvasPersistence.ts index 1234a4214dc..1a3ccded4da 100644 --- a/public/app/features/explore-map/hooks/useCanvasPersistence.ts +++ b/public/app/features/explore-map/hooks/useCanvasPersistence.ts @@ -34,6 +34,7 @@ export function useCanvasPersistence() { // Enrich exploreMapState with current Explore state for each panel const enrichedState: ExploreMapState = { ...exploreMapState, + selectedPanelIds: [], // Don't persist selection state cursors: {}, // Don't persist cursor state - it's ephemeral panels: Object.fromEntries( Object.entries(exploreMapState.panels).map(([panelId, panel]) => { @@ -74,6 +75,7 @@ export function useCanvasPersistence() { const enrichedState: ExploreMapState = { ...exploreMapState, viewport: initialExploreMapState.viewport, // Don't export viewport state - use initial centered viewport + selectedPanelIds: [], // Don't export selection state cursors: {}, // Don't export cursor state - it's ephemeral panels: Object.fromEntries( Object.entries(exploreMapState.panels).map(([panelId, panel]) => { diff --git a/public/app/features/explore-map/state/exploreMapSlice.ts b/public/app/features/explore-map/state/exploreMapSlice.ts index 9aed3476a9e..a2e5b2c9c21 100644 --- a/public/app/features/explore-map/state/exploreMapSlice.ts +++ b/public/app/features/explore-map/state/exploreMapSlice.ts @@ -48,14 +48,12 @@ const exploreMapSlice = createSlice({ position: { ...defaultPosition, ...action.payload.position }, }; state.nextZIndex++; - state.selectedPanelId = panelId; + state.selectedPanelIds = [panelId]; }, removePanel: (state, action: PayloadAction<{ panelId: string }>) => { delete state.panels[action.payload.panelId]; - if (state.selectedPanelId === action.payload.panelId) { - state.selectedPanelId = undefined; - } + state.selectedPanelIds = state.selectedPanelIds.filter((id) => id !== action.payload.panelId); }, updatePanelPosition: ( @@ -68,6 +66,36 @@ const exploreMapSlice = createSlice({ } }, + updateMultiplePanelPositions: ( + state, + action: PayloadAction<{ panelId: string; deltaX: number; deltaY: number }> + ) => { + const { panelId, deltaX, deltaY } = action.payload; + + // If the dragged panel is selected, move all selected panels EXCEPT the dragged one + // (the dragged panel is controlled by react-rnd) + if (state.selectedPanelIds.includes(panelId)) { + state.selectedPanelIds.forEach((id) => { + // Skip the panel being dragged - react-rnd controls it + if (id === panelId) { + return; + } + const panel = state.panels[id]; + if (panel) { + panel.position.x += deltaX; + panel.position.y += deltaY; + } + }); + } else { + // If dragging a non-selected panel, just move that one + const panel = state.panels[panelId]; + if (panel) { + panel.position.x += deltaX; + panel.position.y += deltaY; + } + } + }, + bringPanelToFront: (state, action: PayloadAction<{ panelId: string }>) => { const panel = state.panels[action.payload.panelId]; if (panel) { @@ -76,15 +104,57 @@ const exploreMapSlice = createSlice({ } }, - selectPanel: (state, action: PayloadAction<{ panelId?: string }>) => { - state.selectedPanelId = action.payload.panelId; - if (action.payload.panelId) { - const panel = state.panels[action.payload.panelId]; + selectPanel: (state, action: PayloadAction<{ panelId?: string; addToSelection?: boolean }>) => { + const { panelId, addToSelection } = action.payload; + + if (!panelId) { + // Clear selection + state.selectedPanelIds = []; + return; + } + + if (addToSelection) { + // Toggle panel in selection + if (state.selectedPanelIds.includes(panelId)) { + state.selectedPanelIds = state.selectedPanelIds.filter((id) => id !== panelId); + } else { + state.selectedPanelIds.push(panelId); + } + } else { + // Single selection + state.selectedPanelIds = [panelId]; + } + + // Bring all selected panels to front + state.selectedPanelIds.forEach((id) => { + const panel = state.panels[id]; if (panel) { panel.position.zIndex = state.nextZIndex; state.nextZIndex++; } + }); + }, + + selectMultiplePanels: (state, action: PayloadAction<{ panelIds: string[]; addToSelection?: boolean }>) => { + const { panelIds, addToSelection } = action.payload; + + if (addToSelection) { + // Add to existing selection (dedupe) + const newIds = panelIds.filter((id) => !state.selectedPanelIds.includes(id)); + state.selectedPanelIds = [...state.selectedPanelIds, ...newIds]; + } else { + // Replace selection + state.selectedPanelIds = panelIds; } + + // Bring all selected panels to front + state.selectedPanelIds.forEach((id) => { + const panel = state.panels[id]; + if (panel) { + panel.position.zIndex = state.nextZIndex; + state.nextZIndex++; + } + }); }, updateViewport: (state, action: PayloadAction>) => { @@ -93,7 +163,7 @@ const exploreMapSlice = createSlice({ resetCanvas: (state) => { state.panels = {}; - state.selectedPanelId = undefined; + state.selectedPanelIds = []; state.nextZIndex = 1; state.viewport = initialExploreMapState.viewport; }, @@ -115,7 +185,7 @@ const exploreMapSlice = createSlice({ exploreState: sourcePanel.exploreState, }; state.nextZIndex++; - state.selectedPanelId = newPanelId; + state.selectedPanelIds = [newPanelId]; } }, @@ -130,7 +200,10 @@ const exploreMapSlice = createSlice({ }, loadCanvas: (state, action: PayloadAction) => { - return action.payload; + const loadedState = action.payload; + // Clear any selection state from loaded data + loadedState.selectedPanelIds = []; + return loadedState; }, updateCursor: (state, action: PayloadAction) => { @@ -147,8 +220,10 @@ export const { addPanel, removePanel, updatePanelPosition, + updateMultiplePanelPositions, bringPanelToFront, selectPanel, + selectMultiplePanels, updateViewport, resetCanvas, duplicatePanel, diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts index 4862a8f6c44..0110d766d0d 100644 --- a/public/app/features/explore-map/state/types.ts +++ b/public/app/features/explore-map/state/types.ts @@ -42,7 +42,7 @@ export interface UserCursor { export interface ExploreMapState { viewport: CanvasViewport; panels: Record; - selectedPanelId?: string; + selectedPanelIds: string[]; nextZIndex: number; cursors: Record; } @@ -58,7 +58,7 @@ export const initialExploreMapState: ExploreMapState = { panY: -4460, // -(5000 - 1080/2) = -4460 }, panels: {}, - selectedPanelId: undefined, + selectedPanelIds: [], nextZIndex: 1, cursors: {}, };