summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-12 18:11:10 -0500
committerhaoyuren <13851610112@163.com>2026-03-12 18:11:10 -0500
commita0dd3d7ac642111faeaefd02c5a452898b9c6d49 (patch)
tree2f435e189bd38505b9793b78de51b3a1c282f1c6
parentb116335f9dbde4f483c0b2b8e7bfca5d321c5dfc (diff)
Add collaborator cursors and project chat
Collaborator cursors: - Real-time cursor positions via clientTracking Socket.IO events - CM6 extension renders colored cursor widgets with name labels - Throttled cursor position broadcasting (300ms) - Connected users count in toolbar and status bar Project chat: - Chat panel in right sidebar (toggleable) - Load message history via REST API - Send messages with real-time delivery via Socket.IO new-chat-message - Auto-scroll, avatars, timestamps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--src/main/index.ts45
-rw-r--r--src/main/overleafSocket.ts18
-rw-r--r--src/preload/index.ts27
-rw-r--r--src/renderer/src/App.css195
-rw-r--r--src/renderer/src/App.tsx66
-rw-r--r--src/renderer/src/components/ChatPanel.tsx145
-rw-r--r--src/renderer/src/components/Editor.tsx45
-rw-r--r--src/renderer/src/components/StatusBar.tsx3
-rw-r--r--src/renderer/src/components/Toolbar.tsx11
-rw-r--r--src/renderer/src/extensions/remoteCursors.ts111
-rw-r--r--src/renderer/src/stores/appStore.ts18
11 files changed, 677 insertions, 7 deletions
diff --git a/src/main/index.ts b/src/main/index.ts
index 21b6e43..89a04b0 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -620,6 +620,17 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => {
})
})
+ // Relay collaborator cursor updates to renderer
+ overleafSock.on('serverEvent', (name: string, args: unknown[]) => {
+ if (name === 'clientTracking.clientUpdated') {
+ mainWindow?.webContents.send('cursor:remoteUpdate', args[0])
+ } else if (name === 'clientTracking.clientDisconnected') {
+ mainWindow?.webContents.send('cursor:remoteDisconnected', args[0])
+ } else if (name === 'new-chat-message') {
+ mainWindow?.webContents.send('chat:newMessage', args[0])
+ }
+ })
+
const projectResult = await overleafSock.connect(projectId, overleafSessionCookie)
const { files, docPathMap, pathDocMap, fileRefs, rootFolderId } = walkRootFolder(projectResult.project.rootFolder)
@@ -745,6 +756,40 @@ ipcMain.handle('sync:contentChanged', async (_e, docId: string, content: string)
fileSyncBridge?.onEditorContentChanged(docId, content)
})
+// ── Cursor Tracking ────────────────────────────────────────────
+
+ipcMain.handle('cursor:update', async (_e, docId: string, row: number, column: number) => {
+ overleafSock?.updateCursorPosition(docId, row, column)
+})
+
+ipcMain.handle('cursor:getConnectedUsers', async () => {
+ if (!overleafSock) return []
+ try {
+ return await overleafSock.getConnectedUsers()
+ } catch (e) {
+ console.log('[cursor:getConnectedUsers] error:', e)
+ return []
+ }
+})
+
+// ── Chat ───────────────────────────────────────────────────────
+
+ipcMain.handle('chat:getMessages', async (_e, projectId: string, limit?: number) => {
+ if (!overleafSessionCookie) return { success: false, messages: [] }
+ const result = await overleafFetch(`/project/${projectId}/messages?limit=${limit || 50}`)
+ if (!result.ok) return { success: false, messages: [] }
+ return { success: true, messages: result.data }
+})
+
+ipcMain.handle('chat:sendMessage', async (_e, projectId: string, content: string) => {
+ if (!overleafSessionCookie) return { success: false }
+ const result = await overleafFetch(`/project/${projectId}/messages`, {
+ method: 'POST',
+ body: JSON.stringify({ content })
+ })
+ return { success: result.ok }
+})
+
ipcMain.handle('overleaf:listProjects', async () => {
if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
diff --git a/src/main/overleafSocket.ts b/src/main/overleafSocket.ts
index f825c4c..52ac20f 100644
--- a/src/main/overleafSocket.ts
+++ b/src/main/overleafSocket.ts
@@ -92,6 +92,10 @@ export class OverleafSocket extends EventEmitter {
return this._projectData
}
+ get publicId(): string | null {
+ return this._projectData?.publicId || null
+ }
+
private setState(s: ConnectionState) {
this._state = s
this.emit('connectionState', s)
@@ -270,6 +274,20 @@ export class OverleafSocket extends EventEmitter {
this.ws?.send(encodeEvent('applyOtUpdate', [docId, { doc: docId, op: ops, v: version, hash, lastV: version }]))
}
+ /** Get list of connected users with their cursor positions */
+ async getConnectedUsers(): Promise<unknown[]> {
+ const result = await this.emitWithAck('clientTracking.getConnectedUsers', []) as unknown[]
+ // result format: [error, usersArray]
+ const err = result[0]
+ if (err) throw new Error(`getConnectedUsers failed: ${JSON.stringify(err)}`)
+ return (result[1] as unknown[]) || []
+ }
+
+ /** Send our cursor position */
+ updateCursorPosition(docId: string, row: number, column: number): void {
+ this.ws?.send(encodeEvent('clientTracking.updatePosition', [{ row, column, doc_id: docId }]))
+ }
+
disconnect() {
this.shouldReconnect = false
this.stopHeartbeat()
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 5dcbbff..ca7f098 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -144,6 +144,33 @@ const api = {
syncContentChanged: (docId: string, content: string) =>
ipcRenderer.invoke('sync:contentChanged', docId, content),
+ // Cursor tracking
+ cursorUpdate: (docId: string, row: number, column: number) =>
+ ipcRenderer.invoke('cursor:update', docId, row, column),
+ cursorGetConnectedUsers: () =>
+ ipcRenderer.invoke('cursor:getConnectedUsers') as Promise<unknown[]>,
+ onCursorRemoteUpdate: (cb: (data: unknown) => void) => {
+ const handler = (_e: Electron.IpcRendererEvent, data: unknown) => cb(data)
+ ipcRenderer.on('cursor:remoteUpdate', handler)
+ return () => ipcRenderer.removeListener('cursor:remoteUpdate', handler)
+ },
+ onCursorRemoteDisconnected: (cb: (clientId: string) => void) => {
+ const handler = (_e: Electron.IpcRendererEvent, clientId: string) => cb(clientId)
+ ipcRenderer.on('cursor:remoteDisconnected', handler)
+ return () => ipcRenderer.removeListener('cursor:remoteDisconnected', handler)
+ },
+
+ // Chat
+ chatGetMessages: (projectId: string, limit?: number) =>
+ ipcRenderer.invoke('chat:getMessages', projectId, limit) as Promise<{ success: boolean; messages: unknown[] }>,
+ chatSendMessage: (projectId: string, content: string) =>
+ ipcRenderer.invoke('chat:sendMessage', projectId, content) as Promise<{ success: boolean }>,
+ onChatMessage: (cb: (msg: unknown) => void) => {
+ const handler = (_e: Electron.IpcRendererEvent, msg: unknown) => cb(msg)
+ ipcRenderer.on('chat:newMessage', handler)
+ return () => ipcRenderer.removeListener('chat:newMessage', handler)
+ },
+
// Shell
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
showInFinder: (path: string) => ipcRenderer.invoke('shell:showInFinder', path)
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
index 9ecaefc..ec9cf88 100644
--- a/src/renderer/src/App.css
+++ b/src/renderer/src/App.css
@@ -1844,3 +1844,198 @@ html, body, #root {
.status-git {
color: #E8DFC0;
}
+
+/* ── Remote Cursors ──────────────────────────────────────────── */
+
+.cm-remote-cursor {
+ position: relative;
+ display: inline;
+ pointer-events: none;
+}
+
+.cm-remote-cursor-line {
+ position: absolute;
+ top: 0;
+ height: 1.2em;
+ border-left: 2px solid;
+ z-index: 10;
+}
+
+.cm-remote-cursor-label {
+ position: absolute;
+ top: -1.4em;
+ left: -1px;
+ font-size: 10px;
+ line-height: 1.4;
+ padding: 0 4px;
+ border-radius: 3px 3px 3px 0;
+ color: white;
+ white-space: nowrap;
+ z-index: 11;
+ font-family: var(--font-sans);
+ font-weight: 500;
+ transition: opacity 0.5s;
+ pointer-events: none;
+}
+
+.cm-remote-cursor-label.faded {
+ opacity: 0;
+}
+
+/* ── Toolbar Users Count ─────────────────────────────────────── */
+
+.toolbar-users {
+ font-size: 11px;
+ color: var(--text-muted);
+ background: var(--bg-hover);
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-weight: 500;
+}
+
+/* ── Chat Panel ──────────────────────────────────────────────── */
+
+.chat-panel {
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-primary);
+ flex: 1;
+ min-height: 0;
+}
+
+.review-sidebar .chat-panel {
+ border-top: 1px solid var(--border);
+}
+
+.chat-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 36px;
+ padding: 0 10px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted);
+}
+
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.chat-loading,
+.chat-empty {
+ padding: 24px 16px;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.chat-message {
+ display: flex;
+ gap: 8px;
+ padding: 6px 4px;
+ border-radius: var(--radius-sm);
+}
+
+.chat-message:hover {
+ background: var(--bg-secondary);
+}
+
+.chat-avatar {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 600;
+ color: white;
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.chat-message-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.chat-message-header {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ margin-bottom: 2px;
+}
+
+.chat-user-name {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.chat-time {
+ font-size: 10px;
+ color: var(--text-muted);
+}
+
+.chat-message-content {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.5;
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+
+.chat-input-area {
+ display: flex;
+ gap: 4px;
+ padding: 6px 8px;
+ border-top: 1px solid var(--border);
+ background: var(--bg-secondary);
+}
+
+.chat-input {
+ flex: 1;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 6px 8px;
+ font-size: 12px;
+ font-family: var(--font-sans);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ outline: none;
+}
+
+.chat-input:focus {
+ border-color: var(--accent);
+}
+
+.chat-send-btn {
+ border: none;
+ background: var(--accent);
+ color: var(--bg-primary);
+ font-size: 11px;
+ padding: 6px 12px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-family: var(--font-sans);
+ font-weight: 500;
+}
+
+.chat-send-btn:hover {
+ background: var(--accent-hover);
+}
+
+.chat-send-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 455213b..1eb1d76 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, Component, type ReactNode } from 'react'
+import { useState, useEffect, useCallback, useRef, Component, type ReactNode } from 'react'
import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels'
import { useAppStore } from './stores/appStore'
import ModalProvider from './components/ModalProvider'
@@ -9,11 +9,16 @@ import Editor from './components/Editor'
import PdfViewer from './components/PdfViewer'
import Terminal from './components/Terminal'
import ReviewPanel from './components/ReviewPanel'
+import ChatPanel from './components/ChatPanel'
import StatusBar from './components/StatusBar'
import type { OverleafDocSync } from './ot/overleafSync'
+import { colorForUser, type RemoteCursor } from './extensions/remoteCursors'
export const activeDocSyncs = new Map<string, OverleafDocSync>()
+// Global remote cursor state — shared between App and Editor
+export const remoteCursors = new Map<string, RemoteCursor & { docId: string }>()
+
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null }
static getDerivedStateFromError(error: Error) { return { error } }
@@ -39,6 +44,7 @@ export default function App() {
showTerminal,
showFileTree,
showReviewPanel,
+ showChat,
} = useAppStore()
const [checkingSession, setCheckingSession] = useState(true)
@@ -83,12 +89,65 @@ export default function App() {
if (sync) sync.replaceContent(data.content)
})
+ // Listen for remote cursor updates
+ const unsubCursorUpdate = window.api.onCursorRemoteUpdate((raw) => {
+ const data = raw as {
+ id: string; user_id: string; name: string; email: string;
+ doc_id: string; row: number; column: number
+ }
+ remoteCursors.set(data.id, {
+ userId: data.id,
+ name: data.name || data.email?.split('@')[0] || 'User',
+ color: colorForUser(data.user_id || data.id),
+ row: data.row,
+ column: data.column,
+ docId: data.doc_id
+ })
+ // Update online users count
+ useAppStore.getState().setOnlineUsersCount(remoteCursors.size)
+ // Notify editor to refresh cursors
+ window.dispatchEvent(new CustomEvent('remoteCursorsChanged'))
+ })
+
+ const unsubCursorDisconnected = window.api.onCursorRemoteDisconnected((clientId) => {
+ remoteCursors.delete(clientId)
+ useAppStore.getState().setOnlineUsersCount(remoteCursors.size)
+ window.dispatchEvent(new CustomEvent('remoteCursorsChanged'))
+ })
+
+ // Fetch initial connected users
+ window.api.cursorGetConnectedUsers().then((users) => {
+ const arr = users as Array<{
+ client_id: string; user_id: string;
+ first_name: string; last_name?: string; email: string;
+ cursorData?: { doc_id: string; row: number; column: number }
+ }>
+ for (const u of arr) {
+ if (u.cursorData) {
+ const name = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email?.split('@')[0] || 'User'
+ remoteCursors.set(u.client_id, {
+ userId: u.client_id,
+ name,
+ color: colorForUser(u.user_id || u.client_id),
+ row: u.cursorData.row,
+ column: u.cursorData.column,
+ docId: u.cursorData.doc_id
+ })
+ }
+ }
+ useAppStore.getState().setOnlineUsersCount(remoteCursors.size)
+ window.dispatchEvent(new CustomEvent('remoteCursorsChanged'))
+ })
+
return () => {
unsubRemoteOp()
unsubAck()
unsubState()
unsubRejoined()
unsubExternalEdit()
+ unsubCursorUpdate()
+ unsubCursorDisconnected()
+ remoteCursors.clear()
}
}, [screen, setStatusMessage])
@@ -273,9 +332,10 @@ export default function App() {
</PanelGroup>
</Panel>
</PanelGroup>
- {showReviewPanel && (
+ {(showReviewPanel || showChat) && (
<div className="review-sidebar">
- <ReviewPanel />
+ {showReviewPanel && <ReviewPanel />}
+ {showChat && <ChatPanel />}
</div>
)}
</div>
diff --git a/src/renderer/src/components/ChatPanel.tsx b/src/renderer/src/components/ChatPanel.tsx
new file mode 100644
index 0000000..a0638bd
--- /dev/null
+++ b/src/renderer/src/components/ChatPanel.tsx
@@ -0,0 +1,145 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { useAppStore } from '../stores/appStore'
+
+interface ChatMessage {
+ id: string
+ content: string
+ timestamp: number
+ user: {
+ id: string
+ first_name: string
+ last_name?: string
+ email?: string
+ }
+}
+
+export default function ChatPanel() {
+ const overleafProjectId = useAppStore((s) => s.overleafProjectId)
+ const [messages, setMessages] = useState<ChatMessage[]>([])
+ const [input, setInput] = useState('')
+ const [sending, setSending] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const messagesEndRef = useRef<HTMLDivElement>(null)
+ const containerRef = useRef<HTMLDivElement>(null)
+
+ // Load initial messages
+ useEffect(() => {
+ if (!overleafProjectId) return
+ setLoading(true)
+ window.api.chatGetMessages(overleafProjectId, 50).then((result) => {
+ if (result.success) {
+ // Messages come newest-first from API, reverse for display
+ const msgs = (result.messages as ChatMessage[]).reverse()
+ setMessages(msgs)
+ }
+ setLoading(false)
+ })
+ }, [overleafProjectId])
+
+ // Listen for new messages via Socket.IO
+ useEffect(() => {
+ const unsub = window.api.onChatMessage((raw) => {
+ const msg = raw as ChatMessage
+ setMessages((prev) => {
+ // Deduplicate by id
+ if (prev.some((m) => m.id === msg.id)) return prev
+ return [...prev, msg]
+ })
+ })
+ return unsub
+ }, [])
+
+ // Auto-scroll to bottom on new messages
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [messages])
+
+ const handleSend = useCallback(async () => {
+ if (!input.trim() || !overleafProjectId || sending) return
+ const content = input.trim()
+ setInput('')
+ setSending(true)
+ await window.api.chatSendMessage(overleafProjectId, content)
+ setSending(false)
+ }, [input, overleafProjectId, sending])
+
+ const formatTime = (ts: number) => {
+ const d = new Date(ts)
+ const now = new Date()
+ const isToday = d.toDateString() === now.toDateString()
+ if (isToday) {
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ }
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
+ d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ }
+
+ const getInitial = (user: ChatMessage['user']) => {
+ return (user.first_name?.[0] || user.email?.[0] || '?').toUpperCase()
+ }
+
+ const getColor = (userId: string) => {
+ const colors = ['#E06C75', '#61AFEF', '#98C379', '#E5C07B', '#C678DD', '#56B6C2', '#BE5046', '#D19A66']
+ let hash = 0
+ for (let i = 0; i < userId.length; i++) {
+ hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
+ }
+ return colors[Math.abs(hash) % colors.length]
+ }
+
+ return (
+ <div className="chat-panel">
+ <div className="chat-header">
+ <span>Chat</span>
+ </div>
+ <div className="chat-messages" ref={containerRef}>
+ {loading && <div className="chat-loading">Loading messages...</div>}
+ {!loading && messages.length === 0 && (
+ <div className="chat-empty">No messages yet</div>
+ )}
+ {messages.map((msg) => (
+ <div key={msg.id} className="chat-message">
+ <div
+ className="chat-avatar"
+ style={{ backgroundColor: getColor(msg.user.id) }}
+ >
+ {getInitial(msg.user)}
+ </div>
+ <div className="chat-message-body">
+ <div className="chat-message-header">
+ <span className="chat-user-name">
+ {msg.user.first_name}{msg.user.last_name ? ' ' + msg.user.last_name : ''}
+ </span>
+ <span className="chat-time">{formatTime(msg.timestamp)}</span>
+ </div>
+ <div className="chat-message-content">{msg.content}</div>
+ </div>
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ <div className="chat-input-area">
+ <input
+ className="chat-input"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ placeholder="Send a message..."
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }}
+ disabled={sending}
+ />
+ <button
+ className="chat-send-btn"
+ onClick={handleSend}
+ disabled={!input.trim() || sending}
+ >
+ Send
+ </button>
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index e381802..245fd35 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -16,8 +16,9 @@ import {
} from '../extensions/commentHighlights'
import { addCommentTooltip, setAddCommentCallback } from '../extensions/addCommentTooltip'
import { otSyncExtension, remoteUpdateAnnotation } from '../extensions/otSyncExtension'
+import { remoteCursorsExtension, setRemoteCursorsEffect, type RemoteCursor } from '../extensions/remoteCursors'
import { OverleafDocSync } from '../ot/overleafSync'
-import { activeDocSyncs } from '../App'
+import { activeDocSyncs, remoteCursors } from '../App'
const cosmicLatteTheme = EditorView.theme({
'&': { height: '100%', fontSize: '13.5px', backgroundColor: '#FFF8E7' },
@@ -64,6 +65,8 @@ export default function Editor() {
const content = activeTab ? fileContents[activeTab] ?? '' : ''
const docSyncRef = useRef<OverleafDocSync | null>(null)
+ const cursorThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
// Add comment state
const [newComment, setNewComment] = useState<{ from: number; to: number; text: string } | null>(null)
const [commentInput, setCommentInput] = useState('')
@@ -163,6 +166,18 @@ export default function Editor() {
if (found !== store.focusedThreadId) {
store.setFocusedThreadId(found)
}
+
+ // Send cursor position to Overleaf (throttled)
+ const docId = pathDocMap[activeTab!]
+ if (docId) {
+ if (cursorThrottleRef.current) clearTimeout(cursorThrottleRef.current)
+ cursorThrottleRef.current = setTimeout(() => {
+ const line = update.state.doc.lineAt(cursorPos)
+ const row = line.number - 1
+ const column = cursorPos - line.from
+ window.api.cursorUpdate(docId, row, column)
+ }, 300)
+ }
}
})
@@ -207,6 +222,7 @@ export default function Editor() {
commentHighlights(),
overleafProjectId ? addCommentTooltip() : [],
...otExt,
+ remoteCursorsExtension(),
]
})
@@ -254,6 +270,33 @@ export default function Editor() {
}
}, [activeTab])
+ // Sync remote cursors to CodeMirror
+ useEffect(() => {
+ if (!viewRef.current || !activeTab) return
+ const docId = pathDocMap[activeTab]
+ if (!docId) return
+
+ const refreshCursors = () => {
+ if (!viewRef.current) return
+ const cursorsForDoc: RemoteCursor[] = []
+ for (const c of remoteCursors.values()) {
+ if (c.docId === docId) {
+ cursorsForDoc.push(c)
+ }
+ }
+ viewRef.current.dispatch({ effects: setRemoteCursorsEffect.of(cursorsForDoc) })
+ }
+
+ // Refresh on event
+ window.addEventListener('remoteCursorsChanged', refreshCursors)
+ // Initial refresh
+ refreshCursors()
+
+ return () => {
+ window.removeEventListener('remoteCursorsChanged', refreshCursors)
+ }
+ }, [activeTab, pathDocMap])
+
// Sync comment ranges to CodeMirror
useEffect(() => {
if (!viewRef.current || !activeTab) return
diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx
index 79b8a10..f24e6d6 100644
--- a/src/renderer/src/components/StatusBar.tsx
+++ b/src/renderer/src/components/StatusBar.tsx
@@ -1,7 +1,7 @@
import { useAppStore } from '../stores/appStore'
export default function StatusBar() {
- const { statusMessage, activeTab, compiling, connectionState } = useAppStore()
+ const { statusMessage, activeTab, compiling, connectionState, onlineUsersCount } = useAppStore()
const lineInfo = activeTab ? activeTab.split('/').pop() : ''
@@ -24,6 +24,7 @@ export default function StatusBar() {
<span className="status-connection">
<span className={`connection-dot ${connectionDot}`} />
{connectionLabel}
+ {onlineUsersCount > 0 && ` (${onlineUsersCount + 1})`}
</span>
{lineInfo && <span className="status-file">{lineInfo}</span>}
<span className="status-encoding">UTF-8</span>
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx
index 002b374..c847a9d 100644
--- a/src/renderer/src/components/Toolbar.tsx
+++ b/src/renderer/src/components/Toolbar.tsx
@@ -8,7 +8,8 @@ interface ToolbarProps {
export default function Toolbar({ onCompile, onBack }: ToolbarProps) {
const {
compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree,
- showReviewPanel, toggleReviewPanel, connectionState, overleafProject
+ showReviewPanel, toggleReviewPanel, showChat, toggleChat,
+ connectionState, overleafProject, onlineUsersCount
} = useAppStore()
const projectName = overleafProject?.name || 'Project'
@@ -43,6 +44,14 @@ export default function Toolbar({ onCompile, onBack }: ToolbarProps) {
</button>
</div>
<div className="toolbar-right">
+ {onlineUsersCount > 0 && (
+ <span className="toolbar-users" title={`${onlineUsersCount} user${onlineUsersCount > 1 ? 's' : ''} online`}>
+ {onlineUsersCount}
+ </span>
+ )}
+ <button className={`toolbar-btn ${showChat ? 'active' : ''}`} onClick={toggleChat} title="Toggle chat">
+ Chat
+ </button>
<button className={`toolbar-btn ${showReviewPanel ? 'active' : ''}`} onClick={toggleReviewPanel} title="Toggle review panel">
Review
</button>
diff --git a/src/renderer/src/extensions/remoteCursors.ts b/src/renderer/src/extensions/remoteCursors.ts
new file mode 100644
index 0000000..522d15d
--- /dev/null
+++ b/src/renderer/src/extensions/remoteCursors.ts
@@ -0,0 +1,111 @@
+// CM6 extension for rendering remote collaborator cursors
+import { StateEffect, StateField } from '@codemirror/state'
+import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view'
+
+export interface RemoteCursor {
+ userId: string
+ name: string
+ color: string
+ row: number // 0-based
+ column: number // 0-based
+}
+
+/** Effect to update all remote cursors for the current doc */
+export const setRemoteCursorsEffect = StateEffect.define<RemoteCursor[]>()
+
+const CURSOR_COLORS = [
+ '#E06C75', '#61AFEF', '#98C379', '#E5C07B',
+ '#C678DD', '#56B6C2', '#BE5046', '#D19A66'
+]
+
+export function colorForUser(userId: string): string {
+ let hash = 0
+ for (let i = 0; i < userId.length; i++) {
+ hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
+ }
+ return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]
+}
+
+class CursorWidget extends WidgetType {
+ constructor(private name: string, private color: string, private id: string) {
+ super()
+ }
+
+ toDOM(): HTMLElement {
+ const wrapper = document.createElement('span')
+ wrapper.className = 'cm-remote-cursor'
+ wrapper.setAttribute('data-cursor-id', this.id)
+
+ const line = document.createElement('span')
+ line.className = 'cm-remote-cursor-line'
+ line.style.borderLeftColor = this.color
+ wrapper.appendChild(line)
+
+ const label = document.createElement('span')
+ label.className = 'cm-remote-cursor-label'
+ label.style.backgroundColor = this.color
+ label.textContent = this.name.split(' ')[0] // first name only
+ wrapper.appendChild(label)
+
+ // Fade label after 2s
+ setTimeout(() => label.classList.add('faded'), 2000)
+
+ return wrapper
+ }
+
+ eq(other: CursorWidget): boolean {
+ return this.name === other.name && this.color === other.color && this.id === other.id
+ }
+
+ get estimatedHeight(): number { return 0 }
+
+ ignoreEvent(): boolean { return true }
+}
+
+const remoteCursorsField = StateField.define<DecorationSet>({
+ create() {
+ return Decoration.none
+ },
+
+ update(value, tr) {
+ for (const effect of tr.effects) {
+ if (effect.is(setRemoteCursorsEffect)) {
+ const cursors = effect.value
+ const decorations: { pos: number; widget: CursorWidget }[] = []
+
+ for (const c of cursors) {
+ const lineNum = c.row + 1 // CM6 is 1-based
+ if (lineNum < 1 || lineNum > tr.state.doc.lines) continue
+ const line = tr.state.doc.line(lineNum)
+ const pos = line.from + Math.min(c.column, line.length)
+ decorations.push({
+ pos,
+ widget: new CursorWidget(c.name, c.color, c.userId)
+ })
+ }
+
+ // Sort by position
+ decorations.sort((a, b) => a.pos - b.pos)
+
+ return Decoration.set(
+ decorations.map(d =>
+ Decoration.widget({ widget: d.widget, side: 1 }).range(d.pos)
+ )
+ )
+ }
+ }
+
+ // Map through document changes
+ if (tr.docChanged) {
+ value = value.map(tr.changes)
+ }
+
+ return value
+ },
+
+ provide: f => EditorView.decorations.from(f)
+})
+
+export function remoteCursorsExtension() {
+ return [remoteCursorsField]
+}
diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts
index 9e8e0d0..867026c 100644
--- a/src/renderer/src/stores/appStore.ts
+++ b/src/renderer/src/stores/appStore.ts
@@ -92,6 +92,14 @@ interface AppState {
showReviewPanel: boolean
toggleReviewPanel: () => void
+ // Chat panel
+ showChat: boolean
+ toggleChat: () => void
+
+ // Connected users count
+ onlineUsersCount: number
+ setOnlineUsersCount: (n: number) => void
+
// Comment data
commentContexts: Record<string, CommentContext>
setCommentContexts: (c: Record<string, CommentContext>) => void
@@ -191,6 +199,12 @@ export const useAppStore = create<AppState>((set) => ({
showReviewPanel: false,
toggleReviewPanel: () => set((s) => ({ showReviewPanel: !s.showReviewPanel })),
+ showChat: false,
+ toggleChat: () => set((s) => ({ showChat: !s.showChat })),
+
+ onlineUsersCount: 0,
+ setOnlineUsersCount: (n) => set({ onlineUsersCount: n }),
+
commentContexts: {},
setCommentContexts: (c) => set({ commentContexts: c }),
overleafDocs: {},
@@ -228,6 +242,8 @@ export const useAppStore = create<AppState>((set) => ({
hoveredThreadId: null,
focusedThreadId: null,
pendingGoTo: null,
- statusMessage: 'Ready'
+ statusMessage: 'Ready',
+ showChat: false,
+ onlineUsersCount: 0
})
}))