Skip to content

Commit eac358b

Browse files
feat(live-cursor): live cursor during collaboration (#1775)
* feat(live-cursor): collaborative cursor * fix user avatar url rendering * simplify presence * fix env ts * fix lint * fix type mismatch
1 parent a072e6d commit eac358b

File tree

14 files changed

+342
-80
lines changed

14 files changed

+342
-80
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx

Lines changed: 27 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,30 @@
11
'use client'
22

33
import { type CSSProperties, useMemo } from 'react'
4+
import Image from 'next/image'
45
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
6+
import { getPresenceColors } from '@/lib/collaboration/presence-colors'
57

68
interface AvatarProps {
79
connectionId: string | number
810
name?: string
911
color?: string
12+
avatarUrl?: string | null
1013
tooltipContent?: React.ReactNode | null
1114
size?: 'sm' | 'md' | 'lg'
1215
index?: number // Position in stack for z-index
1316
}
1417

15-
// Color palette inspired by the app's design
16-
const APP_COLORS = [
17-
{ from: '#4F46E5', to: '#7C3AED' }, // indigo to purple
18-
{ from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia
19-
{ from: '#EC4899', to: '#F97316' }, // pink to orange
20-
{ from: '#14B8A6', to: '#10B981' }, // teal to emerald
21-
{ from: '#6366F1', to: '#8B5CF6' }, // indigo to violet
22-
{ from: '#F59E0B', to: '#F97316' }, // amber to orange
23-
]
24-
25-
/**
26-
* Generate a deterministic gradient based on a connection ID
27-
*/
28-
function generateGradient(connectionId: string | number): string {
29-
// Convert connectionId to a number for consistent hashing
30-
const numericId =
31-
typeof connectionId === 'string'
32-
? Math.abs(connectionId.split('').reduce((a, b) => a + b.charCodeAt(0), 0))
33-
: connectionId
34-
35-
// Use the numeric ID to select a color pair from our palette
36-
const colorPair = APP_COLORS[numericId % APP_COLORS.length]
37-
38-
// Add a slight rotation to the gradient based on connection ID for variety
39-
const rotation = (numericId * 25) % 360
40-
41-
return `linear-gradient(${rotation}deg, ${colorPair.from}, ${colorPair.to})`
42-
}
43-
4418
export function UserAvatar({
4519
connectionId,
4620
name,
4721
color,
22+
avatarUrl,
4823
tooltipContent,
4924
size = 'md',
5025
index = 0,
5126
}: AvatarProps) {
52-
// Generate a deterministic gradient for this user based on connection ID
53-
// Or use the provided color if available
54-
const backgroundStyle = useMemo(() => {
55-
if (color) {
56-
// If a color is provided, create a gradient with it
57-
const baseColor = color
58-
const lighterShade = color.startsWith('#')
59-
? `${color}dd` // Add transparency for a lighter shade effect
60-
: color
61-
const darkerShade = color.startsWith('#') ? color : color
62-
63-
return `linear-gradient(135deg, ${lighterShade}, ${darkerShade})`
64-
}
65-
// Otherwise, generate a gradient based on connectionId
66-
return generateGradient(connectionId)
67-
}, [connectionId, color])
27+
const { gradient } = useMemo(() => getPresenceColors(connectionId, color), [connectionId, color])
6828

6929
// Determine avatar size
7030
const sizeClass = {
@@ -73,20 +33,39 @@ export function UserAvatar({
7333
lg: 'h-9 w-9 text-sm',
7434
}[size]
7535

36+
const pixelSize = {
37+
sm: 20,
38+
md: 28,
39+
lg: 36,
40+
}[size]
41+
7642
const initials = name ? name.charAt(0).toUpperCase() : '?'
43+
const hasAvatar = Boolean(avatarUrl)
7744

7845
const avatarElement = (
7946
<div
8047
className={`
81-
${sizeClass} flex flex-shrink-0 cursor-default items-center justify-center rounded-full border-2 border-white font-semibold text-white shadow-sm `}
48+
${sizeClass} relative flex flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full border-2 border-white font-semibold text-white shadow-sm `}
8249
style={
8350
{
84-
background: backgroundStyle,
51+
background: hasAvatar ? undefined : gradient,
8552
zIndex: 10 - index, // Higher index = lower z-index for stacking effect
8653
} as CSSProperties
8754
}
8855
>
89-
{initials}
56+
{hasAvatar && avatarUrl ? (
57+
<Image
58+
src={avatarUrl}
59+
alt={name ? `${name}'s avatar` : 'User avatar'}
60+
fill
61+
sizes={`${pixelSize}px`}
62+
className='object-cover'
63+
referrerPolicy='no-referrer'
64+
unoptimized={avatarUrl.startsWith('http')}
65+
/>
66+
) : (
67+
initials
68+
)}
9069
</div>
9170
)
9271

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { useMemo } from 'react'
4+
import { cn } from '@/lib/utils'
45
import { ConnectionStatus } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/connection-status/connection-status'
56
import { UserAvatar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar'
67
import { usePresence } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence'
@@ -11,6 +12,7 @@ interface User {
1112
name?: string
1213
color?: string
1314
info?: string
15+
avatarUrl?: string | null
1416
}
1517

1618
interface UserAvatarStackProps {
@@ -55,21 +57,19 @@ export function UserAvatarStack({
5557
lg: '-space-x-2',
5658
}[size]
5759

58-
return (
59-
<div className={`flex items-center gap-3 ${className}`}>
60-
{/* Connection status - always check, shows when offline or operation errors */}
61-
<ConnectionStatus isConnected={isConnected} hasOperationError={hasOperationError} />
60+
const shouldShowAvatars = visibleUsers.length > 0
6261

63-
{/* Only show avatar stack when there are multiple users (>1) */}
64-
{users.length > 1 && (
65-
<div className={`flex items-center ${spacingClass}`}>
66-
{/* Render visible user avatars */}
62+
return (
63+
<div className={`flex flex-col items-start gap-2 ${className}`}>
64+
{shouldShowAvatars && (
65+
<div className={cn('flex items-center px-2 py-1', spacingClass)}>
6766
{visibleUsers.map((user, index) => (
6867
<UserAvatar
6968
key={user.connectionId}
7069
connectionId={user.connectionId}
7170
name={user.name}
7271
color={user.color}
72+
avatarUrl={user.avatarUrl}
7373
size={size}
7474
index={index}
7575
tooltipContent={
@@ -85,10 +85,9 @@ export function UserAvatarStack({
8585
/>
8686
))}
8787

88-
{/* Render overflow indicator if there are more users */}
8988
{overflowCount > 0 && (
9089
<UserAvatar
91-
connectionId='overflow-indicator' // Use a unique string identifier
90+
connectionId='overflow-indicator'
9291
name={`+${overflowCount}`}
9392
size={size}
9493
index={visibleUsers.length}
@@ -106,6 +105,8 @@ export function UserAvatarStack({
106105
)}
107106
</div>
108107
)}
108+
109+
<ConnectionStatus isConnected={isConnected} hasOperationError={hasOperationError} />
109110
</div>
110111
)
111112
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use client'
2+
3+
import { memo, useMemo } from 'react'
4+
import { useViewport } from 'reactflow'
5+
import { useSession } from '@/lib/auth-client'
6+
import { getPresenceColors } from '@/lib/collaboration/presence-colors'
7+
import { useSocket } from '@/contexts/socket-context'
8+
9+
interface CursorPoint {
10+
x: number
11+
y: number
12+
}
13+
14+
interface CursorRenderData {
15+
id: string
16+
name: string
17+
cursor: CursorPoint
18+
gradient: string
19+
accentColor: string
20+
}
21+
22+
const POINTER_OFFSET = {
23+
x: 2,
24+
y: 18,
25+
}
26+
27+
const LABEL_BACKGROUND = 'rgba(15, 23, 42, 0.88)'
28+
29+
const CollaboratorCursorLayerComponent = () => {
30+
const { presenceUsers } = useSocket()
31+
const viewport = useViewport()
32+
const session = useSession()
33+
const currentUserId = session.data?.user?.id
34+
35+
const cursors = useMemo<CursorRenderData[]>(() => {
36+
if (!presenceUsers.length) {
37+
return []
38+
}
39+
40+
return presenceUsers
41+
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
42+
.filter((user) => user.userId !== currentUserId)
43+
.map((user) => {
44+
const cursor = user.cursor
45+
const name = user.userName?.trim() || 'Collaborator'
46+
const { gradient, accentColor } = getPresenceColors(user.userId)
47+
48+
return {
49+
id: user.socketId,
50+
name,
51+
cursor,
52+
gradient,
53+
accentColor,
54+
}
55+
})
56+
}, [currentUserId, presenceUsers])
57+
58+
if (!cursors.length) {
59+
return null
60+
}
61+
62+
return (
63+
<div className='pointer-events-none absolute inset-0 z-30 select-none'>
64+
{cursors.map(({ id, name, cursor, gradient, accentColor }) => {
65+
const x = cursor.x * viewport.zoom + viewport.x
66+
const y = cursor.y * viewport.zoom + viewport.y
67+
68+
return (
69+
<div
70+
key={id}
71+
className='pointer-events-none absolute'
72+
style={{
73+
transform: `translate3d(${x}px, ${y}px, 0)`,
74+
transition: 'transform 0.12s ease-out',
75+
}}
76+
>
77+
<div
78+
className='relative'
79+
style={{ transform: `translate(${-POINTER_OFFSET.x}px, ${-POINTER_OFFSET.y}px)` }}
80+
>
81+
<svg
82+
width={20}
83+
height={22}
84+
viewBox='0 0 20 22'
85+
className='drop-shadow-md'
86+
style={{ fill: accentColor, stroke: 'white', strokeWidth: 1.25 }}
87+
>
88+
<path d='M1 0L1 17L6.2 12.5L10.5 21.5L13.7 19.8L9.4 10.7L18.5 10.7L1 0Z' />
89+
</svg>
90+
91+
<div
92+
className='absolute top-[-28px] left-4 flex items-center gap-2 rounded-full px-2 py-1 font-medium text-white text-xs shadow-lg'
93+
style={{
94+
background: LABEL_BACKGROUND,
95+
border: `1px solid ${accentColor}`,
96+
backdropFilter: 'blur(8px)',
97+
}}
98+
>
99+
<span
100+
className='h-2.5 w-2.5 rounded-full border border-white/60'
101+
style={{ background: gradient }}
102+
/>
103+
<span>{name}</span>
104+
</div>
105+
</div>
106+
</div>
107+
)
108+
})}
109+
</div>
110+
)
111+
}
112+
113+
export const CollaboratorCursorLayer = memo(CollaboratorCursorLayerComponent)
114+
CollaboratorCursorLayer.displayName = 'CollaboratorCursorLayer'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ interface SocketPresenceUser {
88
socketId: string
99
userId: string
1010
userName: string
11-
cursor?: { x: number; y: number }
11+
avatarUrl?: string | null
12+
cursor?: { x: number; y: number } | null
1213
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
1314
}
1415

@@ -18,6 +19,7 @@ type PresenceUser = {
1819
name?: string
1920
color?: string
2021
info?: string
22+
avatarUrl?: string | null
2123
}
2224

2325
interface UsePresenceReturn {
@@ -48,6 +50,7 @@ export function usePresence(): UsePresenceReturn {
4850
name: user.userName,
4951
color: undefined, // Let the avatar component generate colors
5052
info: user.selection?.type ? `Editing ${user.selection.type}` : undefined,
53+
avatarUrl: user.avatarUrl,
5154
}))
5255
}, [presenceUsers])
5356

0 commit comments

Comments
 (0)