Real-Time Cursors
Proposal for implementing real-time cursor positions using Ably Spaces
Real-Time Cursors Implementation
Source: This page is based on docs/PROPOSALS/REAL_TIME_CURSORS.md
Overview
This document outlines the proposal for implementing real-time cursor positions in Project Eddy using Ably Spaces, a purpose-built SDK for collaborative features.
Technology Choice: Ably Spaces
Repository: https://github.com/ably/spaces
React Documentation: https://github.com/ably/spaces/blob/main/docs/react.md
Ably Spaces provides a complete suite of collaboration features including:
- Live cursor tracking
- Avatar stacks (who's online)
- Member locations (where users are in the app)
- Component locking
- Real-time presence
Key Features
Live Cursors
The Spaces SDK's cursor API enables tracking member cursor positions with:
- Real-time position updates (x, y coordinates)
- Associated metadata (e.g., user color, cursor style)
- Efficient subscription model
- Built-in cursor state management
React Integration
Ably provides idiomatic React Hooks for seamless integration:
useSpace- Subscribe to space events and get space instanceuseMembers- Build avatar stacks and track online membersuseCursors- Track and update cursor positionsuseLocation- Track member locations within the appuseLocks- Implement component locking
Implementation Targets
Primary Target: Workflow Builder
Component: components/builder/BuilderStageGraph.tsx
This component is the workflow graph builder using ReactFlow. It's an ideal candidate for real-time cursors because:
- Multiple users may collaborate on workflow design simultaneously
- Visual feedback of other users' actions improves coordination
- The canvas-based interface maps naturally to cursor positions
- Users need awareness of what others are editing
Secondary Target: Session Stage View
Component: components/session/SessionStage.tsx
This component displays the actual workflow stage interface where users complete tasks. It's another strong candidate because:
- Multiple users may work on the same session stage simultaneously
- Already has Ably integration (
useChannelandusePresencehooks) - Users would benefit from seeing where teammates are working within forms/blocks
- Collaboration awareness is valuable during task completion
Technical Implementation
1. Setup
import Spaces from '@ably/spaces'
import { SpaceProvider, SpacesProvider } from '@ably/spaces/react'
import { Realtime } from 'ably'
const ably = new Realtime({
key: 'your-ably-api-key',
clientId: 'user-id' // Must be set for Spaces
})
const spaces = new Spaces(ably)
// Wrap the application
<SpacesProvider client={spaces}>
<SpaceProvider name='workflow-builder-{workflowId}'>
<BuilderStageGraph />
</SpaceProvider>
</SpacesProvider>2. Cursor Tracking in BuilderStageGraph
const reactFlowInstance = useReactFlow()
const { set, cursors } = useCursors(
cursorUpdate => {
// Handle cursor updates from other members
console.log(cursorUpdate)
},
{ returnCursors: true }
)
useEffect(() => {
const handleMouseMove = ({ clientX, clientY }) => {
// Convert screen coordinates to flow coordinates
const flowPos = reactFlowInstance.screenToFlowPosition({
x: clientX,
y: clientY
})
set({
position: { x: flowPos.x, y: flowPos.y },
data: {
color: userColor,
username: currentUser.name
}
})
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [set, userColor, currentUser, reactFlowInstance])3. Rendering Other Users' Cursors
const reactFlowInstance = useReactFlow()
// Render cursors with flow coordinate conversion
{cursors.map(cursor => {
// Convert flow coordinates to screen coordinates for rendering
const screenPos = reactFlowInstance.project({
x: cursor.position.x,
y: cursor.position.y
})
return (
<Cursor
key={cursor.clientId}
x={screenPos.x}
y={screenPos.y}
color={cursor.data.color}
username={cursor.data.username}
/>
)
})}4. Cursor Component
function Cursor({ x, y, color, username }) {
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
pointerEvents: 'none',
zIndex: 1000
}}
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path
d="M5.65376 12.3673H5.46026L5.31717 12.4976L0.500002 16.8829L0.500002 1.19841L11.7841 12.3673H5.65376Z"
fill={color}
/>
</svg>
<div
style={{
marginLeft: '20px',
marginTop: '-4px',
padding: '2px 6px',
backgroundColor: color,
color: 'white',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'nowrap'
}}
>
{username}
</div>
</div>
)
}Implementation for Session Stage View
Cursor Tracking in SessionStage
const { set, cursors } = useCursors(
cursorUpdate => {
console.log(cursorUpdate)
},
{ returnCursors: true }
)
useEffect(() => {
const handleMouseMove = ({ clientX, clientY }) => {
set({
position: { x: clientX, y: clientY },
data: {
color: userColor,
username: currentUser.name,
currentBlock: focusedBlockId // Track which block user is editing
}
})
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [set, userColor, currentUser, focusedBlockId])Rendering Cursors in Session
{cursors.map(cursor => (
<Cursor
key={cursor.clientId}
x={cursor.position.x}
y={cursor.position.y}
color={cursor.data.color}
username={cursor.data.username}
/>
))}Additional Features
Avatar Stack (Who's Online)
const { self, others } = useMembers()
return (
<div className="avatar-stack">
{[self, ...others].map(member => (
<Avatar
key={member.clientId}
name={member.profileData.username}
color={member.profileData.color}
/>
))}
</div>
)Member Locations
Track where users are in the app:
const { update, locations } = useLocation()
// Update location when user navigates
useEffect(() => {
update({
slide: currentSlideId,
component: 'builder'
})
}, [currentSlideId, update])Component Locking
Prevent simultaneous editing of the same component:
const { acquire, release, getLock } = useLocks()
const handleEdit = async (componentId) => {
const lock = await acquire(componentId)
if (lock) {
// User can edit
// ...
release(componentId)
} else {
// Component is locked by another user
const currentLock = getLock(componentId)
alert(`${currentLock.member.profileData.username} is editing this`)
}
}Performance Considerations
Throttling Cursor Updates
const throttledSet = useCallback(
throttle((position, data) => {
set({ position, data })
}, 50), // Update every 50ms
[set]
)Conditional Rendering
Only render cursors when they're in the visible viewport:
{cursors
.filter(cursor => isInViewport(cursor.position))
.map(cursor => <Cursor key={cursor.clientId} {...cursor} />)
}Space Naming Strategy
Workflow Builder:
- Space name:
workflow-builder-{workflowId} - Scope: All users editing the same workflow
Session View:
- Space name:
session-{workflowRunId}-stage-{pageId} - Scope: All users working on the same stage of a session