NOT-OMEGA commited on
Commit
c77fbef
·
verified ·
1 Parent(s): 511c6eb

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +29 -0
  2. document_store.py +113 -0
  3. index.html +1118 -0
  4. main.py +282 -0
  5. ot_engine.py +181 -0
  6. presence.py +133 -0
  7. requirements.txt +4 -0
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Stage 1: Builder ─────────────────────────────────────────
2
+ FROM python:3.11-slim AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
8
+
9
+ # ── Stage 2: Runtime ─────────────────────────────────────────
10
+ FROM python:3.11-slim
11
+
12
+ # HF Spaces runs as user 1000
13
+ RUN useradd -m -u 1000 appuser
14
+
15
+ WORKDIR /app
16
+
17
+ # Copy dependencies
18
+ COPY --from=builder /install /usr/local
19
+
20
+ # Copy source
21
+ COPY --chown=appuser:appuser . .
22
+
23
+ USER appuser
24
+
25
+ # HF Spaces exposes port 7860
26
+ EXPOSE 7860
27
+
28
+ # Start with uvicorn, binding to 0.0.0.0:7860
29
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--ws", "websockets"]
document_store.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Document Store
3
+ Manages in-memory document state with snapshot support.
4
+ In production this would be backed by Redis + Postgres.
5
+ """
6
+
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Dict, List, Optional, Tuple
10
+ from ot_engine import Operation, apply_operation, ServerState, transform_against_log
11
+
12
+ SNAPSHOT_EVERY_N_OPS = 50 # Take snapshot every 50 ops
13
+
14
+
15
+ @dataclass
16
+ class Snapshot:
17
+ version: int
18
+ content: str
19
+ timestamp: float = field(default_factory=time.time)
20
+
21
+
22
+ @dataclass
23
+ class Document:
24
+ doc_id: str
25
+ title: str = "Untitled Document"
26
+ content: str = ""
27
+ version: int = 0
28
+ op_log: List[Tuple[int, Operation]] = field(default_factory=list)
29
+ snapshots: List[Snapshot] = field(default_factory=list)
30
+ created_at: float = field(default_factory=time.time)
31
+ updated_at: float = field(default_factory=time.time)
32
+
33
+ def get_latest_snapshot(self) -> Optional[Snapshot]:
34
+ if self.snapshots:
35
+ return self.snapshots[-1]
36
+ return None
37
+
38
+ def maybe_take_snapshot(self):
39
+ if self.version > 0 and self.version % SNAPSHOT_EVERY_N_OPS == 0:
40
+ snap = Snapshot(version=self.version, content=self.content)
41
+ self.snapshots.append(snap)
42
+
43
+ def apply_op(self, op: Operation) -> Operation:
44
+ """
45
+ Apply an incoming operation with OT.
46
+ Returns the transformed operation that was actually applied.
47
+ """
48
+ # If client is behind, transform against missed ops
49
+ if op.base_version < self.version:
50
+ op = transform_against_log(
51
+ op,
52
+ self.op_log,
53
+ from_version=op.base_version,
54
+ to_version=self.version,
55
+ )
56
+
57
+ # Apply to content
58
+ self.content = apply_operation(self.content, op)
59
+ self.version += 1
60
+ op.base_version = self.version # stamp with server version
61
+
62
+ # Store in log
63
+ self.op_log.append((self.version, op))
64
+
65
+ # Keep op_log bounded (last 1000 ops)
66
+ if len(self.op_log) > 1000:
67
+ self.op_log = self.op_log[-1000:]
68
+
69
+ self.updated_at = time.time()
70
+ self.maybe_take_snapshot()
71
+
72
+ return op
73
+
74
+ def get_state(self) -> dict:
75
+ return {
76
+ "doc_id": self.doc_id,
77
+ "title": self.title,
78
+ "content": self.content,
79
+ "version": self.version,
80
+ }
81
+
82
+
83
+ class DocumentStore:
84
+ def __init__(self):
85
+ self._docs: Dict[str, Document] = {}
86
+
87
+ def get_or_create(self, doc_id: str) -> Document:
88
+ if doc_id not in self._docs:
89
+ self._docs[doc_id] = Document(doc_id=doc_id)
90
+ return self._docs[doc_id]
91
+
92
+ def get(self, doc_id: str) -> Optional[Document]:
93
+ return self._docs.get(doc_id)
94
+
95
+ def update_title(self, doc_id: str, title: str):
96
+ doc = self.get(doc_id)
97
+ if doc:
98
+ doc.title = title
99
+
100
+ def list_docs(self) -> List[dict]:
101
+ return [
102
+ {
103
+ "doc_id": d.doc_id,
104
+ "title": d.title,
105
+ "version": d.version,
106
+ "updated_at": d.updated_at,
107
+ }
108
+ for d in self._docs.values()
109
+ ]
110
+
111
+
112
+ # Global singleton
113
+ store = DocumentStore()
index.html ADDED
@@ -0,0 +1,1118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CollabDocs</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --bg: #F7F5F0;
12
+ --surface: #FFFFFF;
13
+ --surface2: #F0EDE8;
14
+ --border: #E2DDD6;
15
+ --border-strong: #C8C0B4;
16
+ --text: #1A1816;
17
+ --text-muted: #7A7168;
18
+ --text-light: #A89F94;
19
+ --accent: #2D6A4F;
20
+ --accent-light: #52B788;
21
+ --accent-dim: #D8F3DC;
22
+ --danger: #C0392B;
23
+ --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.04);
24
+ --shadow-lg: 0 4px 24px rgba(0,0,0,0.12);
25
+ --radius: 8px;
26
+ --font-serif: 'DM Serif Display', Georgia, serif;
27
+ --font-sans: 'DM Sans', system-ui, sans-serif;
28
+ }
29
+
30
+ * { box-sizing: border-box; margin: 0; padding: 0; }
31
+
32
+ body {
33
+ font-family: var(--font-sans);
34
+ background: var(--bg);
35
+ color: var(--text);
36
+ min-height: 100vh;
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+
41
+ /* ── Toolbar ── */
42
+ #toolbar {
43
+ background: var(--surface);
44
+ border-bottom: 1px solid var(--border);
45
+ padding: 0 20px;
46
+ height: 56px;
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 16px;
50
+ position: sticky;
51
+ top: 0;
52
+ z-index: 100;
53
+ box-shadow: var(--shadow);
54
+ }
55
+
56
+ .logo {
57
+ font-family: var(--font-serif);
58
+ font-size: 20px;
59
+ color: var(--accent);
60
+ white-space: nowrap;
61
+ letter-spacing: -0.3px;
62
+ }
63
+
64
+ .logo span { color: var(--text-muted); font-size: 14px; margin-left: 4px; }
65
+
66
+ #doc-title {
67
+ flex: 1;
68
+ min-width: 0;
69
+ background: none;
70
+ border: none;
71
+ outline: none;
72
+ font-family: var(--font-sans);
73
+ font-size: 16px;
74
+ font-weight: 500;
75
+ color: var(--text);
76
+ padding: 6px 10px;
77
+ border-radius: var(--radius);
78
+ transition: background 0.2s;
79
+ white-space: nowrap;
80
+ overflow: hidden;
81
+ text-overflow: ellipsis;
82
+ }
83
+
84
+ #doc-title:hover, #doc-title:focus {
85
+ background: var(--surface2);
86
+ }
87
+
88
+ .toolbar-right {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 12px;
92
+ flex-shrink: 0;
93
+ }
94
+
95
+ /* Status badge */
96
+ #status-badge {
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 6px;
100
+ font-size: 12px;
101
+ color: var(--text-muted);
102
+ padding: 4px 10px;
103
+ border-radius: 20px;
104
+ background: var(--surface2);
105
+ border: 1px solid var(--border);
106
+ transition: all 0.3s;
107
+ white-space: nowrap;
108
+ }
109
+
110
+ #status-badge.connected { color: var(--accent); background: var(--accent-dim); border-color: #95D5B2; }
111
+ #status-badge.disconnected { color: var(--danger); background: #FADBD8; border-color: #F1948A; }
112
+
113
+ #status-dot {
114
+ width: 7px; height: 7px;
115
+ border-radius: 50%;
116
+ background: currentColor;
117
+ animation: pulse 2s infinite;
118
+ }
119
+
120
+ @keyframes pulse {
121
+ 0%, 100% { opacity: 1; }
122
+ 50% { opacity: 0.4; }
123
+ }
124
+
125
+ /* Share button */
126
+ #share-btn {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 6px;
130
+ padding: 7px 14px;
131
+ background: var(--accent);
132
+ color: white;
133
+ border: none;
134
+ border-radius: var(--radius);
135
+ font-family: var(--font-sans);
136
+ font-size: 13px;
137
+ font-weight: 500;
138
+ cursor: pointer;
139
+ transition: all 0.2s;
140
+ white-space: nowrap;
141
+ }
142
+
143
+ #share-btn:hover { background: #245A42; transform: translateY(-1px); box-shadow: var(--shadow); }
144
+ #share-btn:active { transform: translateY(0); }
145
+
146
+ /* User avatars */
147
+ #user-avatars {
148
+ display: flex;
149
+ align-items: center;
150
+ }
151
+
152
+ .avatar {
153
+ width: 30px; height: 30px;
154
+ border-radius: 50%;
155
+ display: flex; align-items: center; justify-content: center;
156
+ font-size: 12px;
157
+ font-weight: 600;
158
+ color: white;
159
+ border: 2px solid white;
160
+ margin-left: -8px;
161
+ cursor: default;
162
+ position: relative;
163
+ transition: transform 0.2s;
164
+ box-shadow: 0 1px 4px rgba(0,0,0,0.2);
165
+ }
166
+
167
+ .avatar:first-child { margin-left: 0; }
168
+ .avatar:hover { transform: translateY(-2px); z-index: 10; }
169
+
170
+ .avatar-tooltip {
171
+ position: absolute;
172
+ bottom: calc(100% + 6px);
173
+ left: 50%;
174
+ transform: translateX(-50%);
175
+ background: var(--text);
176
+ color: white;
177
+ font-size: 11px;
178
+ padding: 3px 8px;
179
+ border-radius: 4px;
180
+ white-space: nowrap;
181
+ opacity: 0;
182
+ pointer-events: none;
183
+ transition: opacity 0.2s;
184
+ }
185
+
186
+ .avatar:hover .avatar-tooltip { opacity: 1; }
187
+
188
+ /* ── Main layout ── */
189
+ #main {
190
+ flex: 1;
191
+ display: flex;
192
+ justify-content: center;
193
+ padding: 32px 20px 60px;
194
+ }
195
+
196
+ #editor-wrap {
197
+ width: 100%;
198
+ max-width: 860px;
199
+ display: flex;
200
+ flex-direction: column;
201
+ gap: 0;
202
+ }
203
+
204
+ /* Page shadow effect */
205
+ #page {
206
+ background: var(--surface);
207
+ border-radius: 4px;
208
+ box-shadow: var(--shadow-lg), 0 0 0 1px var(--border);
209
+ position: relative;
210
+ min-height: 80vh;
211
+ }
212
+
213
+ /* Remote cursor layer */
214
+ #cursor-layer {
215
+ position: absolute;
216
+ top: 0; left: 0; right: 0; bottom: 0;
217
+ pointer-events: none;
218
+ overflow: hidden;
219
+ z-index: 5;
220
+ border-radius: 4px;
221
+ }
222
+
223
+ .remote-cursor {
224
+ position: absolute;
225
+ width: 2px;
226
+ pointer-events: none;
227
+ transition: left 0.1s, top 0.1s;
228
+ }
229
+
230
+ .remote-cursor-line {
231
+ width: 2px;
232
+ height: 20px;
233
+ border-radius: 1px;
234
+ animation: cursor-blink 1s infinite;
235
+ }
236
+
237
+ @keyframes cursor-blink {
238
+ 0%, 100% { opacity: 1; }
239
+ 50% { opacity: 0.2; }
240
+ }
241
+
242
+ .remote-cursor-label {
243
+ position: absolute;
244
+ top: -20px;
245
+ left: 0;
246
+ font-size: 10px;
247
+ font-weight: 600;
248
+ color: white;
249
+ padding: 1px 5px;
250
+ border-radius: 3px 3px 3px 0;
251
+ white-space: nowrap;
252
+ font-family: var(--font-sans);
253
+ box-shadow: 0 1px 4px rgba(0,0,0,0.2);
254
+ }
255
+
256
+ /* The actual editor */
257
+ #editor {
258
+ width: 100%;
259
+ min-height: 80vh;
260
+ padding: 60px 80px;
261
+ font-family: 'Georgia', serif;
262
+ font-size: 16px;
263
+ line-height: 1.8;
264
+ color: var(--text);
265
+ border: none;
266
+ outline: none;
267
+ resize: none;
268
+ background: transparent;
269
+ position: relative;
270
+ z-index: 10;
271
+ white-space: pre-wrap;
272
+ word-wrap: break-word;
273
+ caret-color: var(--accent);
274
+ }
275
+
276
+ #editor::selection {
277
+ background: rgba(45, 106, 79, 0.2);
278
+ }
279
+
280
+ /* ── Notifications ── */
281
+ #notifs {
282
+ position: fixed;
283
+ bottom: 20px;
284
+ right: 20px;
285
+ display: flex;
286
+ flex-direction: column;
287
+ gap: 8px;
288
+ z-index: 1000;
289
+ pointer-events: none;
290
+ }
291
+
292
+ .notif {
293
+ background: var(--surface);
294
+ border: 1px solid var(--border);
295
+ border-radius: var(--radius);
296
+ padding: 10px 14px;
297
+ font-size: 13px;
298
+ box-shadow: var(--shadow-lg);
299
+ display: flex;
300
+ align-items: center;
301
+ gap: 8px;
302
+ animation: notif-in 0.3s ease;
303
+ max-width: 280px;
304
+ }
305
+
306
+ @keyframes notif-in {
307
+ from { transform: translateX(20px); opacity: 0; }
308
+ to { transform: translateX(0); opacity: 1; }
309
+ }
310
+
311
+ .notif-dot {
312
+ width: 8px; height: 8px;
313
+ border-radius: 50%;
314
+ flex-shrink: 0;
315
+ }
316
+
317
+ /* ── Share modal ── */
318
+ #share-modal {
319
+ display: none;
320
+ position: fixed;
321
+ inset: 0;
322
+ background: rgba(0,0,0,0.4);
323
+ z-index: 500;
324
+ align-items: center;
325
+ justify-content: center;
326
+ backdrop-filter: blur(2px);
327
+ }
328
+
329
+ #share-modal.open { display: flex; }
330
+
331
+ .modal-box {
332
+ background: var(--surface);
333
+ border-radius: 12px;
334
+ padding: 28px;
335
+ width: 460px;
336
+ max-width: 90vw;
337
+ box-shadow: var(--shadow-lg);
338
+ animation: modal-in 0.25s ease;
339
+ }
340
+
341
+ @keyframes modal-in {
342
+ from { transform: scale(0.95); opacity: 0; }
343
+ to { transform: scale(1); opacity: 1; }
344
+ }
345
+
346
+ .modal-title {
347
+ font-family: var(--font-serif);
348
+ font-size: 22px;
349
+ margin-bottom: 8px;
350
+ }
351
+
352
+ .modal-desc {
353
+ font-size: 14px;
354
+ color: var(--text-muted);
355
+ margin-bottom: 20px;
356
+ line-height: 1.6;
357
+ }
358
+
359
+ .link-row {
360
+ display: flex;
361
+ gap: 8px;
362
+ margin-bottom: 16px;
363
+ }
364
+
365
+ .link-input {
366
+ flex: 1;
367
+ padding: 10px 12px;
368
+ border: 1px solid var(--border-strong);
369
+ border-radius: var(--radius);
370
+ font-size: 13px;
371
+ font-family: monospace;
372
+ background: var(--surface2);
373
+ color: var(--text);
374
+ outline: none;
375
+ }
376
+
377
+ .copy-btn {
378
+ padding: 10px 16px;
379
+ background: var(--accent);
380
+ color: white;
381
+ border: none;
382
+ border-radius: var(--radius);
383
+ font-size: 13px;
384
+ font-weight: 500;
385
+ cursor: pointer;
386
+ transition: all 0.2s;
387
+ white-space: nowrap;
388
+ }
389
+
390
+ .copy-btn:hover { background: #245A42; }
391
+ .copy-btn.copied { background: #52B788; }
392
+
393
+ .modal-close {
394
+ width: 100%;
395
+ padding: 10px;
396
+ background: var(--surface2);
397
+ border: 1px solid var(--border);
398
+ border-radius: var(--radius);
399
+ font-size: 14px;
400
+ cursor: pointer;
401
+ transition: background 0.2s;
402
+ font-family: var(--font-sans);
403
+ color: var(--text-muted);
404
+ }
405
+
406
+ .modal-close:hover { background: var(--border); }
407
+
408
+ /* ── Collab activity bar ── */
409
+ #activity-bar {
410
+ position: fixed;
411
+ left: 20px;
412
+ bottom: 20px;
413
+ background: var(--surface);
414
+ border: 1px solid var(--border);
415
+ border-radius: var(--radius);
416
+ padding: 8px 12px;
417
+ font-size: 12px;
418
+ color: var(--text-muted);
419
+ box-shadow: var(--shadow);
420
+ display: flex;
421
+ align-items: center;
422
+ gap: 6px;
423
+ max-width: 200px;
424
+ transition: opacity 0.3s;
425
+ }
426
+
427
+ #activity-bar.hidden { opacity: 0; pointer-events: none; }
428
+
429
+ #typing-indicator {
430
+ display: flex;
431
+ align-items: center;
432
+ gap: 3px;
433
+ }
434
+
435
+ .typing-dot {
436
+ width: 4px; height: 4px;
437
+ border-radius: 50%;
438
+ background: var(--accent-light);
439
+ animation: typing 1.2s infinite;
440
+ }
441
+
442
+ .typing-dot:nth-child(2) { animation-delay: 0.2s; }
443
+ .typing-dot:nth-child(3) { animation-delay: 0.4s; }
444
+
445
+ @keyframes typing {
446
+ 0%, 100% { transform: translateY(0); opacity: 0.6; }
447
+ 50% { transform: translateY(-4px); opacity: 1; }
448
+ }
449
+
450
+ /* ── Responsive ── */
451
+ @media (max-width: 640px) {
452
+ #editor { padding: 30px 20px; }
453
+ .logo span { display: none; }
454
+ #doc-title { font-size: 14px; }
455
+ }
456
+
457
+ /* ── New doc button ── */
458
+ #new-doc-btn {
459
+ display: flex;
460
+ align-items: center;
461
+ gap: 5px;
462
+ padding: 6px 12px;
463
+ background: var(--surface2);
464
+ border: 1px solid var(--border);
465
+ border-radius: var(--radius);
466
+ font-size: 13px;
467
+ cursor: pointer;
468
+ color: var(--text-muted);
469
+ transition: all 0.2s;
470
+ font-family: var(--font-sans);
471
+ white-space: nowrap;
472
+ }
473
+
474
+ #new-doc-btn:hover { background: var(--border); color: var(--text); }
475
+
476
+ /* Version display */
477
+ #version-display {
478
+ font-size: 11px;
479
+ color: var(--text-light);
480
+ padding: 4px 8px;
481
+ border-radius: 4px;
482
+ background: var(--surface2);
483
+ white-space: nowrap;
484
+ }
485
+ </style>
486
+ </head>
487
+ <body>
488
+
489
+ <!-- Toolbar -->
490
+ <div id="toolbar">
491
+ <div class="logo">CollabDocs <span>beta</span></div>
492
+
493
+ <input id="doc-title" type="text" value="Untitled Document" spellcheck="false" maxlength="200">
494
+
495
+ <div class="toolbar-right">
496
+ <div id="version-display">v0</div>
497
+
498
+ <div id="user-avatars"></div>
499
+
500
+ <div id="status-badge" class="disconnected">
501
+ <div id="status-dot"></div>
502
+ <span id="status-text">Connecting…</span>
503
+ </div>
504
+
505
+ <button id="new-doc-btn">+ New</button>
506
+ <button id="share-btn">⬆ Share</button>
507
+ </div>
508
+ </div>
509
+
510
+ <!-- Main -->
511
+ <div id="main">
512
+ <div id="editor-wrap">
513
+ <div id="page">
514
+ <div id="cursor-layer"></div>
515
+ <textarea id="editor" spellcheck="true" autocomplete="off" autocorrect="on"
516
+ placeholder="Start typing to collaborate…"></textarea>
517
+ </div>
518
+ </div>
519
+ </div>
520
+
521
+ <!-- Notifications -->
522
+ <div id="notifs"></div>
523
+
524
+ <!-- Activity bar -->
525
+ <div id="activity-bar" class="hidden">
526
+ <div id="typing-indicator">
527
+ <div class="typing-dot"></div>
528
+ <div class="typing-dot"></div>
529
+ <div class="typing-dot"></div>
530
+ </div>
531
+ <span id="activity-text"></span>
532
+ </div>
533
+
534
+ <!-- Share modal -->
535
+ <div id="share-modal">
536
+ <div class="modal-box">
537
+ <div class="modal-title">Share Document</div>
538
+ <div class="modal-desc">Anyone with this link can view and edit this document in real time.</div>
539
+ <div class="link-row">
540
+ <input class="link-input" id="share-link-input" readonly>
541
+ <button class="copy-btn" id="copy-link-btn">Copy</button>
542
+ </div>
543
+ <button class="modal-close" id="modal-close-btn">Done</button>
544
+ </div>
545
+ </div>
546
+
547
+ <script>
548
+ // ═══════════════════════════════════════════════════════════
549
+ // CollabDocs Client
550
+ // Implements client-side OT, cursor sync, WebSocket protocol
551
+ // ═══════════════════════════════════════════════════════════
552
+
553
+ (function() {
554
+ 'use strict';
555
+
556
+ // ── State ──────────────────────────────────────────────
557
+ let ws = null;
558
+ let myUserId = null;
559
+ let myName = null;
560
+ let myColor = null;
561
+ let docId = null;
562
+ let serverVersion = 0;
563
+ let pendingOps = []; // ops sent but not acked
564
+ let localVersion = 0; // what we think server version is
565
+ let isApplyingRemote = false;
566
+
567
+ // Remote cursors: userId -> { pos, name, color, elem }
568
+ const remoteCursors = {};
569
+
570
+ // Activity tracking
571
+ let activityTimeout = null;
572
+ let typingUsers = {};
573
+
574
+ // ── DOM ────────────────────────────────────────────────
575
+ const editor = document.getElementById('editor');
576
+ const toolbar = document.getElementById('toolbar');
577
+ const statusBadge = document.getElementById('status-badge');
578
+ const statusText = document.getElementById('status-text');
579
+ const statusDot = document.getElementById('status-dot');
580
+ const userAvatars = document.getElementById('user-avatars');
581
+ const shareBtn = document.getElementById('share-btn');
582
+ const shareModal = document.getElementById('share-modal');
583
+ const shareLinkInput = document.getElementById('share-link-input');
584
+ const copyLinkBtn = document.getElementById('copy-link-btn');
585
+ const modalCloseBtn = document.getElementById('modal-close-btn');
586
+ const newDocBtn = document.getElementById('new-doc-btn');
587
+ const docTitle = document.getElementById('doc-title');
588
+ const versionDisplay = document.getElementById('version-display');
589
+ const notifs = document.getElementById('notifs');
590
+ const activityBar = document.getElementById('activity-bar');
591
+ const activityText = document.getElementById('activity-text');
592
+
593
+ // ── URL / Doc routing ──────────────────────────────────
594
+ function getDocId() {
595
+ const params = new URLSearchParams(window.location.search);
596
+ return params.get('doc') || 'welcome';
597
+ }
598
+
599
+ function getUserId() {
600
+ let id = sessionStorage.getItem('collabdocs_uid');
601
+ if (!id) {
602
+ id = 'u_' + Math.random().toString(36).substr(2, 9);
603
+ sessionStorage.setItem('collabdocs_uid', id);
604
+ }
605
+ return id;
606
+ }
607
+
608
+ function getShareUrl() {
609
+ const base = window.location.origin + window.location.pathname;
610
+ return `${base}?doc=${docId}`;
611
+ }
612
+
613
+ // ── OT: Client-side operation building ────────────────
614
+ // We detect what changed between old and new text
615
+ // by comparing textarea content before and after input events.
616
+
617
+ let prevContent = '';
618
+ let prevSelStart = 0;
619
+ let prevSelEnd = 0;
620
+
621
+ function buildOpsFromDiff(oldText, newText, selStart) {
622
+ // Simple LCS-based diff for single edits (textarea gives us input events)
623
+ // For real prod: use a proper diff library. This handles typical typing/paste/delete.
624
+ const ops = [];
625
+
626
+ // Find common prefix
627
+ let prefixLen = 0;
628
+ while (prefixLen < oldText.length && prefixLen < newText.length
629
+ && oldText[prefixLen] === newText[prefixLen]) {
630
+ prefixLen++;
631
+ }
632
+
633
+ // Find common suffix
634
+ let oldSuffix = oldText.length - 1;
635
+ let newSuffix = newText.length - 1;
636
+ while (oldSuffix >= prefixLen && newSuffix >= prefixLen
637
+ && oldText[oldSuffix] === newText[newSuffix]) {
638
+ oldSuffix--;
639
+ newSuffix--;
640
+ }
641
+
642
+ const deletedLen = oldSuffix - prefixLen + 1;
643
+ const insertedText = newText.slice(prefixLen, newSuffix + 1);
644
+
645
+ if (deletedLen > 0) {
646
+ ops.push({
647
+ op_type: 'delete',
648
+ position: prefixLen,
649
+ length: deletedLen,
650
+ value: '',
651
+ });
652
+ }
653
+
654
+ if (insertedText.length > 0) {
655
+ ops.push({
656
+ op_type: 'insert',
657
+ position: prefixLen,
658
+ value: insertedText,
659
+ length: insertedText.length,
660
+ });
661
+ }
662
+
663
+ return ops;
664
+ }
665
+
666
+ // ── WebSocket connection ───────────────────────────────
667
+ function connect() {
668
+ docId = getDocId();
669
+ myUserId = getUserId();
670
+
671
+ const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
672
+ const wsUrl = `${protocol}://${location.host}/ws/${docId}?user_id=${myUserId}`;
673
+
674
+ ws = new WebSocket(wsUrl);
675
+
676
+ ws.onopen = () => {
677
+ setStatus('connected', 'Connected');
678
+ scheduleHeartbeat();
679
+ };
680
+
681
+ ws.onclose = () => {
682
+ setStatus('disconnected', 'Disconnected — reconnecting…');
683
+ clearInterval(heartbeatInterval);
684
+ setTimeout(connect, 2000);
685
+ };
686
+
687
+ ws.onerror = (e) => {
688
+ console.error('WS error', e);
689
+ };
690
+
691
+ ws.onmessage = (event) => {
692
+ try {
693
+ const msg = JSON.parse(event.data);
694
+ handleMessage(msg);
695
+ } catch (e) {
696
+ console.error('Parse error', e);
697
+ }
698
+ };
699
+ }
700
+
701
+ // ── Message handling ───────────────────────────────────
702
+ function handleMessage(msg) {
703
+ switch (msg.type) {
704
+ case 'init':
705
+ handleInit(msg);
706
+ break;
707
+ case 'operation':
708
+ handleRemoteOperation(msg);
709
+ break;
710
+ case 'ack':
711
+ handleAck(msg);
712
+ break;
713
+ case 'cursor':
714
+ handleRemoteCursor(msg);
715
+ break;
716
+ case 'user_joined':
717
+ handleUserJoined(msg);
718
+ break;
719
+ case 'user_left':
720
+ handleUserLeft(msg);
721
+ break;
722
+ case 'title_change':
723
+ docTitle.value = msg.title;
724
+ document.title = `${msg.title} — CollabDocs`;
725
+ break;
726
+ }
727
+ }
728
+
729
+ function handleInit(msg) {
730
+ myUserId = msg.user_id;
731
+ myName = msg.name;
732
+ myColor = msg.color;
733
+
734
+ const state = msg.doc_state;
735
+ serverVersion = state.version;
736
+ localVersion = state.version;
737
+
738
+ // Set content without triggering our own handlers
739
+ isApplyingRemote = true;
740
+ editor.value = state.content;
741
+ prevContent = state.content;
742
+ isApplyingRemote = false;
743
+
744
+ docTitle.value = state.title || 'Untitled Document';
745
+ document.title = `${docTitle.value} — CollabDocs`;
746
+ updateVersionDisplay();
747
+
748
+ renderUsers(msg.users || []);
749
+ }
750
+
751
+ function handleRemoteOperation(msg) {
752
+ if (msg.user_id === myUserId) return; // Already applied locally
753
+
754
+ serverVersion = msg.server_version;
755
+ localVersion = msg.server_version;
756
+ updateVersionDisplay();
757
+
758
+ // Save cursor
759
+ const cursorPos = editor.selectionStart;
760
+ const cursorEnd = editor.selectionEnd;
761
+
762
+ // Apply to editor
763
+ isApplyingRemote = true;
764
+ const oldContent = editor.value;
765
+ const newContent = applyOpToString(oldContent, msg);
766
+ editor.value = newContent;
767
+ prevContent = newContent;
768
+
769
+ // Adjust cursor for remote op
770
+ const newCursor = transformCursorPos(cursorPos, msg);
771
+ const newCursorEnd = transformCursorPos(cursorEnd, msg);
772
+ editor.setSelectionRange(newCursor, newCursorEnd);
773
+ isApplyingRemote = false;
774
+
775
+ // Show typing activity
776
+ showTypingActivity(msg.user_id, remoteCursors[msg.user_id]?.name || 'Someone');
777
+ }
778
+
779
+ function handleAck(msg) {
780
+ serverVersion = msg.server_version;
781
+ localVersion = msg.server_version;
782
+ updateVersionDisplay();
783
+
784
+ // Remove from pending
785
+ pendingOps = pendingOps.filter(op => op.op_id !== msg.op_id);
786
+ }
787
+
788
+ function handleRemoteCursor(msg) {
789
+ if (msg.user_id === myUserId) return;
790
+
791
+ remoteCursors[msg.user_id] = {
792
+ pos: msg.cursor_pos,
793
+ name: msg.name,
794
+ color: msg.color,
795
+ };
796
+
797
+ updateRemoteCursorUI(msg.user_id, msg.cursor_pos, msg.name, msg.color);
798
+ }
799
+
800
+ function handleUserJoined(msg) {
801
+ if (msg.user_id !== myUserId) {
802
+ showNotif(`${msg.name} joined`, msg.color);
803
+ }
804
+ renderUsers(msg.users || []);
805
+ }
806
+
807
+ function handleUserLeft(msg) {
808
+ showNotif(`${msg.name} left`, '#999');
809
+ removeRemoteCursor(msg.user_id);
810
+ delete remoteCursors[msg.user_id];
811
+ renderUsers(msg.users || []);
812
+ }
813
+
814
+ // ── Apply remote op to string ──────────────────────────
815
+ function applyOpToString(content, op) {
816
+ if (op.op_type === 'insert') {
817
+ const pos = Math.max(0, Math.min(op.position, content.length));
818
+ return content.slice(0, pos) + op.value + content.slice(pos);
819
+ } else if (op.op_type === 'delete') {
820
+ const pos = Math.max(0, Math.min(op.position, content.length));
821
+ const end = Math.max(0, Math.min(pos + op.length, content.length));
822
+ return content.slice(0, pos) + content.slice(end);
823
+ }
824
+ return content;
825
+ }
826
+
827
+ function transformCursorPos(cursorPos, op) {
828
+ if (op.op_type === 'insert') {
829
+ const insLen = op.value.length;
830
+ if (op.position <= cursorPos) return cursorPos + insLen;
831
+ } else if (op.op_type === 'delete') {
832
+ const delEnd = op.position + op.length;
833
+ if (delEnd <= cursorPos) return cursorPos - op.length;
834
+ if (op.position <= cursorPos && cursorPos < delEnd) return op.position;
835
+ }
836
+ return cursorPos;
837
+ }
838
+
839
+ // ── Send operation ─────────────────────────────────────
840
+ function sendOp(opType, position, value, length) {
841
+ const opId = Math.random().toString(36).substr(2, 9);
842
+ const msg = {
843
+ type: 'operation',
844
+ op_id: opId,
845
+ op_type: opType,
846
+ position: position,
847
+ value: value || '',
848
+ length: length || (value ? value.length : 1),
849
+ base_version: localVersion,
850
+ };
851
+
852
+ pendingOps.push(msg);
853
+ sendIfOpen(msg);
854
+ }
855
+
856
+ // ── Editor event handlers ──────────────────────────────
857
+ editor.addEventListener('input', (e) => {
858
+ if (isApplyingRemote) return;
859
+
860
+ const newContent = editor.value;
861
+ const ops = buildOpsFromDiff(prevContent, newContent, editor.selectionStart);
862
+
863
+ for (const op of ops) {
864
+ sendOp(op.op_type, op.position, op.value, op.length);
865
+ }
866
+
867
+ prevContent = newContent;
868
+ sendCursorPosition();
869
+ });
870
+
871
+ editor.addEventListener('keyup', sendCursorPosition);
872
+ editor.addEventListener('click', sendCursorPosition);
873
+ editor.addEventListener('mouseup', sendCursorPosition);
874
+ editor.addEventListener('touchend', sendCursorPosition);
875
+ editor.addEventListener('select', sendCursorPosition);
876
+
877
+ let cursorDebounce = null;
878
+ function sendCursorPosition() {
879
+ clearTimeout(cursorDebounce);
880
+ cursorDebounce = setTimeout(() => {
881
+ sendIfOpen({
882
+ type: 'cursor',
883
+ cursor_pos: editor.selectionStart,
884
+ selection_start: editor.selectionStart,
885
+ selection_end: editor.selectionEnd,
886
+ });
887
+ }, 30);
888
+ }
889
+
890
+ // ── Title sync ─────────────────────────────────────────
891
+ let titleDebounce = null;
892
+ docTitle.addEventListener('input', () => {
893
+ clearTimeout(titleDebounce);
894
+ titleDebounce = setTimeout(() => {
895
+ document.title = `${docTitle.value} — CollabDocs`;
896
+ sendIfOpen({ type: 'title_change', title: docTitle.value });
897
+ }, 300);
898
+ });
899
+
900
+ // ── Remote cursor rendering ────────────────────────────
901
+ // We approximate cursor pixel position from char index.
902
+ // This is a simplified approach — in prod you'd use Range API.
903
+ const cursorElems = {};
904
+
905
+ function updateRemoteCursorUI(userId, charPos, name, color) {
906
+ // We'll use a char-offset based approach
907
+ // Get pixel coords by measuring text before cursor
908
+ const coords = getCharCoords(charPos);
909
+ if (!coords) return;
910
+
911
+ let elem = cursorElems[userId];
912
+ if (!elem) {
913
+ elem = document.createElement('div');
914
+ elem.className = 'remote-cursor';
915
+ elem.innerHTML = `
916
+ <div class="remote-cursor-label" style="background:${color}">${escHtml(name)}</div>
917
+ <div class="remote-cursor-line" style="background:${color}"></div>
918
+ `;
919
+ document.getElementById('cursor-layer').appendChild(elem);
920
+ cursorElems[userId] = elem;
921
+ }
922
+
923
+ elem.style.left = `${coords.x}px`;
924
+ elem.style.top = `${coords.y}px`;
925
+ elem.querySelector('.remote-cursor-label').style.background = color;
926
+ elem.querySelector('.remote-cursor-line').style.background = color;
927
+ elem.querySelector('.remote-cursor-label').textContent = name;
928
+ }
929
+
930
+ function removeRemoteCursor(userId) {
931
+ const elem = cursorElems[userId];
932
+ if (elem) {
933
+ elem.remove();
934
+ delete cursorElems[userId];
935
+ }
936
+ }
937
+
938
+ function getCharCoords(charIndex) {
939
+ // Measure by creating a hidden mirror of textarea content
940
+ const editorRect = editor.getBoundingClientRect();
941
+ const pageRect = document.getElementById('page').getBoundingClientRect();
942
+
943
+ const mirror = document.createElement('div');
944
+ const styles = window.getComputedStyle(editor);
945
+
946
+ Object.assign(mirror.style, {
947
+ position: 'absolute',
948
+ top: '-9999px',
949
+ left: '-9999px',
950
+ width: `${editor.clientWidth}px`,
951
+ fontFamily: styles.fontFamily,
952
+ fontSize: styles.fontSize,
953
+ lineHeight: styles.lineHeight,
954
+ padding: styles.padding,
955
+ whiteSpace: 'pre-wrap',
956
+ wordWrap: 'break-word',
957
+ overflowWrap: 'break-word',
958
+ visibility: 'hidden',
959
+ });
960
+
961
+ const text = editor.value.slice(0, charIndex);
962
+ const before = document.createTextNode(text);
963
+ const span = document.createElement('span');
964
+ span.textContent = '|';
965
+ mirror.appendChild(before);
966
+ mirror.appendChild(span);
967
+ document.body.appendChild(mirror);
968
+
969
+ const spanRect = span.getBoundingClientRect();
970
+ const mirrorRect = mirror.getBoundingClientRect();
971
+ const x = spanRect.left - mirrorRect.left + (editorRect.left - pageRect.left);
972
+ const y = spanRect.top - mirrorRect.top + (editorRect.top - pageRect.top);
973
+
974
+ document.body.removeChild(mirror);
975
+ return { x, y };
976
+ }
977
+
978
+ // ── User avatars ───────────────────────────────────────
979
+ function renderUsers(users) {
980
+ userAvatars.innerHTML = '';
981
+ const maxShow = 5;
982
+ const shown = users.slice(0, maxShow);
983
+
984
+ shown.forEach(u => {
985
+ const div = document.createElement('div');
986
+ div.className = 'avatar';
987
+ div.style.background = u.color;
988
+ div.textContent = (u.name || '?')[0].toUpperCase();
989
+ if (u.user_id === myUserId) {
990
+ div.style.border = '2px solid ' + u.color;
991
+ div.style.boxShadow = `0 0 0 2px white, 0 0 0 3px ${u.color}`;
992
+ }
993
+ const tooltip = document.createElement('div');
994
+ tooltip.className = 'avatar-tooltip';
995
+ tooltip.style.background = u.color;
996
+ tooltip.textContent = u.user_id === myUserId ? `${u.name} (you)` : u.name;
997
+ div.appendChild(tooltip);
998
+ userAvatars.appendChild(div);
999
+ });
1000
+
1001
+ if (users.length > maxShow) {
1002
+ const more = document.createElement('div');
1003
+ more.className = 'avatar';
1004
+ more.style.background = '#888';
1005
+ more.textContent = `+${users.length - maxShow}`;
1006
+ userAvatars.appendChild(more);
1007
+ }
1008
+ }
1009
+
1010
+ // ── Activity/typing indicator ──────────────────────────
1011
+ const typingTimeouts = {};
1012
+
1013
+ function showTypingActivity(userId, name) {
1014
+ typingUsers[userId] = name;
1015
+ clearTimeout(typingTimeouts[userId]);
1016
+ typingTimeouts[userId] = setTimeout(() => {
1017
+ delete typingUsers[userId];
1018
+ updateActivityBar();
1019
+ }, 2000);
1020
+ updateActivityBar();
1021
+ }
1022
+
1023
+ function updateActivityBar() {
1024
+ const names = Object.values(typingUsers).filter(n => true);
1025
+ if (names.length === 0) {
1026
+ activityBar.classList.add('hidden');
1027
+ return;
1028
+ }
1029
+ activityBar.classList.remove('hidden');
1030
+ const label = names.length === 1
1031
+ ? `${names[0]} is typing`
1032
+ : `${names.slice(0,-1).join(', ')} and ${names[names.length-1]} are typing`;
1033
+ activityText.textContent = label;
1034
+ }
1035
+
1036
+ // ── Notifications ──────────────────────────────────────
1037
+ function showNotif(text, color) {
1038
+ const div = document.createElement('div');
1039
+ div.className = 'notif';
1040
+ div.innerHTML = `<div class="notif-dot" style="background:${color}"></div><span>${escHtml(text)}</span>`;
1041
+ notifs.appendChild(div);
1042
+ setTimeout(() => div.remove(), 3500);
1043
+ }
1044
+
1045
+ // ── Status ─────────────────────────────────────────────
1046
+ function setStatus(state, text) {
1047
+ statusBadge.className = `${state}`;
1048
+ statusText.textContent = text;
1049
+ }
1050
+
1051
+ function updateVersionDisplay() {
1052
+ versionDisplay.textContent = `v${serverVersion}`;
1053
+ }
1054
+
1055
+ // ── Heartbeat ───────────��──────────────────────────────
1056
+ let heartbeatInterval;
1057
+ function scheduleHeartbeat() {
1058
+ clearInterval(heartbeatInterval);
1059
+ heartbeatInterval = setInterval(() => {
1060
+ sendIfOpen({ type: 'ping' });
1061
+ }, 10000);
1062
+ }
1063
+
1064
+ function sendIfOpen(msg) {
1065
+ if (ws && ws.readyState === WebSocket.OPEN) {
1066
+ ws.send(JSON.stringify(msg));
1067
+ }
1068
+ }
1069
+
1070
+ // ── Share modal ────────────────────────────────────────
1071
+ shareBtn.addEventListener('click', () => {
1072
+ shareLinkInput.value = getShareUrl();
1073
+ shareModal.classList.add('open');
1074
+ shareLinkInput.select();
1075
+ });
1076
+
1077
+ modalCloseBtn.addEventListener('click', () => {
1078
+ shareModal.classList.remove('open');
1079
+ });
1080
+
1081
+ shareModal.addEventListener('click', (e) => {
1082
+ if (e.target === shareModal) shareModal.classList.remove('open');
1083
+ });
1084
+
1085
+ copyLinkBtn.addEventListener('click', () => {
1086
+ navigator.clipboard.writeText(shareLinkInput.value).then(() => {
1087
+ copyLinkBtn.textContent = '✓ Copied!';
1088
+ copyLinkBtn.classList.add('copied');
1089
+ setTimeout(() => {
1090
+ copyLinkBtn.textContent = 'Copy';
1091
+ copyLinkBtn.classList.remove('copied');
1092
+ }, 2000);
1093
+ });
1094
+ });
1095
+
1096
+ // ── New doc ────────────────────────────────────────────
1097
+ newDocBtn.addEventListener('click', async () => {
1098
+ const res = await fetch('/api/docs', { method: 'POST' });
1099
+ const data = await res.json();
1100
+ window.location.href = data.url;
1101
+ });
1102
+
1103
+ // ── Escape util ────────────────────────────────────────
1104
+ function escHtml(str) {
1105
+ return String(str)
1106
+ .replace(/&/g, '&amp;')
1107
+ .replace(/</g, '&lt;')
1108
+ .replace(/>/g, '&gt;')
1109
+ .replace(/"/g, '&quot;');
1110
+ }
1111
+
1112
+ // ── Init ───────────────────────────────────────────────
1113
+ connect();
1114
+
1115
+ })();
1116
+ </script>
1117
+ </body>
1118
+ </html>
main.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google Docs Clone - FastAPI Backend
3
+ Real-time collaborative document editing with Operational Transformation.
4
+
5
+ Architecture:
6
+ - WebSocket gateway for real-time bidirectional comms
7
+ - OT Engine for conflict resolution
8
+ - Presence manager for cursor/user tracking
9
+ - Document store for persistence (in-memory, swap for Redis+PG in prod)
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import uuid
16
+ from contextlib import asynccontextmanager
17
+ from typing import Any, Dict
18
+
19
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.responses import FileResponse, HTMLResponse
22
+ from fastapi.staticfiles import StaticFiles
23
+
24
+ from document_store import store
25
+ from ot_engine import Operation
26
+ from presence import presence_manager
27
+
28
+ logging.basicConfig(level=logging.INFO)
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # ─── Broadcast helpers ────────────────────────────────────────────────────────
33
+
34
+ async def broadcast_to_doc(doc_id: str, message: dict, exclude_user: str = None):
35
+ """Fan out a message to all connected clients for a document."""
36
+ connections = presence_manager.get_connections(doc_id)
37
+ dead = []
38
+ for user_id, ws in connections.items():
39
+ if user_id == exclude_user:
40
+ continue
41
+ try:
42
+ await ws.send_json(message)
43
+ except Exception:
44
+ dead.append(user_id)
45
+ for uid in dead:
46
+ presence_manager.leave(doc_id, uid)
47
+
48
+
49
+ async def send_to_user(doc_id: str, user_id: str, message: dict):
50
+ """Send a message to a specific user."""
51
+ user = presence_manager.get_user(doc_id, user_id)
52
+ if user and user.websocket:
53
+ try:
54
+ await user.websocket.send_json(message)
55
+ except Exception:
56
+ pass
57
+
58
+
59
+ # ─── Message handlers ─────────────────────────────────────────────────────────
60
+
61
+ async def handle_operation(doc_id: str, user_id: str, data: dict):
62
+ """
63
+ Process an incoming OT operation from a client.
64
+ Transform → Apply → Broadcast.
65
+ """
66
+ doc = store.get_or_create(doc_id)
67
+
68
+ op = Operation(
69
+ op_type=data["op_type"],
70
+ position=data["position"],
71
+ value=data.get("value", ""),
72
+ length=data.get("length", len(data.get("value", "")) or 1),
73
+ base_version=data["base_version"],
74
+ user_id=user_id,
75
+ op_id=data.get("op_id", str(uuid.uuid4())),
76
+ )
77
+
78
+ # Apply with OT (thread-safety note: use asyncio.Lock in prod)
79
+ transformed_op = doc.apply_op(op)
80
+
81
+ # Ack to sender
82
+ await send_to_user(doc_id, user_id, {
83
+ "type": "ack",
84
+ "op_id": op.op_id,
85
+ "server_version": doc.version,
86
+ })
87
+
88
+ # Broadcast transformed op to all others
89
+ broadcast_msg = {
90
+ "type": "operation",
91
+ "op_type": transformed_op.op_type,
92
+ "position": transformed_op.position,
93
+ "value": transformed_op.value,
94
+ "length": transformed_op.length,
95
+ "server_version": doc.version,
96
+ "user_id": user_id,
97
+ "op_id": transformed_op.op_id,
98
+ }
99
+ await broadcast_to_doc(doc_id, broadcast_msg, exclude_user=user_id)
100
+
101
+
102
+ async def handle_cursor(doc_id: str, user_id: str, data: dict):
103
+ """Update and broadcast cursor position."""
104
+ presence_manager.update_cursor(
105
+ doc_id,
106
+ user_id,
107
+ cursor_pos=data.get("cursor_pos", 0),
108
+ sel_start=data.get("selection_start", -1),
109
+ sel_end=data.get("selection_end", -1),
110
+ )
111
+
112
+ user = presence_manager.get_user(doc_id, user_id)
113
+ if not user:
114
+ return
115
+
116
+ await broadcast_to_doc(doc_id, {
117
+ "type": "cursor",
118
+ "user_id": user_id,
119
+ "name": user.name,
120
+ "color": user.color,
121
+ "cursor_pos": data.get("cursor_pos", 0),
122
+ "selection_start": data.get("selection_start", -1),
123
+ "selection_end": data.get("selection_end", -1),
124
+ }, exclude_user=user_id)
125
+
126
+
127
+ async def handle_title_change(doc_id: str, user_id: str, data: dict):
128
+ """Sync document title change."""
129
+ title = data.get("title", "Untitled Document")[:200]
130
+ store.update_title(doc_id, title)
131
+ await broadcast_to_doc(doc_id, {
132
+ "type": "title_change",
133
+ "title": title,
134
+ "user_id": user_id,
135
+ }, exclude_user=user_id)
136
+
137
+
138
+ async def handle_ping(doc_id: str, user_id: str):
139
+ user = presence_manager.get_user(doc_id, user_id)
140
+ if user:
141
+ user.ping()
142
+
143
+
144
+ # ─── WebSocket endpoint ───────────────────────────────────────────────────────
145
+
146
+ @asynccontextmanager
147
+ async def lifespan(app: FastAPI):
148
+ # Create a default welcome document
149
+ doc = store.get_or_create("welcome")
150
+ if not doc.content:
151
+ doc.title = "Welcome Document"
152
+ welcome_text = (
153
+ "Welcome to CollabDocs!\n\n"
154
+ "This is a real-time collaborative document editor.\n"
155
+ "Share the URL with anyone — they can edit simultaneously.\n\n"
156
+ "✦ Changes appear instantly for all users\n"
157
+ "✦ Each user gets a unique color cursor\n"
158
+ "✦ Conflicts are resolved via Operational Transformation (OT)\n\n"
159
+ "Start typing to collaborate..."
160
+ )
161
+ from ot_engine import Operation, apply_operation
162
+ doc.content = welcome_text
163
+ doc.version = 1
164
+ yield
165
+
166
+
167
+ app = FastAPI(title="CollabDocs", lifespan=lifespan)
168
+
169
+ app.add_middleware(
170
+ CORSMiddleware,
171
+ allow_origins=["*"],
172
+ allow_credentials=True,
173
+ allow_methods=["*"],
174
+ allow_headers=["*"],
175
+ )
176
+
177
+
178
+ @app.websocket("/ws/{doc_id}")
179
+ async def websocket_endpoint(websocket: WebSocket, doc_id: str):
180
+ await websocket.accept()
181
+
182
+ # Generate or get user_id from query params
183
+ user_id = websocket.query_params.get("user_id") or str(uuid.uuid4())
184
+
185
+ # Register presence
186
+ user = presence_manager.join(doc_id, user_id, websocket)
187
+ doc = store.get_or_create(doc_id)
188
+
189
+ logger.info(f"User {user.name} ({user_id}) joined doc {doc_id}")
190
+
191
+ # Send initial state to connecting user
192
+ await websocket.send_json({
193
+ "type": "init",
194
+ "user_id": user_id,
195
+ "name": user.name,
196
+ "color": user.color,
197
+ "doc_state": doc.get_state(),
198
+ "users": presence_manager.get_users(doc_id),
199
+ })
200
+
201
+ # Announce join to others
202
+ await broadcast_to_doc(doc_id, {
203
+ "type": "user_joined",
204
+ "user_id": user_id,
205
+ "name": user.name,
206
+ "color": user.color,
207
+ "users": presence_manager.get_users(doc_id),
208
+ }, exclude_user=user_id)
209
+
210
+ try:
211
+ while True:
212
+ raw = await websocket.receive_text()
213
+ try:
214
+ data = json.loads(raw)
215
+ except json.JSONDecodeError:
216
+ continue
217
+
218
+ msg_type = data.get("type")
219
+
220
+ if msg_type == "operation":
221
+ await handle_operation(doc_id, user_id, data)
222
+
223
+ elif msg_type == "cursor":
224
+ await handle_cursor(doc_id, user_id, data)
225
+
226
+ elif msg_type == "title_change":
227
+ await handle_title_change(doc_id, user_id, data)
228
+
229
+ elif msg_type == "ping":
230
+ await handle_ping(doc_id, user_id)
231
+
232
+ except WebSocketDisconnect:
233
+ logger.info(f"User {user.name} ({user_id}) left doc {doc_id}")
234
+ presence_manager.leave(doc_id, user_id)
235
+
236
+ await broadcast_to_doc(doc_id, {
237
+ "type": "user_left",
238
+ "user_id": user_id,
239
+ "name": user.name,
240
+ "users": presence_manager.get_users(doc_id),
241
+ })
242
+
243
+ except Exception as e:
244
+ logger.error(f"WebSocket error for {user_id}: {e}")
245
+ presence_manager.leave(doc_id, user_id)
246
+
247
+
248
+ # ─── REST endpoints ───────────────────────────────────────────────────────────
249
+
250
+ @app.get("/api/docs")
251
+ async def list_documents():
252
+ return store.list_docs()
253
+
254
+
255
+ @app.get("/api/docs/{doc_id}")
256
+ async def get_document(doc_id: str):
257
+ doc = store.get(doc_id)
258
+ if not doc:
259
+ raise HTTPException(status_code=404, detail="Document not found")
260
+ return doc.get_state()
261
+
262
+
263
+ @app.post("/api/docs")
264
+ async def create_document():
265
+ doc_id = str(uuid.uuid4())[:8]
266
+ doc = store.get_or_create(doc_id)
267
+ return {"doc_id": doc_id, "url": f"/?doc={doc_id}"}
268
+
269
+
270
+ @app.get("/health")
271
+ async def health():
272
+ return {"status": "ok", "docs": len(store._docs)}
273
+
274
+
275
+ # ─── Serve frontend ───────────────────────────────────────────────────────────
276
+
277
+ @app.get("/")
278
+ async def serve_index():
279
+ return FileResponse("static/index.html")
280
+
281
+
282
+ @app.mount("/static", StaticFiles(directory="static"), name="static")
ot_engine.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Operational Transformation Engine
3
+ Implements insert-insert, insert-delete, delete-insert, delete-delete,
4
+ delete-range-insert, and insert-inside-delete-range transformations.
5
+ Based on the architecture from the Google Docs system design.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Literal, Optional
10
+ import copy
11
+
12
+
13
+ @dataclass
14
+ class Operation:
15
+ op_type: Literal["insert", "delete"]
16
+ position: int
17
+ value: str = "" # For insert
18
+ length: int = 1 # For delete (range support)
19
+ base_version: int = 0
20
+ user_id: str = ""
21
+ op_id: str = ""
22
+
23
+
24
+ @dataclass
25
+ class ServerState:
26
+ version: int = 0
27
+ content: str = ""
28
+ # Operation log: list of (version, Operation)
29
+ op_log: list = field(default_factory=list)
30
+
31
+
32
+ def transform_insert_insert(incoming: Operation, applied: Operation) -> Operation:
33
+ """
34
+ Two concurrent inserts. If applied insert position <= incoming position,
35
+ shift incoming position right by length of applied insert.
36
+ Tie-break by user_id to ensure consistency.
37
+ """
38
+ result = copy.copy(incoming)
39
+ app_len = len(applied.value)
40
+
41
+ if applied.position < incoming.position:
42
+ result.position += app_len
43
+ elif applied.position == incoming.position:
44
+ # Tie-break: lexicographically larger user_id goes later
45
+ if applied.user_id <= incoming.user_id:
46
+ result.position += app_len
47
+ return result
48
+
49
+
50
+ def transform_delete_insert(incoming: Operation, applied: Operation) -> Operation:
51
+ """
52
+ incoming = delete, applied = insert (already on server).
53
+ Insert before delete pos: shift delete right.
54
+ Insert inside/after: no change.
55
+ """
56
+ result = copy.copy(incoming)
57
+ app_len = len(applied.value)
58
+
59
+ if applied.position <= incoming.position:
60
+ result.position += app_len
61
+ elif applied.position < incoming.position + incoming.length:
62
+ # Insert is inside the delete range — expand delete range
63
+ result.length += app_len
64
+ return result
65
+
66
+
67
+ def transform_insert_delete(incoming: Operation, applied: Operation) -> Operation:
68
+ """
69
+ incoming = insert, applied = delete (already on server).
70
+ Delete before insert pos: shift insert left by deleted length.
71
+ Delete range overlaps insert pos: push insert to start of deleted range.
72
+ """
73
+ result = copy.copy(incoming)
74
+
75
+ del_end = applied.position + applied.length
76
+
77
+ if del_end <= incoming.position:
78
+ # Delete entirely before insert — shift left
79
+ result.position -= applied.length
80
+ elif applied.position <= incoming.position < del_end:
81
+ # Insert falls inside deleted region — push to start
82
+ result.position = applied.position
83
+ # If delete is entirely after insert pos, no change
84
+ return result
85
+
86
+
87
+ def transform_delete_delete(incoming: Operation, applied: Operation) -> Operation:
88
+ """
89
+ Two concurrent deletes.
90
+ """
91
+ result = copy.copy(incoming)
92
+
93
+ app_end = applied.position + applied.length
94
+ inc_end = incoming.position + incoming.length
95
+
96
+ if app_end <= incoming.position:
97
+ # Applied delete entirely before incoming delete — shift left
98
+ result.position -= applied.length
99
+
100
+ elif applied.position >= inc_end:
101
+ # Applied delete entirely after incoming delete — no change
102
+ pass
103
+
104
+ else:
105
+ # Overlapping deletes — trim incoming to remove already-deleted chars
106
+ overlap_start = max(applied.position, incoming.position)
107
+ overlap_end = min(app_end, inc_end)
108
+ overlap = overlap_end - overlap_start
109
+
110
+ # Shift position if applied starts before incoming
111
+ if applied.position < incoming.position:
112
+ result.position = applied.position
113
+
114
+ result.length = max(0, result.length - overlap)
115
+
116
+ return result
117
+
118
+
119
+ def transform_operation(incoming: Operation, applied: Operation) -> Operation:
120
+ """
121
+ Transform incoming operation against an already-applied operation.
122
+ """
123
+ if incoming.op_type == "insert" and applied.op_type == "insert":
124
+ return transform_insert_insert(incoming, applied)
125
+ elif incoming.op_type == "delete" and applied.op_type == "insert":
126
+ return transform_delete_insert(incoming, applied)
127
+ elif incoming.op_type == "insert" and applied.op_type == "delete":
128
+ return transform_insert_delete(incoming, applied)
129
+ elif incoming.op_type == "delete" and applied.op_type == "delete":
130
+ return transform_delete_delete(incoming, applied)
131
+ return incoming
132
+
133
+
134
+ def transform_against_log(
135
+ incoming: Operation,
136
+ op_log: list,
137
+ from_version: int,
138
+ to_version: int,
139
+ ) -> Operation:
140
+ """
141
+ Transform incoming against all operations from from_version to to_version.
142
+ op_log is list of (version, Operation) tuples.
143
+ """
144
+ result = copy.copy(incoming)
145
+ for ver, applied_op in op_log:
146
+ if from_version < ver <= to_version:
147
+ result = transform_operation(result, applied_op)
148
+ return result
149
+
150
+
151
+ def apply_operation(content: str, op: Operation) -> str:
152
+ """
153
+ Apply a single operation to the document content string.
154
+ Returns new content string.
155
+ """
156
+ if op.op_type == "insert":
157
+ pos = max(0, min(op.position, len(content)))
158
+ return content[:pos] + op.value + content[pos:]
159
+ elif op.op_type == "delete":
160
+ pos = max(0, min(op.position, len(content)))
161
+ end = max(0, min(pos + op.length, len(content)))
162
+ return content[:pos] + content[end:]
163
+ return content
164
+
165
+
166
+ def transform_cursor(cursor_pos: int, op: Operation) -> int:
167
+ """
168
+ Adjust a cursor position based on an operation.
169
+ Same logic as OT but for cursor position.
170
+ """
171
+ if op.op_type == "insert":
172
+ ins_len = len(op.value)
173
+ if op.position <= cursor_pos:
174
+ return cursor_pos + ins_len
175
+ elif op.op_type == "delete":
176
+ del_end = op.position + op.length
177
+ if del_end <= cursor_pos:
178
+ return cursor_pos - op.length
179
+ elif op.position <= cursor_pos < del_end:
180
+ return op.position
181
+ return cursor_pos
presence.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Presence Manager
3
+ Tracks active users per document: cursor positions, selections, colors.
4
+ """
5
+
6
+ import time
7
+ import random
8
+ from dataclasses import dataclass, field
9
+ from typing import Dict, List, Optional, Set
10
+ from fastapi import WebSocket
11
+
12
+ # Distinct user colors
13
+ USER_COLORS = [
14
+ "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7",
15
+ "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9",
16
+ "#F8C471", "#82E0AA", "#F1948A", "#AED6F1", "#A9DFBF",
17
+ ]
18
+
19
+ USER_NAMES = [
20
+ "Alice", "Bob", "Carol", "Dave", "Eve",
21
+ "Frank", "Grace", "Hank", "Iris", "Jack",
22
+ "Kate", "Leo", "Mia", "Nick", "Olivia",
23
+ ]
24
+
25
+
26
+ @dataclass
27
+ class UserPresence:
28
+ user_id: str
29
+ name: str
30
+ color: str
31
+ cursor_pos: int = 0
32
+ selection_start: int = -1
33
+ selection_end: int = -1
34
+ last_seen: float = field(default_factory=time.time)
35
+ websocket: Optional[object] = None
36
+
37
+ def to_dict(self) -> dict:
38
+ return {
39
+ "user_id": self.user_id,
40
+ "name": self.name,
41
+ "color": self.color,
42
+ "cursor_pos": self.cursor_pos,
43
+ "selection_start": self.selection_start,
44
+ "selection_end": self.selection_end,
45
+ }
46
+
47
+ def ping(self):
48
+ self.last_seen = time.time()
49
+
50
+
51
+ class PresenceManager:
52
+ def __init__(self):
53
+ # doc_id -> {user_id -> UserPresence}
54
+ self._doc_users: Dict[str, Dict[str, UserPresence]] = {}
55
+ # doc_id -> {user_id -> WebSocket}
56
+ self._connections: Dict[str, Dict[str, WebSocket]] = {}
57
+ self._used_colors: Dict[str, Set[str]] = {}
58
+
59
+ def _get_color(self, doc_id: str) -> str:
60
+ used = self._used_colors.get(doc_id, set())
61
+ available = [c for c in USER_COLORS if c not in used]
62
+ if not available:
63
+ available = USER_COLORS
64
+ color = random.choice(available)
65
+ self._used_colors.setdefault(doc_id, set()).add(color)
66
+ return color
67
+
68
+ def join(self, doc_id: str, user_id: str, ws: WebSocket) -> UserPresence:
69
+ if doc_id not in self._doc_users:
70
+ self._doc_users[doc_id] = {}
71
+ self._connections[doc_id] = {}
72
+
73
+ if user_id not in self._doc_users[doc_id]:
74
+ color = self._get_color(doc_id)
75
+ name = random.choice(USER_NAMES)
76
+ presence = UserPresence(
77
+ user_id=user_id,
78
+ name=name,
79
+ color=color,
80
+ websocket=ws,
81
+ )
82
+ self._doc_users[doc_id][user_id] = presence
83
+ else:
84
+ self._doc_users[doc_id][user_id].websocket = ws
85
+ self._doc_users[doc_id][user_id].ping()
86
+
87
+ self._connections[doc_id][user_id] = ws
88
+ return self._doc_users[doc_id][user_id]
89
+
90
+ def leave(self, doc_id: str, user_id: str):
91
+ if doc_id in self._doc_users:
92
+ self._doc_users[doc_id].pop(user_id, None)
93
+ self._connections[doc_id].pop(user_id, None)
94
+ color = None
95
+ if color and doc_id in self._used_colors:
96
+ self._used_colors[doc_id].discard(color)
97
+
98
+ def update_cursor(
99
+ self,
100
+ doc_id: str,
101
+ user_id: str,
102
+ cursor_pos: int,
103
+ sel_start: int = -1,
104
+ sel_end: int = -1,
105
+ ):
106
+ users = self._doc_users.get(doc_id, {})
107
+ if user_id in users:
108
+ p = users[user_id]
109
+ p.cursor_pos = cursor_pos
110
+ p.selection_start = sel_start
111
+ p.selection_end = sel_end
112
+ p.ping()
113
+
114
+ def get_users(self, doc_id: str) -> List[dict]:
115
+ users = self._doc_users.get(doc_id, {})
116
+ # Prune stale users (> 30s no ping)
117
+ now = time.time()
118
+ active = {
119
+ uid: u for uid, u in users.items()
120
+ if now - u.last_seen < 30
121
+ }
122
+ self._doc_users[doc_id] = active
123
+ return [u.to_dict() for u in active.values()]
124
+
125
+ def get_connections(self, doc_id: str) -> Dict[str, WebSocket]:
126
+ return self._connections.get(doc_id, {})
127
+
128
+ def get_user(self, doc_id: str, user_id: str) -> Optional[UserPresence]:
129
+ return self._doc_users.get(doc_id, {}).get(user_id)
130
+
131
+
132
+ # Global singleton
133
+ presence_manager = PresenceManager()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ websockets==13.1
4
+ python-multipart==0.0.12