feat: [US-047] - Live cursor positions on canvas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-23 21:03:17 -03:00
parent b6d85b1a47
commit 7fe10544a1
4 changed files with 225 additions and 67 deletions

View File

@ -33,7 +33,8 @@ import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
import ConditionEditor from '@/components/editor/ConditionEditor'
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
import { EditorProvider } from '@/components/editor/EditorContext'
import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime'
import { RealtimeConnection, type ConnectionState, type PresenceUser, type RemoteCursor } from '@/lib/collaboration/realtime'
import RemoteCursors from '@/components/editor/RemoteCursors'
import { CRDTManager } from '@/lib/collaboration/crdt'
import ShareModal from '@/components/editor/ShareModal'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
@ -508,9 +509,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
const [remoteCursors, setRemoteCursors] = useState<RemoteCursor[]>([])
const realtimeRef = useRef<RealtimeConnection | null>(null)
const crdtRef = useRef<CRDTManager | null>(null)
const isRemoteUpdateRef = useRef(false)
const cursorThrottleRef = useRef<number>(0)
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
useEffect(() => {
@ -553,6 +556,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
onConnectionStateChange: setConnectionState,
onPresenceSync: setPresenceUsers,
onCursorUpdate: (cursor) => {
setRemoteCursors((prev) => {
const existing = prev.findIndex((c) => c.userId === cursor.userId)
if (existing >= 0) {
const updated = [...prev]
updated[existing] = cursor
return updated
}
return [...prev, cursor]
})
},
onChannelSubscribed: (channel) => {
crdtManager.connectChannel(channel)
},
@ -600,6 +614,36 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
crdtRef.current?.updateEdges(edgesForCRDT)
}, [edgesForCRDT])
// Broadcast cursor position on mouse move (throttled to 50ms)
const handleMouseMove = useCallback(
(event: React.MouseEvent) => {
const now = Date.now()
if (now - cursorThrottleRef.current < 50) return
cursorThrottleRef.current = now
const connection = realtimeRef.current
if (!connection) return
// Convert screen position to flow coordinates
const bounds = event.currentTarget.getBoundingClientRect()
const screenX = event.clientX - bounds.left
const screenY = event.clientY - bounds.top
const flowPosition = screenToFlowPosition({ x: screenX, y: screenY })
connection.broadcastCursor(flowPosition)
},
[screenToFlowPosition]
)
// Remove cursors for users who leave
useEffect(() => {
setRemoteCursors((prev) =>
prev.filter((cursor) =>
presenceUsers.some((u) => u.userId === cursor.userId)
)
)
}, [presenceUsers])
const handleAddCharacter = useCallback(
(name: string, color: string): string => {
const id = nanoid()
@ -1210,7 +1254,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
connectionState={connectionState}
presenceUsers={presenceUsers}
/>
<div className="flex-1">
<div className="relative flex-1" onMouseMove={handleMouseMove}>
<ReactFlow
nodes={styledNodes}
edges={edges}
@ -1231,6 +1275,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls position="bottom-right" />
</ReactFlow>
<RemoteCursors cursors={remoteCursors} />
</div>
{contextMenu && (
<ContextMenu

View File

@ -41,7 +41,6 @@ export default async function EditorPage({ params }: PageProps) {
// If not the owner, check if the user is a collaborator
if (!project) {
// RLS on projects allows collaborators to SELECT shared projects
const { data: collab } = await supabase
.from('project_collaborators')
.select('id, role')
@ -49,58 +48,42 @@ export default async function EditorPage({ params }: PageProps) {
.eq('user_id', user.id)
.single()
if (!collab) {
notFound()
if (collab) {
const { data: sharedProject } = await supabase
.from('projects')
.select('id, name, flowchart_data')
.eq('id', projectId)
.single()
project = sharedProject
isOwner = false
}
const { data: sharedProject } = await supabase
.from('projects')
.select('id, name, flowchart_data')
.eq('id', projectId)
.single()
if (!sharedProject) {
notFound()
}
project = sharedProject
isOwner = false
}
const rawData = project.flowchart_data || {}
const flowchartData: FlowchartData = {
nodes: rawData.nodes || [],
edges: rawData.edges || [],
characters: rawData.characters || [],
variables: rawData.variables || [],
}
// Migration flag: if the raw data doesn't have characters/variables arrays,
// the project was created before these features existed and may need auto-migration
const needsMigration = !rawData.characters && !rawData.variables
return (<>
<div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
aria-label="Back to dashboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
if (!project) {
return (
<div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
aria-label="Back to dashboard"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
</Link>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
</Link>
</div>
</header>
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-col items-center gap-4 text-center">
@ -131,32 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
</Link>
</div>
</div>
</header>
<div className="flex-1">
<FlowchartEditor
projectId={project.id}
userId={user.id}
userDisplayName={userDisplayName}
isOwner={isOwner}
initialData={flowchartData}
needsMigration={needsMigration}
/>
</div>
</>
)
}
const flowchartData = (project.flowchart_data || {
nodes: [],
edges: [],
}) as FlowchartData
const rawData = project.flowchart_data || {}
const flowchartData: FlowchartData = {
nodes: rawData.nodes || [],
edges: rawData.edges || [],
characters: rawData.characters || [],
variables: rawData.variables || [],
}
// Migration flag: if the raw data doesn't have characters/variables arrays,
// the project was created before these features existed and may need auto-migration
const needsMigration = !rawData.characters && !rawData.variables
return (
<FlowchartEditor
projectId={project.id}
projectName={project.name}
userId={user.id}
userDisplayName={userDisplayName}
isOwner={isOwner}
initialData={flowchartData}
needsMigration={needsMigration}
/>
)
}

View File

@ -0,0 +1,95 @@
'use client'
import { useEffect, useState } from 'react'
import { useViewport } from 'reactflow'
import type { RemoteCursor } from '@/lib/collaboration/realtime'
type RemoteCursorsProps = {
cursors: RemoteCursor[]
}
const FADE_TIMEOUT_MS = 5000
// Generate a consistent color from a user ID hash (same algorithm as PresenceAvatars)
function getUserColor(userId: string): string {
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
}
const colors = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
return colors[Math.abs(hash) % colors.length]
}
export default function RemoteCursors({ cursors }: RemoteCursorsProps) {
const viewport = useViewport()
const [now, setNow] = useState(() => Date.now())
// Tick every second to update fade states
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [])
return (
<div className="pointer-events-none absolute inset-0 z-40 overflow-hidden">
{cursors.map((cursor) => {
const timeSinceUpdate = now - cursor.lastUpdated
const isFaded = timeSinceUpdate > FADE_TIMEOUT_MS
if (isFaded) return null
const opacity = timeSinceUpdate > FADE_TIMEOUT_MS - 1000
? Math.max(0, 1 - (timeSinceUpdate - (FADE_TIMEOUT_MS - 1000)) / 1000)
: 1
// Convert flow coordinates to screen coordinates
const screenX = cursor.position.x * viewport.zoom + viewport.x
const screenY = cursor.position.y * viewport.zoom + viewport.y
const color = getUserColor(cursor.userId)
return (
<div
key={cursor.userId}
className="absolute left-0 top-0"
style={{
transform: `translate(${screenX}px, ${screenY}px)`,
opacity,
transition: 'transform 80ms linear, opacity 300ms ease',
}}
>
{/* Cursor arrow SVG */}
<svg
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }}
>
<path
d="M0 0L16 12L8 12L6 20L0 0Z"
fill={color}
/>
<path
d="M0 0L16 12L8 12L6 20L0 0Z"
stroke="white"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
{/* User name label */}
<div
className="absolute left-4 top-4 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm"
style={{ backgroundColor: color }}
>
{cursor.displayName}
</div>
</div>
)
})}
</div>
)
}

View File

@ -8,10 +8,23 @@ export type PresenceUser = {
displayName: string
}
export type CursorPosition = {
x: number
y: number
}
export type RemoteCursor = {
userId: string
displayName: string
position: CursorPosition
lastUpdated: number
}
type RealtimeCallbacks = {
onConnectionStateChange: (state: ConnectionState) => void
onPresenceSync?: (users: PresenceUser[]) => void
onChannelSubscribed?: (channel: RealtimeChannel) => void
onCursorUpdate?: (cursor: RemoteCursor) => void
}
const HEARTBEAT_INTERVAL_MS = 30_000
@ -63,6 +76,15 @@ export class RealtimeConnection {
this.callbacks.onPresenceSync?.(users)
}
})
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
if (payload.userId === this.userId) return
this.callbacks.onCursorUpdate?.({
userId: payload.userId,
displayName: payload.displayName,
position: { x: payload.x, y: payload.y },
lastUpdated: Date.now(),
})
})
.subscribe(async (status) => {
if (this.isDestroyed) return
@ -107,6 +129,20 @@ export class RealtimeConnection {
return this.channel
}
broadcastCursor(position: CursorPosition): void {
if (!this.channel) return
this.channel.send({
type: 'broadcast',
event: 'cursor',
payload: {
userId: this.userId,
displayName: this.displayName,
x: position.x,
y: position.y,
},
})
}
private async createSession(): Promise<void> {
try {
await this.supabase.from('collaboration_sessions').upsert(