From 34e031e40acbd72eb3d6c34a344f2777a4180d26 Mon Sep 17 00:00:00 2001 From: Christian Simon Date: Wed, 3 Dec 2025 10:50:19 +0000 Subject: [PATCH] Add edge-of-view cursor indicators for collaborative editing --- .../components/EdgeCursorIndicator.tsx | 128 +++++++++++++ .../components/ExploreMapCanvas.tsx | 54 +++++- .../explore-map/components/UserCursor.tsx | 15 +- .../hooks/useCursorViewportTracking.ts | 177 ++++++++++++++++++ 4 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 public/app/features/explore-map/components/EdgeCursorIndicator.tsx create mode 100644 public/app/features/explore-map/hooks/useCursorViewportTracking.ts diff --git a/public/app/features/explore-map/components/EdgeCursorIndicator.tsx b/public/app/features/explore-map/components/EdgeCursorIndicator.tsx new file mode 100644 index 00000000000..210a55ad18b --- /dev/null +++ b/public/app/features/explore-map/components/EdgeCursorIndicator.tsx @@ -0,0 +1,128 @@ +/** + * Component to display cursor indicators at the edge of the viewport + * for cursors that are currently off-screen + */ + +import { css, keyframes } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { CursorViewportInfo } from '../hooks/useCursorViewportTracking'; + +interface EdgeCursorIndicatorProps { + cursorInfo: CursorViewportInfo; +} + +export function EdgeCursorIndicator({ cursorInfo }: EdgeCursorIndicatorProps) { + const styles = useStyles2(getStyles); + + if (cursorInfo.isVisible || !cursorInfo.edgePosition) { + return null; + } + + const { cursor, edgePosition } = cursorInfo; + const { x, y, side } = edgePosition; + + // Calculate rotation based on side + let rotation = 0; + switch (side) { + case 'top': + rotation = -90; + break; + case 'bottom': + rotation = 90; + break; + case 'left': + rotation = 180; + break; + case 'right': + rotation = 0; + break; + } + + return ( +
+
+ + + +
+
+ {cursor.userName} +
+
+ ); +} + +const pulseAnimation = keyframes` + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.8; + } +`; + +const fadeInAnimation = keyframes` + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.8); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +`; + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + position: 'absolute', + pointerEvents: 'none', + zIndex: 10001, // Above regular cursors + transform: 'translate(-50%, -50%)', + transition: 'left 0.2s ease-out, top 0.2s ease-out, opacity 0.2s ease-out', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '4px', + animation: `${fadeInAnimation} 0.2s ease-out`, + }), + indicator: css({ + width: '32px', + height: '32px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxShadow: theme.shadows.z3, + border: `2px solid white`, + animation: `${pulseAnimation} 2s ease-in-out infinite`, + }), + label: css({ + padding: '2px 6px', + borderRadius: theme.shape.radius.default, + color: 'white', + fontSize: '11px', + fontWeight: theme.typography.fontWeightMedium, + whiteSpace: 'nowrap', + boxShadow: theme.shadows.z2, + }), + }; +}; diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx index 1b2e6677467..21a16e951b2 100644 --- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx +++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx @@ -8,9 +8,11 @@ import { useDispatch, useSelector } from 'app/types/store'; import { useTransformContext } from '../context/TransformContext'; import { useCursorSync } from '../hooks/useCursorSync'; +import { useCursorViewportTracking } from '../hooks/useCursorViewportTracking'; import { selectPanel as selectPanelCRDT, updateViewport as updateViewportCRDT, selectMultiplePanels as selectMultiplePanelsCRDT } from '../state/crdtSlice'; import { selectPanels, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors'; +import { EdgeCursorIndicator } from './EdgeCursorIndicator'; import { ExploreMapComment } from './ExploreMapComment'; import { ExploreMapPanelContainer } from './ExploreMapPanelContainer'; import { UserCursor } from './UserCursor'; @@ -26,10 +28,12 @@ export function ExploreMapCanvas() { const styles = useStyles2(getStyles); const dispatch = useDispatch(); const canvasRef = useRef(null); + const containerRef = useRef(null); const { transformRef: contextTransformRef } = useTransformContext(); const [selectionRect, setSelectionRect] = useState(null); const [isSelecting, setIsSelecting] = useState(false); const justCompletedSelectionRef = useRef(false); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const panels = useSelector((state) => selectPanels(state.exploreMapCRDT)); const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT)); @@ -43,6 +47,40 @@ export function ExploreMapCanvas() { enabled: !!mapUid, }); + // Track cursor viewport positions for edge indicators + const cursorViewportInfo = useCursorViewportTracking({ + cursors, + viewport, + transformRef: contextTransformRef, + containerWidth: containerSize.width, + containerHeight: containerSize.height, + }); + + // Track container size for viewport calculations + useEffect(() => { + if (!containerRef.current) { + return; + } + + const updateSize = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setContainerSize({ width: rect.width, height: rect.height }); + } + }; + + // Initial size + updateSize(); + + // Update on resize + const resizeObserver = new ResizeObserver(updateSize); + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + // Send selection update immediately when selection changes // Use position (0, 0) since we only care about the selection, not cursor position useEffect(() => { @@ -248,7 +286,7 @@ export function ExploreMapCanvas() { ); return ( -
+
{ return ; })} - {Object.values(cursors).map((cursor) => ( - - ))} + {cursorViewportInfo + .filter((info) => info.isVisible) + .map((info) => ( + + ))} {selectionRect && (
+ {/* Render edge indicators for off-screen cursors */} + {cursorViewportInfo + .filter((info) => !info.isVisible) + .map((info) => ( + + ))}
); diff --git a/public/app/features/explore-map/components/UserCursor.tsx b/public/app/features/explore-map/components/UserCursor.tsx index c42877aca0e..67260105589 100644 --- a/public/app/features/explore-map/components/UserCursor.tsx +++ b/public/app/features/explore-map/components/UserCursor.tsx @@ -56,9 +56,20 @@ const getStyles = (theme: GrafanaTheme2) => { // Smooth transition matching the update frequency (100ms) // Using linear for more predictive movement // Also transition transform for smooth scaling when zoom changes - transition: 'left 0.1s linear, top 0.1s linear, transform 0.2s ease-out', + // Add opacity transition for smooth fade in/out when entering/leaving view + transition: 'left 0.1s linear, top 0.1s linear, transform 0.2s ease-out, opacity 0.2s ease-out', // Will-change hint for better performance - willChange: 'left, top, transform', + willChange: 'left, top, transform, opacity', + // Fade in animation + animation: 'fadeIn 0.2s ease-out', + '@keyframes fadeIn': { + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + }, }), cursorSvg: css({ display: 'block', diff --git a/public/app/features/explore-map/hooks/useCursorViewportTracking.ts b/public/app/features/explore-map/hooks/useCursorViewportTracking.ts new file mode 100644 index 00000000000..dd6583b480c --- /dev/null +++ b/public/app/features/explore-map/hooks/useCursorViewportTracking.ts @@ -0,0 +1,177 @@ +/** + * Hook to track cursor positions relative to the viewport + * + * Determines if cursors are visible in the current viewport and calculates + * edge positions for off-screen cursors to show indicators at the viewport edge. + */ + +import { useMemo } from 'react'; +import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; + +import { UserCursor } from '../state/types'; +import { CanvasViewport } from '../state/types'; + +export interface CursorViewportInfo { + cursor: UserCursor; + isVisible: boolean; + edgePosition?: { + x: number; // Screen pixel position on the edge + y: number; // Screen pixel position on the edge + side: 'top' | 'bottom' | 'left' | 'right'; // Which edge + distance: number; // Distance from viewport in canvas units + }; +} + +interface UseCursorViewportTrackingOptions { + cursors: Record; + viewport: CanvasViewport; + transformRef: React.RefObject | null; + containerWidth: number; + containerHeight: number; +} + +const CANVAS_SIZE = 10000; +const EDGE_INDICATOR_MARGIN = 20; // Pixels from edge + +/** + * Calculate visible bounds of the viewport in canvas coordinates + */ +function getVisibleBounds( + viewport: CanvasViewport, + transformRef: React.RefObject | null, + containerWidth: number, + containerHeight: number +) { + // Get current transform state + const scale = transformRef?.current?.state?.scale ?? viewport.zoom; + const panX = transformRef?.current?.state?.positionX ?? viewport.panX; + const panY = transformRef?.current?.state?.positionY ?? viewport.panY; + + // The pan values are in screen pixels and represent how much the canvas is shifted + // Negative panX means the canvas is shifted left (showing more right content) + // To convert to canvas coordinates, we need to: + // 1. Negate the pan to get the offset + // 2. Divide by scale to get canvas units + + const visibleLeft = -panX / scale; + const visibleTop = -panY / scale; + const visibleRight = visibleLeft + containerWidth / scale; + const visibleBottom = visibleTop + containerHeight / scale; + + return { + left: Math.max(0, visibleLeft), + top: Math.max(0, visibleTop), + right: Math.min(CANVAS_SIZE, visibleRight), + bottom: Math.min(CANVAS_SIZE, visibleBottom), + scale, + }; +} + +/** + * Calculate edge position for a cursor outside the viewport + */ +function calculateEdgePosition( + cursor: UserCursor, + bounds: ReturnType, + containerWidth: number, + containerHeight: number +): CursorViewportInfo['edgePosition'] { + const { left, top, right, bottom, scale } = bounds; + const { x: cursorX, y: cursorY } = cursor; + + // Determine which edge(s) the cursor is beyond + const isAbove = cursorY < top; + const isBelow = cursorY > bottom; + const isLeft = cursorX < left; + const isRight = cursorX > right; + + // Calculate clamped position at viewport edges + const clampedX = Math.max(left, Math.min(right, cursorX)); + const clampedY = Math.max(top, Math.min(bottom, cursorY)); + + // Convert clamped canvas position to screen coordinates + let screenX = (clampedX - left) * scale; + let screenY = (clampedY - top) * scale; + + // Determine primary side and adjust to edge with margin + let side: 'top' | 'bottom' | 'left' | 'right'; + let distance: number; + + // Priority: vertical edges over horizontal if both apply + if (isLeft || isRight) { + if (isLeft) { + side = 'left'; + screenX = EDGE_INDICATOR_MARGIN; + distance = left - cursorX; + } else { + side = 'right'; + screenX = containerWidth - EDGE_INDICATOR_MARGIN; + distance = cursorX - right; + } + } else if (isAbove || isBelow) { + if (isAbove) { + side = 'top'; + screenY = EDGE_INDICATOR_MARGIN; + distance = top - cursorY; + } else { + side = 'bottom'; + screenY = containerHeight - EDGE_INDICATOR_MARGIN; + distance = cursorY - bottom; + } + } else { + // Should not happen, but handle gracefully + side = 'top'; + distance = 0; + } + + // Clamp screen positions to container bounds with margin + screenX = Math.max(EDGE_INDICATOR_MARGIN, Math.min(containerWidth - EDGE_INDICATOR_MARGIN, screenX)); + screenY = Math.max(EDGE_INDICATOR_MARGIN, Math.min(containerHeight - EDGE_INDICATOR_MARGIN, screenY)); + + return { + x: screenX, + y: screenY, + side, + distance, + }; +} + +/** + * Track cursor positions relative to viewport + */ +export function useCursorViewportTracking({ + cursors, + viewport, + transformRef, + containerWidth, + containerHeight, +}: UseCursorViewportTrackingOptions): CursorViewportInfo[] { + return useMemo(() => { + if (containerWidth === 0 || containerHeight === 0) { + return []; + } + + const bounds = getVisibleBounds(viewport, transformRef, containerWidth, containerHeight); + + return Object.values(cursors).map((cursor) => { + const { x, y } = cursor; + const { left, top, right, bottom } = bounds; + + // Check if cursor is within visible bounds + const isVisible = x >= left && x <= right && y >= top && y <= bottom; + + if (isVisible) { + return { cursor, isVisible: true }; + } + + // Calculate edge position for off-screen cursor + const edgePosition = calculateEdgePosition(cursor, bounds, containerWidth, containerHeight); + + return { + cursor, + isVisible: false, + edgePosition, + }; + }); + }, [cursors, viewport, transformRef, containerWidth, containerHeight]); +}