mirror of
https://github.com/langgenius/dify.git
synced 2026-08-28 21:23:22 +08:00
support mouse display
This commit is contained in:
parent
897c842637
commit
f4438b0a08
@ -2,6 +2,8 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||||
import { WorkflowWithInnerContext } from '@/app/components/workflow'
|
import { WorkflowWithInnerContext } from '@/app/components/workflow'
|
||||||
@ -17,6 +19,7 @@ import {
|
|||||||
useWorkflowStartRun,
|
useWorkflowStartRun,
|
||||||
} from '../hooks'
|
} from '../hooks'
|
||||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||||
|
import { useWebSocketStore } from '@/app/components/workflow/store/websocket-store'
|
||||||
import { useCollaborativeCursors } from '../hooks'
|
import { useCollaborativeCursors } from '../hooks'
|
||||||
import { connectOnlineUserWebSocket } from '@/service/demo/online-user'
|
import { connectOnlineUserWebSocket } from '@/service/demo/online-user'
|
||||||
import type { OnlineUser } from '@/service/demo/online-user'
|
import type { OnlineUser } from '@/service/demo/online-user'
|
||||||
@ -30,6 +33,12 @@ const WorkflowMain = ({
|
|||||||
const featuresStore = useFeaturesStore()
|
const featuresStore = useFeaturesStore()
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const appId = useStore(s => s.appId)
|
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 handleWorkflowDataUpdate = useCallback((payload: any) => {
|
||||||
const {
|
const {
|
||||||
@ -52,6 +61,48 @@ const WorkflowMain = ({
|
|||||||
}
|
}
|
||||||
}, [featuresStore, workflowStore])
|
}, [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 {
|
const {
|
||||||
doSyncWorkflowDraft,
|
doSyncWorkflowDraft,
|
||||||
syncWorkflowDraftWhenPageClose,
|
syncWorkflowDraftWhenPageClose,
|
||||||
@ -71,30 +122,22 @@ const WorkflowMain = ({
|
|||||||
} = useWorkflowStartRun()
|
} = useWorkflowStartRun()
|
||||||
|
|
||||||
const { cursors, myUserId } = useCollaborativeCursors(appId)
|
const { cursors, myUserId } = useCollaborativeCursors(appId)
|
||||||
|
const [onlineUsers, setOnlineUsers] = useState<Record<string, OnlineUser>>({})
|
||||||
|
|
||||||
// Add online users logging
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appId) return
|
if (!appId) return
|
||||||
|
|
||||||
// Connect to WebSocket for online users
|
|
||||||
const socket = connectOnlineUserWebSocket(appId)
|
const socket = connectOnlineUserWebSocket(appId)
|
||||||
|
|
||||||
// Handle online users update
|
|
||||||
const handleOnlineUsersUpdate = (data: { users: OnlineUser[] }) => {
|
const handleOnlineUsersUpdate = (data: { users: OnlineUser[] }) => {
|
||||||
data.users.forEach((user) => {
|
const usersMap = data.users.reduce((acc, user) => {
|
||||||
console.log(`👤 User: ${user.username} (ID: ${user.user_id})`)
|
acc[user.user_id] = user
|
||||||
})
|
return acc
|
||||||
|
}, {} as Record<string, OnlineUser>)
|
||||||
|
setOnlineUsers(usersMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
socket.on('online_users', handleOnlineUsersUpdate)
|
socket.on('online_users', handleOnlineUsersUpdate)
|
||||||
|
// clean up
|
||||||
// Log initial connection
|
|
||||||
console.log('🔌 Connecting to online users WebSocket for app:', appId)
|
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log(' Cleaning up online users WebSocket listeners')
|
|
||||||
socket.off('online_users', handleOnlineUsersUpdate)
|
socket.off('online_users', handleOnlineUsersUpdate)
|
||||||
}
|
}
|
||||||
}, [appId])
|
}, [appId])
|
||||||
@ -182,7 +225,10 @@ const WorkflowMain = ({
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{ position: 'relative', width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
<WorkflowWithInnerContext
|
<WorkflowWithInnerContext
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@ -198,6 +244,20 @@ const WorkflowMain = ({
|
|||||||
if (userId === myUserId)
|
if (userId === myUserId)
|
||||||
return null
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={userId}
|
key={userId}
|
||||||
@ -205,26 +265,51 @@ const WorkflowMain = ({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: cursor.x,
|
left: cursor.x,
|
||||||
top: cursor.y,
|
top: cursor.y,
|
||||||
pointerEvents: 'none', // Important: allows clicking through the cursor
|
pointerEvents: 'none',
|
||||||
zIndex: 9999, // Ensure cursors are on top of other elements
|
zIndex: 10000,
|
||||||
transition: 'left 0.1s linear, top 0.1s linear', // Optional: for smoother movement
|
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
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
width="20"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<span style={{
|
|
||||||
backgroundColor: cursor.color || 'black',
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '18px',
|
||||||
|
top: '-2px',
|
||||||
|
backgroundColor: userColor,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '2px 8px',
|
padding: '2px 6px',
|
||||||
borderRadius: '12px',
|
borderRadius: '4px',
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
|
fontWeight: '500',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
marginLeft: '4px',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
}}>
|
maxWidth: '120px',
|
||||||
{cursor.name || userId}
|
overflow: 'hidden',
|
||||||
</span>
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userName}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,69 +1,48 @@
|
|||||||
import {
|
import { useEffect, useState } from 'react'
|
||||||
useEffect,
|
import { useWebSocketStore } from '@/app/components/workflow/store/websocket-store'
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
|
|
||||||
import { connectOnlineUserWebSocket, disconnectOnlineUserWebSocket } from '@/service/demo/online-user'
|
|
||||||
|
|
||||||
type Cursor = {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
userId: string
|
|
||||||
name?: string
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCollaborativeCursors(appId: string) {
|
export function useCollaborativeCursors(appId: string) {
|
||||||
const [cursors, setCursors] = useState<Record<string, Cursor>>({})
|
const { on, connect, disconnect } = useWebSocketStore()
|
||||||
const socketRef = useRef<ReturnType<typeof connectOnlineUserWebSocket> | null>(null)
|
const [cursors, setCursors] = useState<Record<string, any>>({})
|
||||||
const lastSent = useRef<number>(0)
|
const [myUserId, setMyUserId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Connect websocket
|
if (!appId) return
|
||||||
const socket = connectOnlineUserWebSocket(appId)
|
connect(appId)
|
||||||
socketRef.current = socket
|
|
||||||
|
|
||||||
// Listen for collaboration updates from other users
|
return () => {
|
||||||
socket.on('collaboration_update', (update: {
|
disconnect()
|
||||||
type: string
|
}
|
||||||
userId: string
|
}, [appId, connect, disconnect])
|
||||||
data: any
|
|
||||||
timestamp: number
|
useEffect(() => {
|
||||||
}) => {
|
const unsubscribe = on('mouseMove', (update) => {
|
||||||
if (update.type === 'mouseMove') {
|
const userId = update.userId || update.user_id
|
||||||
|
const data = update.data || update
|
||||||
|
|
||||||
|
if (userId && data) {
|
||||||
setCursors(prev => ({
|
setCursors(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[update.userId]: {
|
[userId]: {
|
||||||
x: update.data.x,
|
x: data.x,
|
||||||
y: update.data.y,
|
y: data.y,
|
||||||
userId: update.userId,
|
userId,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
// if (update.type === 'openPanel') { ... }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mouse move handler with throttle 300ms
|
return unsubscribe
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
}, [on])
|
||||||
const now = Date.now()
|
|
||||||
if (now - lastSent.current > 300) {
|
useEffect(() => {
|
||||||
socket.emit('collaboration_event', {
|
const unsubscribe = on('connected', (data) => {
|
||||||
type: 'mouseMove',
|
if (data.userId || data.user_id)
|
||||||
data: { x: e.clientX, y: e.clientY },
|
setMyUserId(data.userId || data.user_id)
|
||||||
timestamp: now,
|
|
||||||
})
|
})
|
||||||
lastSent.current = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('mousemove', handleMouseMove)
|
|
||||||
|
|
||||||
return () => {
|
return unsubscribe
|
||||||
window.removeEventListener('mousemove', handleMouseMove)
|
}, [on])
|
||||||
socket.off('collaboration_update')
|
|
||||||
disconnectOnlineUserWebSocket()
|
|
||||||
}
|
|
||||||
}, [appId])
|
|
||||||
|
|
||||||
return cursors
|
return { cursors, myUserId }
|
||||||
}
|
}
|
||||||
|
|||||||
86
web/app/components/workflow/store/websocket-store.ts
Normal file
86
web/app/components/workflow/store/websocket-store.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
Loading…
Reference in New Issue
Block a user