Add edge-of-view cursor indicators for collaborative editing
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user