Skip to content

Commit 671f3fc

Browse files
committed
feat: added Collaborator presence to UI, Live cursor
1 parent adb8dfa commit 671f3fc

9 files changed

Lines changed: 280 additions & 18 deletions

File tree

app/api/liveblocks-auth/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@ export async function POST(request: Request) {
6363
"Unknown";
6464

6565
const avatar = user.imageUrl ?? "";
66-
const cursorColor = getCursorColor(userId);
66+
const color = getCursorColor(userId);
6767

6868
// 7. Prepare an access-token session for this user and room.
6969
const session = liveblocks.prepareSession(userId, {
70-
userInfo: { name, avatar, cursorColor },
70+
userInfo: { id: userId, name, avatar, color },
7171
});
7272

7373
session.allow(roomId, session.FULL_ACCESS);

components/editor/canvas-flow.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useCallback, useState } from "react";
4-
import type { DragEvent } from "react";
4+
import type { DragEvent, PointerEvent } from "react";
55
import {
66
ReactFlow,
77
ReactFlowProvider,
@@ -14,11 +14,12 @@ import {
1414
type NodeTypes,
1515
type EdgeTypes,
1616
} from "@xyflow/react";
17-
import { useLiveblocksFlow, Cursors } from "@liveblocks/react-flow";
18-
import { useUndo, useRedo } from "@liveblocks/react";
17+
import { useLiveblocksFlow } from "@liveblocks/react-flow";
18+
import { useUndo, useRedo, useUpdateMyPresence } from "@liveblocks/react";
1919

2020
import { CanvasNodeComponent } from "@/components/editor/canvas-node";
2121
import { CanvasEdgeComponent, CanvasEdgeMarkerDefs } from "@/components/editor/canvas-edge";
22+
import { CanvasPresenceOverlay, LiveCursors } from "@/components/editor/canvas-presence";
2223
import { ShapePanel, SHAPE_DRAG_MIME, type ShapeDragPayload } from "@/components/editor/shape-panel";
2324
import { CanvasControlBar } from "@/components/editor/canvas-control-bar";
2425
import { StarterTemplatesModal } from "@/components/editor/starter-templates-modal";
@@ -65,6 +66,7 @@ function CanvasFlowInner() {
6566
});
6667

6768
const { screenToFlowPosition, addNodes, zoomIn, zoomOut, fitView } = useReactFlow<CanvasNode>();
69+
const updateMyPresence = useUpdateMyPresence();
6870

6971
// Liveblocks history
7072
const undo = useUndo();
@@ -179,11 +181,25 @@ function CanvasFlowInner() {
179181
[edges, fitView, nodes, onDelete, onEdgesChange, onNodesChange],
180182
);
181183

184+
const handlePointerMove = useCallback(
185+
(e: PointerEvent<HTMLDivElement>) => {
186+
const cursor = screenToFlowPosition({ x: e.clientX, y: e.clientY });
187+
updateMyPresence({ cursor });
188+
},
189+
[screenToFlowPosition, updateMyPresence],
190+
);
191+
192+
const handlePointerLeave = useCallback(() => {
193+
updateMyPresence({ cursor: null });
194+
}, [updateMyPresence]);
195+
182196
return (
183197
<div
184198
className="relative h-full w-full"
185199
onDragOver={handleDragOver}
186200
onDrop={handleDrop}
201+
onPointerMove={handlePointerMove}
202+
onPointerLeave={handlePointerLeave}
187203
>
188204
<CanvasEdgeMarkerDefs />
189205
<ReactFlow
@@ -201,7 +217,7 @@ function CanvasFlowInner() {
201217
defaultEdgeOptions={{ type: CANVAS_EDGE_TYPE }}
202218
deleteKeyCode={["Backspace", "Delete"]}
203219
>
204-
<Cursors />
220+
<LiveCursors />
205221
<MiniMap
206222
position="bottom-right"
207223
nodeColor={() => "var(--accent-primary)"}
@@ -221,6 +237,7 @@ function CanvasFlowInner() {
221237
</ReactFlow>
222238

223239
{/* Floating overlays */}
240+
<CanvasPresenceOverlay />
224241
<CanvasControlBar onOpenTemplates={() => setIsTemplatesOpen(true)} />
225242
<ShapePanel />
226243
<StarterTemplatesModal
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"use client";
2+
3+
import { useMemo } from "react";
4+
import { useUser, UserButton } from "@clerk/nextjs";
5+
import { useOthers } from "@liveblocks/react/suspense";
6+
import { ViewportPortal } from "@xyflow/react";
7+
8+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
9+
10+
interface PresenceCursor {
11+
x: number;
12+
y: number;
13+
}
14+
15+
function getInitials(name: string): string {
16+
return name
17+
.split(" ")
18+
.filter(Boolean)
19+
.slice(0, 2)
20+
.map((part) => part[0]?.toUpperCase() ?? "")
21+
.join("");
22+
}
23+
24+
export function CanvasPresenceOverlay() {
25+
const { user } = useUser();
26+
const others = useOthers();
27+
28+
const collaborators = useMemo(() => {
29+
return others.filter((other) => other.id !== user?.id && other.info.id !== user?.id);
30+
}, [others, user?.id]);
31+
32+
const visibleCollaborators = collaborators.slice(0, 5);
33+
const overflowCount = Math.max(0, collaborators.length - visibleCollaborators.length);
34+
35+
return (
36+
<div className="absolute right-3 top-3 z-20 flex items-center rounded-md border border-[var(--border-default)] bg-[var(--bg-base)]/85 px-2 py-1 backdrop-blur-sm">
37+
{collaborators.length > 0 ? (
38+
<>
39+
<div className="flex -space-x-2">
40+
{visibleCollaborators.map((collaborator) => (
41+
<Avatar
42+
key={collaborator.connectionId}
43+
className="h-8 w-8 ring-2 ring-background"
44+
aria-hidden="true"
45+
>
46+
{collaborator.info.avatar ? (
47+
<AvatarImage src={collaborator.info.avatar} alt={collaborator.info.name} />
48+
) : null}
49+
<AvatarFallback className="text-xs font-medium">
50+
{getInitials(collaborator.info.name)}
51+
</AvatarFallback>
52+
</Avatar>
53+
))}
54+
{overflowCount > 0 ? (
55+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--bg-surface)] text-xs font-semibold text-[var(--text-primary)] ring-2 ring-background">
56+
+{overflowCount}
57+
</div>
58+
) : null}
59+
</div>
60+
<div className="mx-2 h-6 w-px bg-[var(--border-default)]" />
61+
</>
62+
) : null}
63+
<UserButton
64+
appearance={{
65+
elements: {
66+
userButtonAvatarBox: "h-8 w-8",
67+
},
68+
}}
69+
/>
70+
</div>
71+
);
72+
}
73+
74+
export function LiveCursors() {
75+
const { user } = useUser();
76+
const others = useOthers();
77+
78+
const participants = useMemo(() => {
79+
return others.filter((other) => {
80+
if (other.id === user?.id || other.info.id === user?.id) {
81+
return false;
82+
}
83+
return other.presence.cursor !== null;
84+
});
85+
}, [others, user?.id]);
86+
87+
return (
88+
<ViewportPortal>
89+
{participants.map((participant) => {
90+
const cursor = participant.presence.cursor as PresenceCursor | null;
91+
if (!cursor) return null;
92+
93+
const color = participant.info.color;
94+
return (
95+
<div
96+
key={participant.connectionId}
97+
className="pointer-events-none absolute select-none"
98+
style={{ left: cursor.x, top: cursor.y, transform: "translate(-1px, -1px)" }}
99+
>
100+
<svg
101+
width="18"
102+
height="24"
103+
viewBox="0 0 18 24"
104+
fill="none"
105+
xmlns="http://www.w3.org/2000/svg"
106+
aria-hidden="true"
107+
>
108+
<path
109+
d="M2 2L2 20L7 15.6L10.2 22L13.1 20.6L9.8 14.1L16 14L2 2Z"
110+
fill={color}
111+
stroke="var(--bg-base)"
112+
strokeWidth="1"
113+
strokeLinejoin="round"
114+
/>
115+
</svg>
116+
<div
117+
className="mt-1 inline-flex rounded-sm px-1.5 py-0.5 text-xs font-medium text-[var(--text-primary)]"
118+
style={{ backgroundColor: color }}
119+
>
120+
{participant.info.name}
121+
</div>
122+
</div>
123+
);
124+
})}
125+
</ViewportPortal>
126+
);
127+
}

components/editor/canvas-wrapper.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function CanvasWrapper({ roomId }: CanvasWrapperProps) {
6060
<LiveblocksProvider authEndpoint="/api/liveblocks-auth">
6161
<RoomProvider
6262
id={roomId}
63-
initialPresence={{ cursor: null, isThinking: false }}
63+
initialPresence={{ cursor: null, thinking: false }}
6464
>
6565
<CanvasErrorBoundary>
6666
<ClientSideSuspense
@@ -77,4 +77,3 @@ export function CanvasWrapper({ roomId }: CanvasWrapperProps) {
7777
</LiveblocksProvider>
7878
);
7979
}
80-

components/editor/starter-templates-modal.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ function TemplatePreview({ template }: { template: CanvasTemplate }) {
9191
};
9292

9393
return (
94-
<div className="relative aspect-video overflow-hidden rounded-md border border-[var(--border-default)] bg-[var(--bg-base)]">
94+
<div className="relative aspect-video overflow-hidden rounded-md border border-(--border-default) bg-(--bg-base)">
9595
<svg className="pointer-events-none absolute inset-0 h-full w-full" viewBox={`0 0 ${viewWidth} ${viewHeight}`}>
9696
{template.edges.map((edge) => {
9797
const source = getCenter(edge.source);
@@ -152,7 +152,7 @@ export function StarterTemplatesModal({
152152
description="Import a prebuilt architecture pattern into your canvas."
153153
contentClassName="w-[min(96vw,1000px)] sm:max-w-none"
154154
>
155-
<p className="rounded-md border border-[var(--state-error)]/40 bg-[color-mix(in_srgb,var(--state-error)_12%,transparent)] px-3 py-2 text-xs text-[var(--text-primary)]">
155+
<p className="rounded-md border border-(--state-error)/40 bg-[color-mix(in_srgb,var(--state-error)_12%,transparent)] px-3 py-2 text-xs text-(--text-primary)">
156156
Importing a template clears the current canvas before loading the selected pattern.
157157
</p>
158158

@@ -161,10 +161,10 @@ export function StarterTemplatesModal({
161161
{templates.map((template) => (
162162
<div
163163
key={template.id}
164-
className="rounded-md border border-[var(--border-default)] bg-[var(--bg-surface)] p-3"
164+
className="rounded-md border border-(--border-default) bg-(--bg-surface) p-3"
165165
>
166-
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{template.name}</h3>
167-
<p className="mt-1 text-xs text-[var(--text-muted)]">{template.description}</p>
166+
<h3 className="text-sm font-semibold text-(--text-primary)">{template.name}</h3>
167+
<p className="mt-1 text-xs text-(--text-muted)">{template.description}</p>
168168
<div className="mt-3">
169169
<TemplatePreview template={template} />
170170
</div>

components/ui/avatar.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
5+
import { cn } from "@/lib/utils";
6+
7+
const Avatar = React.forwardRef<
8+
HTMLDivElement,
9+
React.HTMLAttributes<HTMLDivElement>
10+
>(({ className, ...props }, ref) => (
11+
<div
12+
ref={ref}
13+
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
14+
{...props}
15+
/>
16+
));
17+
Avatar.displayName = "Avatar";
18+
19+
const AvatarImage = React.forwardRef<
20+
HTMLImageElement,
21+
React.ImgHTMLAttributes<HTMLImageElement>
22+
>(({ className, alt = "", ...props }, ref) => (
23+
/* eslint-disable-next-line @next/next/no-img-element */
24+
<img ref={ref} alt={alt} className={cn("aspect-square h-full w-full object-cover", className)} {...props} />
25+
));
26+
AvatarImage.displayName = "AvatarImage";
27+
28+
const AvatarFallback = React.forwardRef<
29+
HTMLDivElement,
30+
React.HTMLAttributes<HTMLDivElement>
31+
>(({ className, ...props }, ref) => (
32+
<div
33+
ref={ref}
34+
className={cn(
35+
"flex h-full w-full items-center justify-center rounded-full bg-[var(--bg-surface)] text-[var(--text-primary)]",
36+
className,
37+
)}
38+
{...props}
39+
/>
40+
));
41+
AvatarFallback.displayName = "AvatarFallback";
42+
43+
export { Avatar, AvatarImage, AvatarFallback };
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
Show active room participants inside the editor canvas view,
2+
without changing the editor home navbar.
3+
4+
## Implementation
5+
6+
1. Keep the existing navbar behavior as-is.
7+
- do not change the editor home navbar.
8+
- do not move or redesign the shared navbar component globally.
9+
- if the editor home and editor canvas use the same navbar component, make sure this presence UI only appears in the canvas/editor room view (e.g., by passing a `showPresence={true}` prop or checking if the route is within a specific project room).
10+
11+
2. Add the participant avatar group inside the editor canvas area.
12+
- position it in the top-right corner of the editor canvas view, overlaid on top of the React Flow canvas (absolute positioning with a high z-index).
13+
- keep it visually separate from the main navbar actions.
14+
- get the current user's ID from the active Clerk session using `useUser()`.
15+
- fetch the collaborators using Liveblocks `useOthers()`.
16+
- filter the Liveblocks presence list to exclude any entry whose user ID matches the current Clerk user ID.
17+
- render the filtered list as collaborator avatars only.
18+
- render the current user separately using the existing Clerk `<UserButton />` - do not render a second avatar for them from the Liveblocks presence list.
19+
- keep collaborator avatars and the Clerk UserButton the same size (e.g., `h-8 w-8`) so the group looks visually consistent.
20+
- collaborator avatars are display-only, not interactive.
21+
- show a vertical divider (`border-l` or `<Separator orientation="vertical" />`) between the collaborator avatars and the Clerk UserButton only when at least one collaborator exists.
22+
- if no collaborators are present, show only the Clerk UserButton with no divider.
23+
24+
3. Render collaborator avatars.
25+
- wrap them in a flex container with `-space-x-2` to create an overlapping stack effect.
26+
- use profile photos when available (passed via Liveblocks `UserMeta.info`).
27+
- fall back to initials using `shadcn/ui` Avatar fallback when there is no image.
28+
- show up to five collaborator avatars.
29+
- show a `+N` overflow chip when there are more than five (e.g., `+3`).
30+
- add a subtle ring (`ring-2 ring-background`) so avatars stay readable and isolated on the dark canvas.
31+
32+
4. Add live cursors to the canvas.
33+
- render cursors for other participants only using `useOthers()`, never the current user.
34+
- use the existing Liveblocks `useUpdateMyPresence()` state to broadcast cursor position.
35+
- **Crucial React Flow Detail**: update cursor position on React Flow's wrapper `onPointerMove` event. Wrap the canvas and capture the client X/Y.
36+
- Use React Flow's `screenToFlowPosition({ x, y })` from `useReactFlow()` so that the cursors remain accurate regardless of canvas panning or zooming.
37+
- clear cursor to `null` on `onPointerLeave`.
38+
- show a small colored custom SVG pointer with a name badge attached.
39+
- match the pointer and badge color to the participant's assigned presence color (e.g., mapping connection IDs to a set of colors).
40+
41+
5. Define the shared presence type in `liveblocks.config.ts`.
42+
43+
Presence should include:
44+
- `cursor`: `{ x: number; y: number } | null`
45+
- `thinking`: boolean
46+
47+
UserMeta should include user information tied from Clerk:
48+
- `id`: string
49+
- `info`: `{ name: string; avatar: string; color: string }`
50+
51+
## Scope Limits
52+
53+
- don't add participant avatars to the shared navbar globally.
54+
- don't remove existing navbar actions like Save, Import, Share, or AI.
55+
- don't replace Clerk user/Profile/Logout behaviour.
56+
- don't make collaborator avatars interactive.
57+
- don't change canvas node or edge behavior.

0 commit comments

Comments
 (0)