|
9 | 9 | Background, |
10 | 10 | BackgroundVariant, |
11 | 11 | ConnectionMode, |
12 | | - useViewport, |
| 12 | + useReactFlow as useReactFlowHook, |
13 | 13 | Node, |
14 | 14 | Edge, |
15 | 15 | } from "@xyflow/react"; |
@@ -48,6 +48,7 @@ import { |
48 | 48 | useLayoutEffect, |
49 | 49 | useRef, |
50 | 50 | useCallback, |
| 51 | + memo, |
51 | 52 | } from "react"; |
52 | 53 | import { |
53 | 54 | Plus, |
@@ -82,62 +83,151 @@ const FIXED_EXTENT: [[number, number], [number, number]] = [ |
82 | 83 | ]; |
83 | 84 | import { ProjectCanvasProps } from "./utils/types"; |
84 | 85 | import { UserMapProvider } from "./UserMapContext"; |
85 | | - |
86 | | -const RemoteCursors = ({ |
87 | | - activeUsers, |
| 86 | +import type { CursorPosition } from "./hooks/useProjectCanvasRealtime"; |
| 87 | + |
| 88 | +/** |
| 89 | + * Imperative remote cursors — bypasses React rendering entirely. |
| 90 | + * Reads cursor positions from a ref (updated by awareness) and viewport |
| 91 | + * from useReactFlow().getViewport() inside a rAF loop. Uses JS lerp |
| 92 | + * interpolation for smooth cursor movement (no CSS transitions). |
| 93 | + */ |
| 94 | +/** Ease-out cursor interpolation: fast catch-up, gentle braking */ |
| 95 | +const LERP_MIN = 0.12; |
| 96 | +const LERP_MAX = 0.45; |
| 97 | +const LERP_DIST_SCALE = 200; |
| 98 | +const SNAP_THRESHOLD = 0.3; |
| 99 | + |
| 100 | +const RemoteCursors = memo(function RemoteCursors({ |
| 101 | + presenceUsers, |
88 | 102 | currentUserId, |
| 103 | + remoteCursorsRef, |
89 | 104 | }: { |
90 | | - activeUsers: UserPresence[]; |
| 105 | + presenceUsers: UserPresence[]; |
91 | 106 | currentUserId?: string; |
92 | | -}) => { |
93 | | - const { x: vX, y: vY, zoom } = useViewport(); |
| 107 | + remoteCursorsRef: React.RefObject<Map<string, CursorPosition>>; |
| 108 | +}) { |
| 109 | + const { getViewport } = useReactFlowHook(); |
| 110 | + const cursorElsRef = useRef<Map<string, HTMLDivElement>>(new Map()); |
| 111 | + const displayedRef = useRef<Map<string, { x: number; y: number }>>(new Map()); |
| 112 | + const rafRef = useRef<number | null>(null); |
| 113 | + |
| 114 | + const remoteUsers = useMemo( |
| 115 | + () => presenceUsers.filter((u) => u.id !== currentUserId), |
| 116 | + [presenceUsers, currentUserId], |
| 117 | + ); |
| 118 | + |
| 119 | + const setCursorRef = useCallback( |
| 120 | + (userId: string, el: HTMLDivElement | null) => { |
| 121 | + if (el) cursorElsRef.current.set(userId, el); |
| 122 | + else { |
| 123 | + cursorElsRef.current.delete(userId); |
| 124 | + displayedRef.current.delete(userId); |
| 125 | + } |
| 126 | + }, |
| 127 | + [], |
| 128 | + ); |
| 129 | + |
| 130 | + useEffect(() => { |
| 131 | + if (remoteUsers.length === 0) { |
| 132 | + rafRef.current = null; |
| 133 | + return; |
| 134 | + } |
| 135 | + |
| 136 | + let running = true; |
| 137 | + |
| 138 | + const animate = () => { |
| 139 | + if (!running) return; |
| 140 | + const vp = getViewport(); |
| 141 | + const cursors = remoteCursorsRef.current; |
| 142 | + |
| 143 | + cursorElsRef.current.forEach((el, userId) => { |
| 144 | + const pos = cursors?.get(userId); |
| 145 | + if (!pos) { |
| 146 | + el.style.display = "none"; |
| 147 | + return; |
| 148 | + } |
94 | 149 |
|
95 | | - if (!activeUsers) return null; |
| 150 | + const targetX = pos.x * vp.zoom + vp.x; |
| 151 | + const targetY = pos.y * vp.zoom + vp.y; |
| 152 | + if (isNaN(targetX) || isNaN(targetY)) { |
| 153 | + el.style.display = "none"; |
| 154 | + return; |
| 155 | + } |
| 156 | + |
| 157 | + let displayed = displayedRef.current.get(userId); |
| 158 | + if (!displayed) { |
| 159 | + displayed = { x: targetX, y: targetY }; |
| 160 | + displayedRef.current.set(userId, displayed); |
| 161 | + } |
| 162 | + |
| 163 | + const dx = targetX - displayed.x; |
| 164 | + const dy = targetY - displayed.y; |
| 165 | + const dist = Math.sqrt(dx * dx + dy * dy); |
| 166 | + |
| 167 | + if (dist < SNAP_THRESHOLD) { |
| 168 | + displayed.x = targetX; |
| 169 | + displayed.y = targetY; |
| 170 | + } else { |
| 171 | + // Distance-based lerp: fast when far, slow braking when close |
| 172 | + const t = |
| 173 | + LERP_MIN + |
| 174 | + (LERP_MAX - LERP_MIN) * Math.min(1, dist / LERP_DIST_SCALE); |
| 175 | + displayed.x += dx * t; |
| 176 | + displayed.y += dy * t; |
| 177 | + } |
| 178 | + |
| 179 | + el.style.display = ""; |
| 180 | + el.style.transform = `translate3d(${displayed.x}px, ${displayed.y}px, 0)`; |
| 181 | + }); |
| 182 | + |
| 183 | + rafRef.current = requestAnimationFrame(animate); |
| 184 | + }; |
| 185 | + |
| 186 | + rafRef.current = requestAnimationFrame(animate); |
| 187 | + return () => { |
| 188 | + running = false; |
| 189 | + if (rafRef.current) cancelAnimationFrame(rafRef.current); |
| 190 | + }; |
| 191 | + }, [remoteUsers, getViewport, remoteCursorsRef]); |
| 192 | + |
| 193 | + if (remoteUsers.length === 0) return null; |
96 | 194 |
|
97 | 195 | return ( |
98 | 196 | <> |
99 | | - {activeUsers.map((user) => { |
100 | | - if (!user.cursor || user.id === currentUserId) return null; |
101 | | - const x = user.cursor.x * zoom + vX; |
102 | | - const y = user.cursor.y * zoom + vY; |
103 | | - |
104 | | - if (isNaN(x) || isNaN(y)) return null; |
105 | | - |
106 | | - return ( |
107 | | - <div |
108 | | - key={user.id} |
109 | | - className="remote-cursor" |
110 | | - style={ |
111 | | - { |
112 | | - "--cursor-x": `${x}px`, |
113 | | - "--cursor-y": `${y}px`, |
114 | | - "--user-color": user.color || "#000", |
115 | | - } as React.CSSProperties |
116 | | - } |
117 | | - > |
118 | | - {!user.isTyping && ( |
119 | | - <> |
120 | | - <svg |
121 | | - width="24" |
122 | | - height="24" |
123 | | - viewBox="0 0 24 24" |
124 | | - fill="none" |
125 | | - xmlns="http://www.w3.org/2000/svg" |
126 | | - className="remote-cursor-svg" |
127 | | - > |
128 | | - <path d="M0 0L14 14L7 14L0 21V0Z" fill="currentColor" /> |
129 | | - </svg> |
130 | | - <div className="remote-cursor-name"> |
131 | | - {user.displayName || user.username} |
132 | | - </div> |
133 | | - </> |
134 | | - )} |
135 | | - </div> |
136 | | - ); |
137 | | - })} |
| 197 | + {remoteUsers.map((user) => ( |
| 198 | + <div |
| 199 | + key={user.id} |
| 200 | + ref={(el) => setCursorRef(user.id, el)} |
| 201 | + className="remote-cursor" |
| 202 | + style={ |
| 203 | + { |
| 204 | + display: "none", |
| 205 | + "--user-color": user.color || "#000", |
| 206 | + } as React.CSSProperties |
| 207 | + } |
| 208 | + > |
| 209 | + {!user.isTyping && ( |
| 210 | + <> |
| 211 | + <svg |
| 212 | + width="24" |
| 213 | + height="24" |
| 214 | + viewBox="0 0 24 24" |
| 215 | + fill="none" |
| 216 | + xmlns="http://www.w3.org/2000/svg" |
| 217 | + className="remote-cursor-svg" |
| 218 | + > |
| 219 | + <path d="M0 0L14 14L7 14L0 21V0Z" fill="currentColor" /> |
| 220 | + </svg> |
| 221 | + <div className="remote-cursor-name"> |
| 222 | + {user.displayName || user.username} |
| 223 | + </div> |
| 224 | + </> |
| 225 | + )} |
| 226 | + </div> |
| 227 | + ))} |
138 | 228 | </> |
139 | 229 | ); |
140 | | -}; |
| 230 | +}); |
141 | 231 |
|
142 | 232 | const blockTypes = { |
143 | 233 | text: CanvasBlock, |
@@ -332,7 +422,8 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { |
332 | 422 | onBlockClick, |
333 | 423 | onLinkClick, |
334 | 424 | handleCreateBlock, |
335 | | - activeUsers, |
| 425 | + remoteCursorsRef, |
| 426 | + presenceUsers, |
336 | 427 | shareCursor, |
337 | 428 | setShareCursor, |
338 | 429 | projectOwnerId, |
@@ -598,8 +689,8 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { |
598 | 689 |
|
599 | 690 | const isTyping = useMemo(() => { |
600 | 691 | if (!currentUser) return false; |
601 | | - return activeUsers.some((u) => u.id === currentUser.id && u.isTyping); |
602 | | - }, [activeUsers, currentUser]); |
| 692 | + return presenceUsers.some((u) => u.id === currentUser.id && u.isTyping); |
| 693 | + }, [presenceUsers, currentUser]); |
603 | 694 |
|
604 | 695 | const contextMenuRef = useRef<HTMLDivElement>(null); |
605 | 696 |
|
@@ -702,7 +793,7 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { |
702 | 793 | </div> |
703 | 794 | )} |
704 | 795 |
|
705 | | - <UserMapProvider activeUsers={activeUsers}> |
| 796 | + <UserMapProvider activeUsers={presenceUsers}> |
706 | 797 | <ReactFlow |
707 | 798 | nodes={blocksWithPreview} |
708 | 799 | edges={links} |
@@ -757,8 +848,9 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { |
757 | 848 | style={{ width: "100%", height: "100%", zIndex: 1500 }} |
758 | 849 | > |
759 | 850 | <RemoteCursors |
760 | | - activeUsers={activeUsers} |
| 851 | + presenceUsers={presenceUsers} |
761 | 852 | currentUserId={currentUser?.id} |
| 853 | + remoteCursorsRef={remoteCursorsRef} |
762 | 854 | /> |
763 | 855 | </Panel> |
764 | 856 | <Background |
@@ -871,7 +963,7 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { |
871 | 963 | <div className="flex items-center gap-2"> |
872 | 964 | {!isPreviewMode && ( |
873 | 965 | <div className="flex gap-2 mr-2"> |
874 | | - {activeUsers.map((u) => ( |
| 966 | + {presenceUsers.map((u) => ( |
875 | 967 | <div |
876 | 968 | key={u.id} |
877 | 969 | className="user-presence-item relative shrink-0" |
|
0 commit comments