Eddy Dev Handbook
Proposals

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 instance
  • useMembers - Build avatar stacks and track online members
  • useCursors - Track and update cursor positions
  • useLocation - Track member locations within the app
  • useLocks - 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 (useChannel and usePresence hooks)
  • 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

On this page