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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ and this project adheres to

### Added

- Show collaborators mouse pointers on the workflow canvas
[#3810](https://github.com/OpenFn/lightning/issues/3810)

### Changed

- Default failure notifications for project users are now disabled to minimize
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
*/

import { ReactFlowProvider } from '@xyflow/react';
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import {
useHistoryPanelCollapsed,
useEditorPreferencesCommands,
useHistoryPanelCollapsed,
} from '../../hooks/useEditorPreferences';
import {
useHistory,
useHistoryLoading,
useHistoryError,
useHistoryCommands,
useHistoryChannelConnected,
useHistoryCommands,
useHistoryError,
useHistoryLoading,
useRunSteps,
} from '../../hooks/useHistory';
import { useIsNewWorkflow } from '../../hooks/useSessionContext';
Expand Down Expand Up @@ -168,7 +168,7 @@ export function CollaborativeWorkflowDiagram({
forceFit={true}
showAiAssistant={false}
inspectorId={inspectorId}
containerEl={containerRef.current}
containerEl={containerRef.current!}
runSteps={currentRunSteps}
/>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useViewport } from '@xyflow/react';
import { useCallback, useEffect } from 'react';

import { useAwarenessCommands } from '#/collaborative-editor/hooks/useAwareness';

import { normalizePointerPosition } from './normalizePointer';
import { RemoteCursors } from './RemoteCursor';

export function PointerTrackerViewer({
containerEl: container,
}: {
containerEl: HTMLDivElement;
}) {
const { updateLocalCursor } = useAwarenessCommands();
const { x: tx, y: ty, zoom: tzoom } = useViewport();

const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!container) return;
const bounds = container.getBoundingClientRect();

const clientXRelativeToPane = e.clientX - bounds.left;
const clientYRelativeToPane = e.clientY - bounds.top;

const normPosition = normalizePointerPosition(
{
x: clientXRelativeToPane,
y: clientYRelativeToPane,
},
[tx, ty, tzoom]
);

updateLocalCursor({ x: normPosition.x, y: normPosition.y });
},
[updateLocalCursor, container, tx, ty, tzoom]
);

const handleMouseLeave = useCallback(() => {
updateLocalCursor(null);
}, [updateLocalCursor]);

useEffect(() => {
if (!container) return;

container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);

return () => {
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
};
}, [handleMouseMove, handleMouseLeave, container]);

return <RemoteCursors />;
}
107 changes: 107 additions & 0 deletions assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useViewport } from '@xyflow/react';
import { useMemo } from 'react';

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

import { denormalizePointerPosition } from './normalizePointer';

interface RemoteCursor {
clientId: number;
name: string;
color: string;
x: number;
y: number;
}

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

const cursors = useMemo<RemoteCursor[]>(() => {
return remoteUsers
.filter(user => user.cursor)
.map(user => {
const screenPos = denormalizePointerPosition(
{
x: user.cursor!.x,
y: user.cursor!.y,
},
[tx, ty, tzoom]
);

return {
clientId: user.clientId,
name: user.user.name,
color: user.user.color,
x: screenPos.x,
y: screenPos.y,
};
});
}, [remoteUsers, tx, ty, tzoom]);

if (cursors.length === 0) {
return null;
}

return (
<div className="pointer-events-none absolute inset-0 z-50">
{cursors.map(cursor => (
<RemoteCursor
key={cursor.clientId}
name={cursor.name}
color={cursor.color}
x={cursor.x}
y={cursor.y}
/>
))}
</div>
);
}

interface RemoteCursorProps {
name: string;
color: string;
x: number;
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 */}
<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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ import type { RunInfo } from '#/workflow-store/store';
import { createEmptyRunInfo } from '../../utils/runStepsTransformer';
import { AdaptorSelectionModal } from '../AdaptorSelectionModal';

import { useInspectorOverlap } from './useInspectorOverlap';
import { PointerTrackerViewer } from './PointerTrackerViewer';

type WorkflowDiagramProps = {
el?: HTMLElement | null;
containerEl?: HTMLElement | null;
containerEl: HTMLElement;
selection: string | null;
onSelectionChange: (id: string | null) => void;
forceFit?: boolean;
Expand Down Expand Up @@ -110,13 +110,7 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) {
const [flow, setFlow] = useState<typeof flowInstance | null>(null);
// value of select in props seems same as select in store.
// one in props is always set on initial render. (helps with refresh)
const {
selection,
onSelectionChange,
containerEl: el,
inspectorId,
runSteps,
} = props;
const { selection, onSelectionChange, containerEl: el, runSteps } = props;

// Get Y.Doc workflow store for placeholder operations
const workflowStore = useWorkflowStoreContext();
Expand Down Expand Up @@ -914,6 +908,7 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) {
className="border-2 border-gray-200"
nodeComponent={MiniMapNode}
/>
<PointerTrackerViewer containerEl={props.containerEl} />
</ReactFlow>
</ReactFlowProvider>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { XYPosition, Transform } from '@xyflow/react';

// functions for normalizing pointer positions based on zoom and pan.
// I was fustrated by the built-in screentoflowposition and its inverse provided by reactflow.
// credit: https://github.com/xyflow/xyflow/issues/3771#issuecomment-1880103788

export const denormalizePointerPosition = (
{ x, y }: XYPosition,
[tx, ty, tScale]: Transform
): XYPosition => {
const position: XYPosition = {
x: x * tScale + tx,
y: y * tScale + ty,
};

return position;
};

export const normalizePointerPosition = (
{ x, y }: XYPosition,
[tx, ty, tScale]: Transform
): XYPosition => {
const position: XYPosition = {
x: (x - tx) / tScale,
y: (y - ty) / tScale,
};

return position;
};
Loading