Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ and this project adheres to

### Fixed

- Fix flickering of active collaborator icons between states(active, inactive,
unavailable) [#3931](https://github.com/OpenFn/lightning/issues/3931)
- Restored footers to inspectors on the canvas while in read only mode
[#4018](https://github.com/OpenFn/lightning/issues/4018)
- Fix vertical scrolling in workflow panels
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cn } from '../../utils/cn';
import { useRemoteUsers } from '../hooks/useAwareness';
import { useAwareness } from '../hooks/useAwareness';
import { getAvatarInitials } from '../utils/avatar';

import { Tooltip } from './Tooltip';
Expand All @@ -15,7 +15,7 @@ interface ActiveCollaboratorsProps {
}

export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) {
const remoteUsers = useRemoteUsers();
const remoteUsers = useAwareness({ cached: true });

if (remoteUsers.length === 0) {
return null;
Expand Down Expand Up @@ -44,7 +44,7 @@ export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) {
return (
<Tooltip key={user.clientId} content={tooltipContent} side="right">
<div
className={`relative inline-flex items-center justify-center rounded-full border-2 ${user.lastSeen && lessthanmin(user.lastSeen, 2) ? 'border-green-500' : 'border-gray-500 '}`}
className={`relative inline-flex items-center justify-center rounded-full border-2 ${user.lastSeen && lessthanmin(user.lastSeen, 0.2) ? 'border-green-500' : 'border-gray-500 '}`}
>
<div
className="w-5 h-5 rounded-full flex items-center justify-center font-normal text-[9px] font-semibold text-white cursor-default"
Expand Down
20 changes: 11 additions & 9 deletions assets/js/collaborative-editor/components/CollaborationWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@

import { useSocket } from '../../react/contexts/SocketProvider';
import { cn } from '../../utils/cn';
import { useAwarenessUsers } from '../hooks/useAwareness';
import { useAwareness } from '../hooks/useAwareness';
import { useSession } from '../hooks/useSession';

export function CollaborationWidget() {
const { isConnected: socketConnected, connectionError } = useSocket();
const { isConnected: yjsConnected, isSynced } = useSession();

const users = useAwarenessUsers();
// Get remote users only (local user is always excluded)
const remoteUsers = useAwareness({ cached: true });

const getStatusColor = () => {
if (socketConnected && yjsConnected && isSynced) return 'bg-green-500';
Expand Down Expand Up @@ -40,19 +41,20 @@ export function CollaborationWidget() {
</div>

{/* Separator */}
{users.length > 0 && <div className="w-px h-3 bg-gray-300" />}
{remoteUsers.length > 0 && <div className="w-px h-3 bg-gray-300" />}

{/* Online users */}
{users.length > 0 && (
{remoteUsers.length > 0 && (
<div className="flex items-center gap-1">
<span className="text-gray-500">
{users.length} user{users.length !== 1 ? 's' : ''}:
You + {remoteUsers.length} other
{remoteUsers.length !== 1 ? 's' : ''}:
</span>
<div className="flex gap-1">
{users.slice(0, 3).map(user => (
{remoteUsers.slice(0, 3).map(user => (
<div
key={user.clientId}
className="flex items-center gap-1 px-2 py-0.5
className="flex items-center gap-1 px-2 py-0.5
bg-gray-50 rounded-full"
title={`${user.user.name} (Client ${user.clientId})`}
>
Expand All @@ -65,9 +67,9 @@ export function CollaborationWidget() {
</span>
</div>
))}
{users.length > 3 && (
{remoteUsers.length > 3 && (
<span className="text-gray-400 px-1">
+{users.length - 3} more
+{remoteUsers.length - 3} more
</span>
)}
</div>
Expand Down
14 changes: 6 additions & 8 deletions assets/js/collaborative-editor/components/Cursors.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from 'react';

import { useUserCursors, useRemoteUsers } from '../hooks/useAwareness';
import { useAwareness } from '../hooks/useAwareness';

function BaseStyles() {
const baseStyles = `
Expand Down Expand Up @@ -46,16 +46,14 @@ function BaseStyles() {
* Cursors component using awareness hooks for better performance and maintainability
*
* Key improvements:
* - Uses useUserCursors() hook with memoized Map for efficient lookups
* - Uses useRemoteUsers() for selection data (referentially stable)
* - Uses useAwareness() hook with Map format for efficient clientId lookups
* - Returns referentially stable data that only changes when users change
* - Eliminates manual awareness state management and reduces re-renders
*/
export function Cursors() {
// Get cursor data as a Map for efficient clientId lookups
const cursorsMap = useUserCursors();

// Get remote users for selection data
const remoteUsers = useRemoteUsers();
// Note: Uses live users only (not cached), always excludes local user
const cursorsMap = useAwareness({ format: 'map' });

// Dynamic user-specific cursor styles - now using Map entries
const userStyles = useMemo(() => {
Expand Down Expand Up @@ -127,7 +125,7 @@ export function Cursors() {
return () => {
editorElement?.removeEventListener('scroll', checkCursorPositions);
};
}, [remoteUsers.length]); // Only re-run when remote users change
}, [cursorsMap.size]); // Only re-run when live users change

return (
<>
Expand Down
99 changes: 58 additions & 41 deletions assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useViewport } from '@xyflow/react';
import { useMemo } from 'react';
import { memo, useMemo } from 'react';

import { cn } from '../../../utils/cn';
import { useRemoteUsers } from '../../hooks/useAwareness';
import { useAwareness } from '../../hooks/useAwareness';

import { denormalizePointerPosition } from './normalizePointer';

Expand All @@ -15,7 +15,7 @@ interface RemoteCursor {
}

export function RemoteCursors() {
const remoteUsers = useRemoteUsers();
const remoteUsers = useAwareness();
const { x: tx, y: ty, zoom: tzoom } = useViewport();

const cursors = useMemo<RemoteCursor[]>(() => {
Expand All @@ -30,12 +30,14 @@ export function RemoteCursors() {
[tx, ty, tzoom]
);

// Round to nearest pixel to reduce micro-updates
// that trigger unnecessary re-renders
return {
clientId: user.clientId,
name: user.user.name,
color: user.user.color,
x: screenPos.x,
y: screenPos.y,
x: Math.round(screenPos.x),
y: Math.round(screenPos.y),
};
});
}, [remoteUsers, tx, ty, tzoom]);
Expand Down Expand Up @@ -66,42 +68,57 @@ interface RemoteCursorProps {
y: number;
}

function RemoteCursor({ name, color, x, y }: RemoteCursorProps) {
return (
<div
className="absolute transition-all duration-100 ease-out"
style={{
left: `${x}px`,
top: `${y}px`,
transform: 'translate(-2px, -2px)', // small shift to align pointer well
}}
>
{/* Cursor pointer (SVG arrow) */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="drop-shadow-md"
>
<path
d="M 4.580503,0.60395675 A 2,2 0 0 1 6.850503,0.18395675 V 0.18395675 L 22.780503,7.4039568 A 2,2 0 0 1 23.980503,9.5239568 A 2.26,2.26 0 0 1 22.180503,11.523957 L 16.600503,12.653957 L 15.470503,18.233957 A 2.26,2.26 0 0 1 13.470503,20.033957 H 13.220503 A 2,2 0 0 1 11.350503,18.833957 L 4.160503,2.8739568 A 2,2 0 0 1 4.580503,0.60395675 Z"
fill={color}
stroke="white"
strokeWidth="1.5"
/>
</svg>
{/* User name label */}
// Memoize RemoteCursor to prevent re-renders when position hasn't changed
// significantly. This prevents CSS transitions from being interrupted by
// frequent viewport updates.
const RemoteCursor = memo(
function RemoteCursor({ name, color, x, y }: RemoteCursorProps) {
return (
<div
className={cn(
'absolute left-7 top-0 whitespace-nowrap rounded px-2 py-1',
'text-xs font-medium text-white shadow-md'
)}
style={{ backgroundColor: color }}
className="absolute transition-all duration-100 ease-out"
style={{
// Use transform for better GPU acceleration and smoother transitions
// Translate by (x-2, y-2) to align pointer well
transform: `translate(${x - 2}px, ${y - 2}px)`,
}}
>
{name}
{/* Cursor pointer (SVG arrow) */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="drop-shadow-md"
>
<path
d="M 4.580503,0.60395675 A 2,2 0 0 1 6.850503,0.18395675 V 0.18395675 L 22.780503,7.4039568 A 2,2 0 0 1 23.980503,9.5239568 A 2.26,2.26 0 0 1 22.180503,11.523957 L 16.600503,12.653957 L 15.470503,18.233957 A 2.26,2.26 0 0 1 13.470503,20.033957 H 13.220503 A 2,2 0 0 1 11.350503,18.833957 L 4.160503,2.8739568 A 2,2 0 0 1 4.580503,0.60395675 Z"
fill={color}
stroke="white"
strokeWidth="1.5"
/>
</svg>
{/* User name label */}
<div
className={cn(
'absolute left-7 top-0 whitespace-nowrap rounded px-2 py-1',
'text-xs font-medium text-white shadow-md'
)}
style={{ backgroundColor: color }}
>
{name}
</div>
</div>
</div>
);
}
);
},
// Custom comparison: only re-render if position changed by >1px
// This prevents render storms from micro viewport changes
(prev, next) => {
return (
prev.name === next.name &&
prev.color === next.color &&
Math.abs(prev.x - next.x) < 1 &&
Math.abs(prev.y - next.y) < 1
);
}
);
Loading