Select and move multiple panels together

This commit is contained in:
Aleksandar Petrov
2025-11-28 19:32:39 -04:00
parent 751e87a9e8
commit 1becc14b2d
5 changed files with 320 additions and 29 deletions
@@ -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<HTMLDivElement>(null);
const { transformRef: contextTransformRef } = useTransformContext();
const [selectionRect, setSelectionRect] = useState<SelectionRect | null>(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<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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) => (
<UserCursor key={cursor.userId} cursor={cursor} />
))}
{selectionRect && (
<div
className={styles.selectionRect}
style={{
left: `${Math.min(selectionRect.startX, selectionRect.currentX)}px`,
top: `${Math.min(selectionRect.startY, selectionRect.currentY)}px`,
width: `${Math.abs(selectionRect.currentX - selectionRect.startX)}px`,
height: `${Math.abs(selectionRect.currentY - selectionRect.startY)}px`,
}}
/>
)}
</div>
</TransformComponent>
</TransformWrapper>
@@ -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,
}),
};
};
@@ -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<Rnd>(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}
@@ -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]) => {
@@ -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<Partial<CanvasViewport>>) => {
@@ -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<ExploreMapState>) => {
return action.payload;
const loadedState = action.payload;
// Clear any selection state from loaded data
loadedState.selectedPanelIds = [];
return loadedState;
},
updateCursor: (state, action: PayloadAction<UserCursor>) => {
@@ -147,8 +220,10 @@ export const {
addPanel,
removePanel,
updatePanelPosition,
updateMultiplePanelPositions,
bringPanelToFront,
selectPanel,
selectMultiplePanels,
updateViewport,
resetCanvas,
duplicatePanel,
@@ -42,7 +42,7 @@ export interface UserCursor {
export interface ExploreMapState {
viewport: CanvasViewport;
panels: Record<string, ExploreMapPanel>;
selectedPanelId?: string;
selectedPanelIds: string[];
nextZIndex: number;
cursors: Record<string, UserCursor>;
}
@@ -58,7 +58,7 @@ export const initialExploreMapState: ExploreMapState = {
panY: -4460, // -(5000 - 1080/2) = -4460
},
panels: {},
selectedPanelId: undefined,
selectedPanelIds: [],
nextZIndex: 1,
cursors: {},
};