| // src/components/CollaborativeEditor.tsx | |
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { useStompClient } from '../hooks/useStompClient'; | |
| import { useAuth } from '../context/AuthContext'; | |
| interface CollaborativeEditorProps { | |
| documentId: string; | |
| } | |
| interface TextOperation { | |
| type: 'insert' | 'delete'; | |
| position: number; | |
| characters?: string; | |
| length?: number; | |
| userId?: string; | |
| version?: number; | |
| } | |
| export const CollaborativeEditor: React.FC<CollaborativeEditorProps> = ({ documentId }) => { | |
| const { user, accessToken } = useAuth(); | |
| const { isConnected, sendMessage, subscribeToTopic } = useStompClient(); | |
| const [content, setContent] = useState<string>(''); | |
| const [title, setTitle] = useState<string>(''); | |
| const [editors, setEditors] = useState<string[]>([]); | |
| const [version, setVersion] = useState<number>(0); | |
| const [pendingOps, setPendingOps] = useState<TextOperation[]>([]); | |
| const [typingUsers, setTypingUsers] = useState<Map<string, number>>(new Map()); | |
| const editorRef = useRef<HTMLTextAreaElement | null>(null); | |
| const selectionRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 }); | |
| const applyOperation = (text: string, op: TextOperation): string => { | |
| if (op.type === 'insert' && op.characters) { | |
| return text.slice(0, op.position) + op.characters + text.slice(op.position); | |
| } else if (op.type === 'delete' && op.length) { | |
| return text.slice(0, op.position) + text.slice(op.position + op.length); | |
| } | |
| return text; | |
| }; | |
| const adjustCursorPosition = (cursorPos: number, op: TextOperation): number => { | |
| if (op.type === 'insert' && op.characters) { | |
| if (cursorPos >= op.position) { | |
| return cursorPos + op.characters.length; | |
| } | |
| } else if (op.type === 'delete' && op.length) { | |
| if (cursorPos >= op.position + op.length) { | |
| return cursorPos - op.length; | |
| } else if (cursorPos > op.position) { | |
| return op.position; | |
| } | |
| } | |
| return cursorPos; | |
| }; | |
| const setCursorPosition = (position: number): void => { | |
| const editor = editorRef.current; | |
| if (editor) { | |
| editor.focus(); | |
| editor.setSelectionRange(position, position); | |
| selectionRef.current = { start: position, end: position }; | |
| } | |
| }; | |
| const applyRemoteOperation = (operation: TextOperation): void => { | |
| setContent(prev => applyOperation(prev, operation)); | |
| setVersion(prev => prev + 1); | |
| if (editorRef.current) { | |
| const newCursorPos = adjustCursorPosition(selectionRef.current.start, operation); | |
| if (newCursorPos !== selectionRef.current.start) { | |
| setCursorPosition(newCursorPos); | |
| } | |
| } | |
| }; | |
| useEffect(() => { | |
| if (!isConnected || !documentId) return; | |
| fetch('/api/documents/' + documentId, { | |
| headers: { Authorization: 'Bearer ' + accessToken }, | |
| }) | |
| .then(res => res.json()) | |
| .then(data => { | |
| setContent(data.content || ''); | |
| setTitle(data.title || 'Untitled'); | |
| setVersion(data.version || 0); | |
| setEditors(data.editors || []); | |
| }) | |
| .catch(err => console.error(err)); | |
| sendMessage('/app/document.' + documentId + '.join', {}); | |
| const opSubscription = subscribeToTopic('/topic/document/' + documentId + '/operations', (message: any) => { | |
| const data = JSON.parse(message.body); | |
| if (data.userId !== user?.username) { | |
| applyRemoteOperation(data.operation); | |
| } | |
| }); | |
| const editorSubscription = subscribeToTopic('/topic/document/' + documentId + '/editors', (message: any) => { | |
| const data = JSON.parse(message.body); | |
| setEditors(data.editors); | |
| }); | |
| const titleSubscription = subscribeToTopic('/topic/document/' + documentId + '/title', (message: any) => { | |
| const data = JSON.parse(message.body); | |
| setTitle(data.title); | |
| }); | |
| const typingSubscription = subscribeToTopic('/topic/document/' + documentId + '/typing', (message: any) => { | |
| const data = JSON.parse(message.body); | |
| setTypingUsers(prev => { | |
| const newMap = new Map(prev); | |
| if (data.isTyping) { | |
| newMap.set(data.userId, Date.now()); | |
| } else { | |
| newMap.delete(data.userId); | |
| } | |
| return newMap; | |
| }); | |
| }); | |
| return () => { | |
| sendMessage('/app/document.' + documentId + '.leave', {}); | |
| opSubscription?.unsubscribe(); | |
| editorSubscription?.unsubscribe(); | |
| titleSubscription?.unsubscribe(); | |
| typingSubscription?.unsubscribe(); | |
| }; | |
| }, [isConnected, documentId, user?.username, accessToken]); | |
| const handleBeforeInput = (e: React.FormEvent<HTMLTextAreaElement> & { data?: string }) => { | |
| e.preventDefault(); | |
| const target = e.currentTarget; | |
| const start = target.selectionStart; | |
| const data = e.data; | |
| if (data) { | |
| const operation: TextOperation = { | |
| type: 'insert', | |
| position: start, | |
| characters: data, | |
| userId: user?.username, | |
| version: version + 1, | |
| }; | |
| setContent(prev => applyOperation(prev, operation)); | |
| setVersion(prev => prev + 1); | |
| setPendingOps(prev => [...prev, operation]); | |
| sendMessage('/app/document.' + documentId + '.operation', operation); | |
| } | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (e.key === 'Delete' || e.key === 'Backspace') { | |
| const target = e.currentTarget; | |
| const start = target.selectionStart; | |
| const end = target.selectionEnd; | |
| if (start !== end) { | |
| e.preventDefault(); | |
| const operation: TextOperation = { | |
| type: 'delete', | |
| position: start, | |
| length: end - start, | |
| userId: user?.username, | |
| version: version + 1, | |
| }; | |
| setContent(prev => applyOperation(prev, operation)); | |
| setVersion(prev => prev + 1); | |
| setPendingOps(prev => [...prev, operation]); | |
| sendMessage('/app/document.' + documentId + '.operation', operation); | |
| setCursorPosition(start); | |
| } else if (e.key === 'Backspace' && start > 0) { | |
| e.preventDefault(); | |
| const operation: TextOperation = { | |
| type: 'delete', | |
| position: start - 1, | |
| length: 1, | |
| userId: user?.username, | |
| version: version + 1, | |
| }; | |
| setContent(prev => applyOperation(prev, operation)); | |
| setVersion(prev => prev + 1); | |
| setPendingOps(prev => [...prev, operation]); | |
| sendMessage('/app/document.' + documentId + '.operation', operation); | |
| setCursorPosition(start - 1); | |
| } | |
| } | |
| }; | |
| const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => { | |
| const target = e.currentTarget; | |
| selectionRef.current = { | |
| start: target.selectionStart, | |
| end: target.selectionEnd, | |
| }; | |
| }; | |
| const handleTitleChange = (newTitle: string) => { | |
| setTitle(newTitle); | |
| sendMessage('/app/document.' + documentId + '.title', { title: newTitle }); | |
| }; | |
| const handleTyping = () => { | |
| sendMessage('/app/document.' + documentId + '.typing', { isTyping: true }); | |
| setTimeout(() => { | |
| sendMessage('/app/document.' + documentId + '.typing', { isTyping: false }); | |
| }, 2000); | |
| }; | |
| const handleSave = async () => { | |
| try { | |
| const response = await fetch('/api/documents/' + documentId, { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: 'Bearer ' + accessToken, | |
| }, | |
| body: JSON.stringify({ content, title, version }), | |
| }); | |
| if (response.ok) { | |
| setPendingOps([]); | |
| } | |
| } catch (error) { | |
| console.error('Failed to save document:', error); | |
| } | |
| }; | |
| useEffect(() => { | |
| const interval = setInterval(() => { | |
| const now = Date.now(); | |
| setTypingUsers(prev => { | |
| const newMap = new Map(prev); | |
| for (const [userId, timestamp] of newMap.entries()) { | |
| if (now - timestamp > 3000) { | |
| newMap.delete(userId); | |
| } | |
| } | |
| return newMap; | |
| }); | |
| }, 1000); | |
| return () => clearInterval(interval); | |
| }, []); | |
| return ( | |
| <div className="collaborative-editor"> | |
| <div className="editor-header"> | |
| <input | |
| type="text" | |
| value={title} | |
| onChange={(e) => handleTitleChange(e.target.value)} | |
| className="editor-title" | |
| /> | |
| <div className="editor-toolbar"> | |
| <button onClick={handleSave}>Save</button> | |
| </div> | |
| <div className="editor-status"> | |
| <span>Connected: {isConnected ? '✓' : '✗'}</span> | |
| <span>Version: {version}</span> | |
| <div className="active-editors"> | |
| {editors.map(ed => ( | |
| <span key={ed} className="editor-badge">{ed}</span> | |
| ))} | |
| </div> | |
| <div className="typing-indicators"> | |
| {Array.from(typingUsers.keys()).map(uId => ( | |
| <span key={uId} className="typing-badge">{uId} is typing...</span> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <textarea | |
| ref={editorRef} | |
| value={content} | |
| onChange={(e) => setContent(e.target.value)} | |
| onBeforeInput={handleBeforeInput} | |
| onKeyDown={handleKeyDown} | |
| onSelect={handleSelect} | |
| onKeyUp={handleTyping} | |
| className="editor-content" | |
| placeholder="Start writing..." | |
| /> | |
| <div className="editor-footer"> | |
| <span>Characters: {content.length}</span> | |
| <span>Words: {content.split(/\s+/).filter(w => w.length > 0).length}</span> | |
| {pendingOps.length > 0 && ( | |
| <span className="sync-status">Syncing... ({pendingOps.length} pending)</span> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default CollaborativeEditor; | |
Xet Storage Details
- Size:
- 9.73 kB
- Xet hash:
- c7ebf9dc5e134cb09784f862c532865a39602d5943125af083dbd7c4e67b27c0
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.