support mouse display

This commit is contained in:
hjlarry 2025-07-22 18:08:35 +08:00
parent 897c842637
commit f4438b0a08
3 changed files with 237 additions and 87 deletions

View File

@ -2,6 +2,8 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
@ -17,6 +19,7 @@ import {
useWorkflowStartRun,
} from '../hooks'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useWebSocketStore } from '@/app/components/workflow/store/websocket-store'
import { useCollaborativeCursors } from '../hooks'
import { connectOnlineUserWebSocket } from '@/service/demo/online-user'
import type { OnlineUser } from '@/service/demo/online-user'
@ -30,6 +33,12 @@ const WorkflowMain = ({
const featuresStore = useFeaturesStore()
const workflowStore = useWorkflowStore()
const appId = useStore(s => s.appId)
const containerRef = useRef<HTMLDivElement>(null)
const lastEmitTimeRef = useRef<number>(0)
const lastPositionRef = useRef<{ x: number; y: number } | null>(null)
// WebSocket connection for collaboration
const { emit } = useWebSocketStore()
const handleWorkflowDataUpdate = useCallback((payload: any) => {
const {
@ -52,6 +61,48 @@ const WorkflowMain = ({
}
}, [featuresStore, workflowStore])
// Handle mouse movement for collaboration with throttling (1 second)
const handleMouseMove = useCallback((event: MouseEvent) => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
// Only emit if mouse is within the container
if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) {
const now = Date.now()
const timeSinceLastEmit = now - lastEmitTimeRef.current
// Throttle to 1 second (1000ms)
if (timeSinceLastEmit >= 1000) {
lastEmitTimeRef.current = now
lastPositionRef.current = { x, y }
emit('mouseMove', {
x,
y,
})
}
else {
// Update position for potential future emit
lastPositionRef.current = { x, y }
}
}
}, [emit])
// Add mouse move event listener
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener('mousemove', handleMouseMove)
return () => {
container.removeEventListener('mousemove', handleMouseMove)
}
}, [handleMouseMove])
const {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
@ -71,30 +122,22 @@ const WorkflowMain = ({
} = useWorkflowStartRun()
const { cursors, myUserId } = useCollaborativeCursors(appId)
const [onlineUsers, setOnlineUsers] = useState<Record<string, OnlineUser>>({})
// Add online users logging
useEffect(() => {
if (!appId) return
// Connect to WebSocket for online users
const socket = connectOnlineUserWebSocket(appId)
// Handle online users update
const handleOnlineUsersUpdate = (data: { users: OnlineUser[] }) => {
data.users.forEach((user) => {
console.log(`👤 User: ${user.username} (ID: ${user.user_id})`)
})
const usersMap = data.users.reduce((acc, user) => {
acc[user.user_id] = user
return acc
}, {} as Record<string, OnlineUser>)
setOnlineUsers(usersMap)
}
// Add event listeners
socket.on('online_users', handleOnlineUsersUpdate)
// Log initial connection
console.log('🔌 Connecting to online users WebSocket for app:', appId)
// Cleanup function
// clean up
return () => {
console.log(' Cleaning up online users WebSocket listeners')
socket.off('online_users', handleOnlineUsersUpdate)
}
}, [appId])
@ -182,7 +225,10 @@ const WorkflowMain = ({
])
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div
ref={containerRef}
style={{ position: 'relative', width: '100%', height: '100%' }}
>
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
@ -198,6 +244,20 @@ const WorkflowMain = ({
if (userId === myUserId)
return null
const userInfo = onlineUsers[userId]
const userName = userInfo?.username || `User ${userId.slice(-4)}`
const getUserColor = (id: string) => {
const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
const hash = id.split('').reduce((a, b) => {
a = ((a << 5) - a) + b.charCodeAt(0)
return a & a
}, 0)
return colors[Math.abs(hash) % colors.length]
}
const userColor = getUserColor(userId)
return (
<div
key={userId}
@ -205,26 +265,51 @@ const WorkflowMain = ({
position: 'absolute',
left: cursor.x,
top: cursor.y,
pointerEvents: 'none', // Important: allows clicking through the cursor
zIndex: 9999, // Ensure cursors are on top of other elements
transition: 'left 0.1s linear, top 0.1s linear', // Optional: for smoother movement
pointerEvents: 'none',
zIndex: 10000,
transform: 'translate(-2px, -2px)',
transition: 'left 0.15s ease-out, top 0.15s ease-out',
}}
>
{/* You can replace this with your own cursor SVG or component */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 3.75L10.5 18.25L12.5 11.25L19.5 9.25L5.5 3.75Z" fill={cursor.color || 'black'} stroke="white" strokeWidth="1.5" strokeLinejoin="round"/>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.2))',
}}
>
<path
d="M3 3L16 8L9 10L7 17L3 3Z"
fill={userColor}
stroke="white"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
<span style={{
backgroundColor: cursor.color || 'black',
color: 'white',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
whiteSpace: 'nowrap',
marginLeft: '4px',
}}>
{cursor.name || userId}
</span>
<div
style={{
position: 'absolute',
left: '18px',
top: '-2px',
backgroundColor: userColor,
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '11px',
fontWeight: '500',
whiteSpace: 'nowrap',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
maxWidth: '120px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{userName}
</div>
</div>
)
})}

View File

@ -1,69 +1,48 @@
import {
useEffect,
useRef,
useState,
} from 'react'
import { connectOnlineUserWebSocket, disconnectOnlineUserWebSocket } from '@/service/demo/online-user'
type Cursor = {
x: number
y: number
userId: string
name?: string
color?: string
}
import { useEffect, useState } from 'react'
import { useWebSocketStore } from '@/app/components/workflow/store/websocket-store'
export function useCollaborativeCursors(appId: string) {
const [cursors, setCursors] = useState<Record<string, Cursor>>({})
const socketRef = useRef<ReturnType<typeof connectOnlineUserWebSocket> | null>(null)
const lastSent = useRef<number>(0)
const { on, connect, disconnect } = useWebSocketStore()
const [cursors, setCursors] = useState<Record<string, any>>({})
const [myUserId, setMyUserId] = useState<string | null>(null)
useEffect(() => {
// Connect websocket
const socket = connectOnlineUserWebSocket(appId)
socketRef.current = socket
if (!appId) return
connect(appId)
// Listen for collaboration updates from other users
socket.on('collaboration_update', (update: {
type: string
userId: string
data: any
timestamp: number
}) => {
if (update.type === 'mouseMove') {
return () => {
disconnect()
}
}, [appId, connect, disconnect])
useEffect(() => {
const unsubscribe = on('mouseMove', (update) => {
const userId = update.userId || update.user_id
const data = update.data || update
if (userId && data) {
setCursors(prev => ({
...prev,
[update.userId]: {
x: update.data.x,
y: update.data.y,
userId: update.userId,
[userId]: {
x: data.x,
y: data.y,
userId,
},
}))
}
// if (update.type === 'openPanel') { ... }
})
// Mouse move handler with throttle 300ms
const handleMouseMove = (e: MouseEvent) => {
const now = Date.now()
if (now - lastSent.current > 300) {
socket.emit('collaboration_event', {
type: 'mouseMove',
data: { x: e.clientX, y: e.clientY },
timestamp: now,
})
lastSent.current = now
}
}
window.addEventListener('mousemove', handleMouseMove)
return unsubscribe
}, [on])
return () => {
window.removeEventListener('mousemove', handleMouseMove)
socket.off('collaboration_update')
disconnectOnlineUserWebSocket()
}
}, [appId])
useEffect(() => {
const unsubscribe = on('connected', (data) => {
if (data.userId || data.user_id)
setMyUserId(data.userId || data.user_id)
})
return cursors
return unsubscribe
}, [on])
return { cursors, myUserId }
}

View File

@ -0,0 +1,86 @@
import { create } from 'zustand'
import { connectOnlineUserWebSocket, disconnectOnlineUserWebSocket } from '@/service/demo/online-user'
type WebSocketInstance = ReturnType<typeof connectOnlineUserWebSocket>
type WebSocketStore = {
socket: WebSocketInstance | null
isConnected: boolean
listeners: Map<string, Set<(data: any) => void>>
// Actions
connect: (appId: string) => void
disconnect: () => void
emit: (eventType: string, data: any) => void
on: (eventType: string, handler: (data: any) => void) => () => void
}
export const useWebSocketStore = create<WebSocketStore>((set, get) => ({
socket: null,
isConnected: false,
listeners: new Map(),
connect: (appId: string) => {
const socket = connectOnlineUserWebSocket(appId)
socket.on('collaboration_update', (update: {
type: string
userId: string
data: any
timestamp: number
}) => {
const { listeners } = get()
const eventListeners = listeners.get(update.type)
if (eventListeners) {
eventListeners.forEach((handler) => {
try {
handler(update)
}
catch (error) {
console.error(`Error in collaboration event handler for ${update.type}:`, error)
}
})
}
})
set({ socket, isConnected: true })
},
disconnect: () => {
const { socket } = get()
if (socket) {
socket.off('collaboration_update')
disconnectOnlineUserWebSocket()
}
set({ socket: null, isConnected: false, listeners: new Map() })
},
emit: (eventType: string, data: any) => {
const { socket, isConnected } = get()
if (socket && isConnected) {
socket.emit('collaboration_event', {
type: eventType,
data,
timestamp: Date.now(),
})
}
},
on: (eventType: string, handler: (data: any) => void) => {
const { listeners } = get()
if (!listeners.has(eventType))
listeners.set(eventType, new Set())
listeners.get(eventType)!.add(handler)
return () => {
const currentListeners = get().listeners.get(eventType)
if (currentListeners) {
currentListeners.delete(handler)
if (currentListeners.size === 0)
get().listeners.delete(eventType)
}
}
},
}))