Select and move multiple panels together
This commit is contained in:
@@ -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: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user