From 7fe10544a11456b645b8ad0ee339bd6f69d0079e Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 21:03:17 -0300 Subject: [PATCH] feat: [US-047] - Live cursor positions on canvas Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 49 +++++++- src/app/editor/[projectId]/page.tsx | 112 ++++++++---------- src/components/editor/RemoteCursors.tsx | 95 +++++++++++++++ src/lib/collaboration/realtime.ts | 36 ++++++ 4 files changed, 225 insertions(+), 67 deletions(-) create mode 100644 src/components/editor/RemoteCursors.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 9f938d5..f4cb1d7 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -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>(new Set()) const [connectionState, setConnectionState] = useState('disconnected') const [presenceUsers, setPresenceUsers] = useState([]) + const [remoteCursors, setRemoteCursors] = useState([]) const realtimeRef = useRef(null) const crdtRef = useRef(null) const isRemoteUpdateRef = useRef(false) + const cursorThrottleRef = useRef(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} /> -
+
+
{contextMenu && ( -
-
-
- - +
+
+ - - - + + + + +
@@ -131,32 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
- - -
-
- ) } - 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 ( ) } diff --git a/src/components/editor/RemoteCursors.tsx b/src/components/editor/RemoteCursors.tsx new file mode 100644 index 0000000..86825dd --- /dev/null +++ b/src/components/editor/RemoteCursors.tsx @@ -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 ( +
+ {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 ( +
+ {/* Cursor arrow SVG */} + + + + + {/* User name label */} +
+ {cursor.displayName} +
+
+ ) + })} +
+ ) +} diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 0c8fe84..d65d860 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -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 { try { await this.supabase.from('collaboration_sessions').upsert(