From a0dd3d7ac642111faeaefd02c5a452898b9c6d49 Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Thu, 12 Mar 2026 18:11:10 -0500 Subject: 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 --- src/main/index.ts | 45 +++++++ src/main/overleafSocket.ts | 18 +++ src/preload/index.ts | 27 ++++ src/renderer/src/App.css | 195 +++++++++++++++++++++++++++ src/renderer/src/App.tsx | 66 ++++++++- src/renderer/src/components/ChatPanel.tsx | 145 ++++++++++++++++++++ src/renderer/src/components/Editor.tsx | 45 ++++++- src/renderer/src/components/StatusBar.tsx | 3 +- src/renderer/src/components/Toolbar.tsx | 11 +- src/renderer/src/extensions/remoteCursors.ts | 111 +++++++++++++++ src/renderer/src/stores/appStore.ts | 18 ++- 11 files changed, 677 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/ChatPanel.tsx create mode 100644 src/renderer/src/extensions/remoteCursors.ts (limited to 'src') 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 { + 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, + 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() +// Global remote cursor state — shared between App and Editor +export const remoteCursors = new Map() + 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() { - {showReviewPanel && ( + {(showReviewPanel || showChat) && (
- + {showReviewPanel && } + {showChat && }
)} 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([]) + const [input, setInput] = useState('') + const [sending, setSending] = useState(false) + const [loading, setLoading] = useState(true) + const messagesEndRef = useRef(null) + const containerRef = useRef(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 ( +
+
+ Chat +
+
+ {loading &&
Loading messages...
} + {!loading && messages.length === 0 && ( +
No messages yet
+ )} + {messages.map((msg) => ( +
+
+ {getInitial(msg.user)} +
+
+
+ + {msg.user.first_name}{msg.user.last_name ? ' ' + msg.user.last_name : ''} + + {formatTime(msg.timestamp)} +
+
{msg.content}
+
+
+ ))} +
+
+
+ setInput(e.target.value)} + placeholder="Send a message..." + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + }} + disabled={sending} + /> + +
+
+ ) +} 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(null) + const cursorThrottleRef = useRef | 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() { {connectionLabel} + {onlineUsersCount > 0 && ` (${onlineUsersCount + 1})`} {lineInfo && {lineInfo}} UTF-8 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) {
+ {onlineUsersCount > 0 && ( + 1 ? 's' : ''} online`}> + {onlineUsersCount} + + )} + 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() + +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({ + 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 setCommentContexts: (c: Record) => void @@ -191,6 +199,12 @@ export const useAppStore = create((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((set) => ({ hoveredThreadId: null, focusedThreadId: null, pendingGoTo: null, - statusMessage: 'Ready' + statusMessage: 'Ready', + showChat: false, + onlineUsersCount: 0 }) })) -- cgit v1.2.3