Broadcast panel selection to other users, rework panel actions
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user