Broadcast panel selection to other users, rework panel actions

This commit is contained in:
Aleksandar Petrov
2025-12-02 11:50:32 -04:00
parent 9b560f1413
commit 4579df3867
4 changed files with 192 additions and 71 deletions
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { GrafanaTheme2 } from '@grafana/data';
@@ -38,11 +38,17 @@ export function ExploreMapCanvas() {
const mapUid = useSelector((state) => selectMapUid(state.exploreMapCRDT));
// Initialize cursor sync
const { updatePosition } = useCursorSync({
const { updatePosition, updatePositionImmediate } = useCursorSync({
mapUid: mapUid || '',
enabled: !!mapUid,
});
// Send selection update immediately when selection changes
// Use position (0, 0) since we only care about the selection, not cursor position
useEffect(() => {
updatePositionImmediate(0, 0, selectedPanelIds);
}, [selectedPanelIds, updatePositionImmediate]);
const handleCanvasClick = useCallback(
(e: React.MouseEvent) => {
// Don't deselect if we just finished a selection drag
@@ -124,7 +130,8 @@ export function ExploreMapCanvas() {
const canvasY = Math.max(0, Math.min(10000, screenY / scale));
// Update cursor position for all sessions (using actual canvas coordinates, clamped to bounds)
updatePosition(canvasX, canvasY);
// Include currently selected panel IDs for collaborative selection visualization
updatePosition(canvasX, canvasY, selectedPanelIds);
// Handle selection rectangle if dragging (using canvas coordinates)
if (isSelecting && selectionRect) {
@@ -135,7 +142,7 @@ export function ExploreMapCanvas() {
});
}
},
[isSelecting, selectionRect, updatePosition, contextTransformRef, viewport]
[isSelecting, selectionRect, updatePosition, contextTransformRef, viewport, selectedPanelIds]
);
const handleCanvasMouseUp = useCallback(
@@ -4,7 +4,7 @@ import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Button, Tooltip, useStyles2 } from '@grafana/ui';
import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { splitClose } from '../../explore/state/main';
@@ -19,7 +19,7 @@ import {
updatePanelPosition,
updatePanelSize,
} from '../state/crdtSlice';
import { selectSelectedPanelIds, selectViewport } from '../state/selectors';
import { selectSelectedPanelIds, selectViewport, selectCursors } from '../state/selectors';
import { ExploreMapPanel } from '../state/types';
import { ExploreMapPanelContent } from './ExploreMapPanelContent';
@@ -36,8 +36,14 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const cursors = useSelector((state) => selectCursors(state.exploreMapCRDT));
const isSelected = selectedPanelIds.includes(panel.id);
// Find all users who have this panel selected (excluding current user)
const remoteSelectingUsers = Object.values(cursors).filter(
(cursor) => cursor.selectedPanelIds?.includes(panel.id)
);
// Get the active drag info from a parent context (if any panel is being dragged)
const activeDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeDrag);
@@ -217,40 +223,70 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
minWidth={300}
minHeight={200}
>
<div className={styles.panel}>
<div className={cx(styles.panelHeader, 'panel-drag-handle')}>
<div className={styles.panelTitle}>
{t('explore-map.panel.title', 'Explore Panel {{id}}', { id: panel.id.slice(0, 8) })}
<div className={styles.panelWrapper}>
{/* Render remote user selection outlines */}
{remoteSelectingUsers.map((user, index) => (
<div
key={user.sessionId}
className={styles.remoteSelection}
style={{
borderColor: user.color,
// Offset each outline slightly so multiple selections are visible
inset: `${-2 - index * 3}px`,
}}
/>
))}
<div className={styles.panel}>
<div className={cx(styles.panelHeader, 'panel-drag-handle')}>
<div className={styles.panelTitle}>
{t('explore-map.panel.title', 'Explore Panel {{id}}', { id: panel.id.slice(0, 8) })}
</div>
<div className={styles.panelHeaderRight}>
{remoteSelectingUsers.length > 0 && (
<div className={styles.remoteUsers}>
{remoteSelectingUsers.map((user) => (
<span key={user.sessionId} className={styles.remoteUserBadge} style={{ backgroundColor: user.color }}>
{user.userName}
</span>
))}
</div>
)}
<div className={styles.panelActions}>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('explore-map.panel.info', 'Panel information')}
icon="info-circle"
onClick={handleInfoClick}
description={getInfoTooltipContent()}
/>
<Menu.Item
label={t('explore-map.panel.duplicate', 'Duplicate panel')}
icon="copy"
onClick={handleDuplicate}
/>
<Menu.Divider />
<Menu.Item
label={t('explore-map.panel.remove', 'Remove')}
icon="times"
onClick={handleRemove}
/>
</Menu>
}
placement="bottom-end"
>
<Button
icon="ellipsis-v"
variant="secondary"
size="sm"
fill="text"
aria-label={t('explore-map.panel.actions', 'Panel actions')}
/>
</Dropdown>
</div>
</div>
</div>
<div className={styles.panelActions}>
<Tooltip content={getInfoTooltipContent()} placement="bottom">
<Button
icon="info-circle"
variant="secondary"
size="sm"
fill="text"
onClick={handleInfoClick}
aria-label={t('explore-map.panel.info', 'Panel information')}
/>
</Tooltip>
<Button
icon="copy"
variant="secondary"
size="sm"
fill="text"
onClick={handleDuplicate}
tooltip={t('explore-map.panel.duplicate', 'Duplicate panel')}
/>
<Button
icon="times"
variant="secondary"
size="sm"
fill="text"
onClick={handleRemove}
tooltip={t('explore-map.panel.remove', 'Remove')}
/>
</div>
</div>
<div className={styles.panelContent}>
<ExploreMapPanelContent
panelId={panel.id}
@@ -263,6 +299,7 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
/>
</div>
</div>
</div>
</Rnd>
);
}
@@ -291,6 +328,18 @@ const getStyles = (theme: GrafanaTheme2) => {
panelContainer: css({
cursor: 'default',
}),
panelWrapper: css({
position: 'relative',
width: '100%',
height: '100%',
}),
remoteSelection: css({
position: 'absolute',
pointerEvents: 'none',
border: '2px solid',
borderRadius: theme.shape.radius.default,
zIndex: 1,
}),
panel: css({
width: '100%',
height: '100%',
@@ -301,9 +350,11 @@ const getStyles = (theme: GrafanaTheme2) => {
borderRadius: theme.shape.radius.default,
overflow: 'hidden',
boxShadow: theme.shadows.z2,
position: 'relative',
zIndex: 2,
}),
selectedPanel: css({
'& > div': {
'& > div > div': {
border: `2px solid ${theme.colors.primary.border}`,
},
}),
@@ -321,6 +372,31 @@ const getStyles = (theme: GrafanaTheme2) => {
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
userSelect: 'none',
flex: 1,
minWidth: 0, // Allow text truncation
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
panelHeaderRight: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
flexShrink: 0, // Prevent shrinking
}),
remoteUsers: css({
display: 'flex',
gap: theme.spacing(0.5),
alignItems: 'center',
}),
remoteUserBadge: css({
fontSize: theme.typography.bodySmall.fontSize,
padding: theme.spacing(0.25, 0.75),
borderRadius: theme.shape.radius.default,
color: 'white',
fontWeight: theme.typography.fontWeightMedium,
userSelect: 'none',
whiteSpace: 'nowrap',
}),
panelActions: css({
display: 'flex',
@@ -5,9 +5,9 @@
* for collaborative cursor sharing across all active sessions.
*/
import { throttle } from 'lodash';
import { useCallback, useEffect, useRef } from 'react';
import { Unsubscribable } from 'rxjs';
import { throttle } from 'lodash';
import { LiveChannelAddress, LiveChannelScope, isLiveChannelMessageEvent } from '@grafana/data';
import { getGrafanaLiveSrv } from '@grafana/runtime';
@@ -32,6 +32,7 @@ interface CursorUpdateMessage {
x: number;
y: number;
color: string;
selectedPanelIds: string[];
};
timestamp: number;
}
@@ -59,6 +60,30 @@ export function useCursorSync(options: CursorSyncOptions) {
const channelAddressRef = useRef<LiveChannelAddress | null>(null);
const cursorColorRef = useRef<string>(generateRandomColor());
// Send cursor leave message
const sendCursorLeave = useCallback(() => {
if (!channelAddressRef.current) {
return;
}
const liveService = getGrafanaLiveSrv();
if (!liveService) {
return;
}
const message: CursorLeaveMessage = {
type: 'cursor_leave',
sessionId,
userId: '', // Will be enriched by backend
userName: '', // Will be enriched by backend
timestamp: Date.now(),
};
liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => {
// Failed to send cursor leave - ignore silently
});
}, [sessionId]);
// Initialize channel connection
useEffect(() => {
if (!enabled || !mapUid) {
@@ -93,7 +118,7 @@ export function useCursorSync(options: CursorSyncOptions) {
try {
// Handle cursor message events
if (isLiveChannelMessageEvent(event)) {
const message = event.message as CursorMessage;
const message: CursorMessage = event.message;
// Skip our own messages (backend should filter, but double-check)
if (message.sessionId === sessionId) {
@@ -109,6 +134,7 @@ export function useCursorSync(options: CursorSyncOptions) {
x: message.data.x,
y: message.data.y,
lastUpdated: message.timestamp,
selectedPanelIds: message.data.selectedPanelIds || [],
};
dispatch(updateCursor(cursor));
} else if (message.type === 'cursor_leave') {
@@ -146,35 +172,11 @@ export function useCursorSync(options: CursorSyncOptions) {
}
channelAddressRef.current = null;
};
}, [mapUid, enabled, sessionId, dispatch]);
// Send cursor leave message
const sendCursorLeave = useCallback(() => {
if (!channelAddressRef.current) {
return;
}
const liveService = getGrafanaLiveSrv();
if (!liveService) {
return;
}
const message: CursorLeaveMessage = {
type: 'cursor_leave',
sessionId,
userId: '', // Will be enriched by backend
userName: '', // Will be enriched by backend
timestamp: Date.now(),
};
liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => {
// Failed to send cursor leave - ignore silently
});
}, [sessionId]);
}, [mapUid, enabled, sessionId, dispatch, sendCursorLeave]);
// Throttled cursor update function
const sendCursorUpdate = useRef(
throttle((x: number, y: number) => {
throttle((x: number, y: number, selectedPanelIds: string[]) => {
if (!channelAddressRef.current) {
return;
}
@@ -193,6 +195,7 @@ export function useCursorSync(options: CursorSyncOptions) {
x,
y,
color: cursorColorRef.current,
selectedPanelIds,
},
timestamp: Date.now(),
};
@@ -203,16 +206,50 @@ export function useCursorSync(options: CursorSyncOptions) {
}, throttleMs)
).current;
// Update cursor position
// Update cursor position (throttled)
const updatePosition = useCallback(
(x: number, y: number) => {
sendCursorUpdate(x, y);
(x: number, y: number, selectedPanelIds: string[]) => {
sendCursorUpdate(x, y, selectedPanelIds);
},
[sendCursorUpdate]
);
// Force send cursor update immediately (not throttled) - used for selection changes
const updatePositionImmediate = useCallback(
(x: number, y: number, selectedPanelIds: string[]) => {
if (!channelAddressRef.current) {
return;
}
const liveService = getGrafanaLiveSrv();
if (!liveService) {
return;
}
const message: CursorUpdateMessage = {
type: 'cursor_update',
sessionId,
userId: '', // Will be enriched by backend
userName: '', // Will be enriched by backend
data: {
x,
y,
color: cursorColorRef.current,
selectedPanelIds,
},
timestamp: Date.now(),
};
liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => {
// Failed to send cursor update - ignore silently
});
},
[sessionId]
);
return {
updatePosition,
updatePositionImmediate,
color: cursorColorRef.current,
};
}
@@ -57,6 +57,7 @@ export interface UserCursor {
x: number;
y: number;
lastUpdated: number;
selectedPanelIds: string[];
}
export interface ExploreMapState {