Add edge-of-view cursor indicators for collaborative editing

This commit is contained in:
Christian Simon
2025-12-03 11:53:34 +00:00
parent c048f25d67
commit 34e031e40a
4 changed files with 368 additions and 6 deletions
@@ -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 (
<div
className={styles.container}
style={{
left: `${x}px`,
top: `${y}px`,
}}
>
<div className={styles.indicator} style={{ backgroundColor: cursor.color }}>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ transform: `rotate(${rotation}deg)` }}
>
<path d="M6 4L10 8L6 12" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className={styles.label} style={{ backgroundColor: cursor.color }}>
{cursor.userName}
</div>
</div>
);
}
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,
}),
};
};
@@ -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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { transformRef: contextTransformRef } = useTransformContext();
const [selectionRect, setSelectionRect] = useState<SelectionRect | null>(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 (
<div className={styles.canvasWrapper} onMouseMove={handleCanvasMouseMove}>
<div ref={containerRef} className={styles.canvasWrapper} onMouseMove={handleCanvasMouseMove}>
<TransformWrapper
ref={contextTransformRef}
initialScale={viewport.zoom}
@@ -287,9 +325,11 @@ export function ExploreMapCanvas() {
{Object.values(panels).map((panel) => {
return <ExploreMapPanelContainer key={panel.id} panel={panel} />;
})}
{Object.values(cursors).map((cursor) => (
<UserCursor key={cursor.sessionId} cursor={cursor} zoom={viewport.zoom} />
))}
{cursorViewportInfo
.filter((info) => info.isVisible)
.map((info) => (
<UserCursor key={info.cursor.sessionId} cursor={info.cursor} zoom={viewport.zoom} />
))}
{selectionRect && (
<div
className={styles.selectionRect}
@@ -304,6 +344,12 @@ export function ExploreMapCanvas() {
</div>
</TransformComponent>
</TransformWrapper>
{/* Render edge indicators for off-screen cursors */}
{cursorViewportInfo
.filter((info) => !info.isVisible)
.map((info) => (
<EdgeCursorIndicator key={info.cursor.sessionId} cursorInfo={info} />
))}
<ExploreMapComment />
</div>
);
@@ -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',
@@ -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<string, UserCursor>;
viewport: CanvasViewport;
transformRef: React.RefObject<ReactZoomPanPinchRef> | 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<ReactZoomPanPinchRef> | 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<typeof getVisibleBounds>,
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]);
}