NOT-OMEGA commited on
Commit
9767f74
Β·
verified Β·
1 Parent(s): bff044f

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +326 -512
index.html CHANGED
@@ -84,12 +84,7 @@
84
  letter-spacing: 0.5px;
85
  }
86
 
87
- .toolbar-divider {
88
- width: 1px;
89
- height: 20px;
90
- background: var(--border);
91
- flex-shrink: 0;
92
- }
93
 
94
  #doc-title {
95
  flex: 1;
@@ -105,428 +100,194 @@
105
  border-radius: var(--radius-sm);
106
  transition: background 0.15s, border-color 0.15s;
107
  }
108
-
109
  #doc-title:hover { background: var(--surface2); border-color: var(--border); }
110
  #doc-title:focus { background: var(--surface2); border-color: var(--border-strong); }
111
 
112
- .toolbar-right {
113
- display: flex;
114
- align-items: center;
115
- gap: 10px;
116
- flex-shrink: 0;
117
- }
118
 
119
- /* Version chip */
120
  #version-chip {
121
- font-size: 11px;
122
- font-weight: 500;
123
- color: var(--text-light);
124
- background: var(--surface2);
125
- border: 1px solid var(--border);
126
- border-radius: 20px;
127
- padding: 3px 9px;
128
- white-space: nowrap;
129
  font-variant-numeric: tabular-nums;
130
  }
131
 
132
- /* Status badge */
133
  #status-badge {
134
- display: flex;
135
- align-items: center;
136
- gap: 5px;
137
- font-size: 12px;
138
- font-weight: 500;
139
- padding: 4px 10px;
140
- border-radius: 20px;
141
- border: 1px solid var(--border);
142
- background: var(--surface2);
143
- color: var(--text-muted);
144
- transition: all 0.25s;
145
- white-space: nowrap;
146
- }
147
-
148
- #status-badge.connected {
149
- color: var(--accent);
150
- background: var(--accent-dim);
151
- border-color: #A8DDB8;
152
- }
153
-
154
- #status-badge.error {
155
- color: var(--danger);
156
- background: #FDEAEA;
157
- border-color: #EAB0B0;
158
- }
159
-
160
- .status-dot {
161
- width: 6px; height: 6px;
162
- border-radius: 50%;
163
- background: currentColor;
164
  }
 
 
165
 
 
166
  .status-dot.pulse { animation: dot-pulse 2s ease-in-out infinite; }
167
 
168
  @keyframes dot-pulse {
169
  0%, 100% { opacity: 1; transform: scale(1); }
170
- 50% { opacity: 0.5; transform: scale(0.85); }
171
  }
172
 
173
- /* User avatars */
174
- #user-avatars {
175
- display: flex;
176
- align-items: center;
177
- }
178
 
179
  .avatar {
180
- width: 28px; height: 28px;
181
- border-radius: 50%;
182
  display: flex; align-items: center; justify-content: center;
183
- font-size: 11px;
184
- font-weight: 700;
185
- color: white;
186
- border: 2px solid var(--surface);
187
- margin-left: -7px;
188
- cursor: default;
189
- position: relative;
190
  transition: transform 0.15s, z-index 0s;
191
- box-shadow: 0 1px 4px rgba(0,0,0,0.15);
192
- text-transform: uppercase;
193
  }
194
-
195
  .avatar:first-child { margin-left: 0; }
196
-
197
- .avatar:hover {
198
- transform: translateY(-2px);
199
- z-index: 20;
200
- }
201
 
202
  .avatar-tip {
203
- position: absolute;
204
- bottom: calc(100% + 7px);
205
- left: 50%;
206
- transform: translateX(-50%);
207
- background: var(--text);
208
- color: #fff;
209
- font-size: 11px;
210
- font-weight: 500;
211
- padding: 3px 8px;
212
- border-radius: 4px;
213
- white-space: nowrap;
214
- opacity: 0;
215
- pointer-events: none;
216
- transition: opacity 0.15s;
217
  }
218
-
219
  .avatar-tip::after {
220
- content: '';
221
- position: absolute;
222
- top: 100%;
223
- left: 50%;
224
- transform: translateX(-50%);
225
- border: 4px solid transparent;
226
  border-top-color: var(--text);
227
  }
228
-
229
  .avatar:hover .avatar-tip { opacity: 1; }
230
 
231
- /* Buttons */
232
  .btn {
233
- display: inline-flex;
234
- align-items: center;
235
- gap: 5px;
236
- padding: 6px 12px;
237
- border-radius: var(--radius-sm);
238
- font-family: var(--font-sans);
239
- font-size: 13px;
240
- font-weight: 500;
241
- cursor: pointer;
242
- border: 1px solid transparent;
243
- transition: all 0.15s;
244
- white-space: nowrap;
245
- line-height: 1;
246
  }
247
-
248
- .btn-ghost {
249
- background: var(--surface2);
250
- border-color: var(--border);
251
- color: var(--text-muted);
252
- }
253
-
254
  .btn-ghost:hover { background: var(--surface3); color: var(--text); }
255
-
256
- .btn-primary {
257
- background: var(--accent);
258
- color: white;
259
- }
260
-
261
- .btn-primary:hover {
262
- background: var(--accent-hover);
263
- transform: translateY(-1px);
264
- box-shadow: var(--shadow);
265
- }
266
-
267
  .btn-primary:active { transform: translateY(0); box-shadow: none; }
268
 
269
  /* ── Main layout ── */
270
- #main {
271
- flex: 1;
272
- display: flex;
273
- justify-content: center;
274
- padding: 40px 20px 80px;
275
- }
276
 
277
- #page-wrap {
278
- width: 100%;
279
- max-width: 800px;
280
- }
281
-
282
- /* Document page */
283
  #page {
284
- background: var(--surface);
285
- border-radius: 2px;
286
- box-shadow: var(--shadow-page);
287
- position: relative;
288
- min-height: 80vh;
289
  }
290
 
291
- /* Remote cursor overlay */
292
  #cursor-layer {
293
- position: absolute;
294
- inset: 0;
295
- pointer-events: none;
296
- overflow: hidden;
297
- z-index: 5;
298
- border-radius: 2px;
299
  }
300
 
301
- .r-cursor {
302
- position: absolute;
303
- pointer-events: none;
304
- will-change: left, top;
305
- }
306
-
307
- .r-cursor-caret {
308
- width: 2px;
309
- height: 20px;
310
- border-radius: 1px;
311
- animation: caret-blink 1.1s ease-in-out infinite;
312
- }
313
 
314
  @keyframes caret-blink {
315
  0%, 45%, 100% { opacity: 1; }
316
- 55%, 90% { opacity: 0; }
317
  }
318
 
319
  .r-cursor-label {
320
- position: absolute;
321
- top: -19px;
322
- left: 0;
323
- font-size: 10px;
324
- font-weight: 600;
325
- color: white;
326
- padding: 2px 6px;
327
- border-radius: 3px 3px 3px 0;
328
- white-space: nowrap;
329
- font-family: var(--font-sans);
330
- line-height: 1.4;
331
- box-shadow: 0 1px 4px rgba(0,0,0,0.2);
332
- letter-spacing: 0.2px;
333
  }
334
 
335
- /* Editor textarea */
336
  #editor {
337
- width: 100%;
338
- min-height: 80vh;
339
- padding: 64px 88px;
340
- font-family: var(--font-serif);
341
- font-size: 16.5px;
342
- line-height: 1.85;
343
- color: var(--text);
344
- border: none;
345
- outline: none;
346
- resize: none;
347
- background: transparent;
348
- position: relative;
349
- z-index: 10;
350
- white-space: pre-wrap;
351
- word-wrap: break-word;
352
- caret-color: var(--accent);
353
- -webkit-font-smoothing: antialiased;
354
- }
355
-
356
- #editor::placeholder {
357
- color: var(--text-light);
358
- font-style: italic;
359
- }
360
-
361
- #editor::selection {
362
- background: rgba(26, 92, 58, 0.15);
363
  }
 
 
364
 
365
- /* ── Activity bar ── */
366
  #activity-bar {
367
- position: fixed;
368
- bottom: 18px;
369
- left: 18px;
370
- background: var(--surface);
371
- border: 1px solid var(--border);
372
- border-radius: var(--radius);
373
- padding: 7px 12px;
374
- font-size: 12px;
375
- color: var(--text-muted);
376
- box-shadow: var(--shadow);
377
- display: flex;
378
- align-items: center;
379
- gap: 7px;
380
- opacity: 1;
381
- transform: translateY(0);
382
- transition: opacity 0.2s, transform 0.2s;
383
- }
384
-
385
- #activity-bar.hidden {
386
- opacity: 0;
387
- transform: translateY(4px);
388
- pointer-events: none;
389
- }
390
-
391
- .typing-dots {
392
- display: flex;
393
- gap: 3px;
394
- align-items: center;
395
  }
 
396
 
 
397
  .typing-dot {
398
- width: 4px; height: 4px;
399
- border-radius: 50%;
400
- background: var(--accent-light);
401
- animation: typing-bounce 1.3s ease-in-out infinite;
402
  }
403
-
404
  .typing-dot:nth-child(2) { animation-delay: 0.16s; }
405
  .typing-dot:nth-child(3) { animation-delay: 0.32s; }
406
 
407
  @keyframes typing-bounce {
408
  0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
409
- 30% { transform: translateY(-4px); opacity: 1; }
410
  }
411
 
412
- /* ── Toast notifications ── */
413
  #toasts {
414
- position: fixed;
415
- bottom: 18px;
416
- right: 18px;
417
- display: flex;
418
- flex-direction: column;
419
- gap: 8px;
420
- z-index: 1000;
421
- pointer-events: none;
422
  }
423
-
424
  .toast {
425
- background: var(--surface);
426
- border: 1px solid var(--border);
427
- border-radius: var(--radius);
428
- padding: 9px 14px;
429
- font-size: 13px;
430
- box-shadow: var(--shadow-lg);
431
- display: flex;
432
- align-items: center;
433
- gap: 8px;
434
- max-width: 260px;
435
- animation: toast-in 0.25s ease;
436
  }
437
-
438
  @keyframes toast-in {
439
  from { transform: translateX(16px); opacity: 0; }
440
- to { transform: translateX(0); opacity: 1; }
441
- }
442
-
443
- .toast-dot {
444
- width: 7px; height: 7px;
445
- border-radius: 50%;
446
- flex-shrink: 0;
447
  }
 
448
 
449
- /* ── Share modal ── */
450
  #share-modal {
451
- display: none;
452
- position: fixed;
453
- inset: 0;
454
- background: rgba(0,0,0,0.35);
455
- z-index: 500;
456
- align-items: center;
457
- justify-content: center;
458
- backdrop-filter: blur(3px);
459
  }
460
-
461
  #share-modal.open { display: flex; }
462
 
463
  .modal {
464
- background: var(--surface);
465
- border-radius: var(--radius-lg);
466
- padding: 28px;
467
- width: 440px;
468
- max-width: 92vw;
469
  box-shadow: var(--shadow-lg);
470
  animation: modal-pop 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
471
  }
472
-
473
  @keyframes modal-pop {
474
  from { transform: scale(0.93); opacity: 0; }
475
- to { transform: scale(1); opacity: 1; }
476
- }
477
-
478
- .modal-title {
479
- font-family: var(--font-serif);
480
- font-size: 21px;
481
- margin-bottom: 6px;
482
- color: var(--text);
483
- }
484
-
485
- .modal-desc {
486
- font-size: 13.5px;
487
- color: var(--text-muted);
488
- margin-bottom: 18px;
489
- line-height: 1.6;
490
- }
491
-
492
- .link-row {
493
- display: flex;
494
- gap: 8px;
495
- margin-bottom: 14px;
496
- }
497
-
498
- .link-input {
499
- flex: 1;
500
- padding: 9px 11px;
501
- border: 1px solid var(--border-strong);
502
- border-radius: var(--radius-sm);
503
- font-size: 12.5px;
504
  font-family: 'SF Mono', 'Fira Code', monospace;
505
- background: var(--surface2);
506
- color: var(--text-muted);
507
- outline: none;
508
- overflow: hidden;
509
- text-overflow: ellipsis;
510
- white-space: nowrap;
511
  }
512
-
513
  .btn-copy { min-width: 72px; justify-content: center; }
514
  .btn-copy.copied { background: var(--accent-light); }
515
 
516
- /* ── Responsive ── */
517
  @media (max-width: 640px) {
518
  #editor { padding: 32px 24px; font-size: 15px; }
519
- .logo-badge { display: none; }
520
  #version-chip { display: none; }
521
  }
522
 
523
- /* Saving indicator */
524
- #save-indicator {
525
- font-size: 11.5px;
526
- color: var(--text-light);
527
- transition: opacity 0.3s;
528
- }
529
-
530
  #save-indicator.saving { color: var(--accent); }
531
  </style>
532
  </head>
@@ -583,124 +344,121 @@
583
  </div>
584
 
585
  <script>
586
- // ══════════════════════════════════════════════════════════════════════════
587
- // CollabDocs β€” Updated Client with OT Transformation logic
588
- // ══════════════════════════════════════════════════════════════════════════
 
 
 
 
 
589
 
590
  (function () {
591
  'use strict';
592
 
593
- // ── State ────────────────────────────────────────────────────────────────
594
  let ws = null;
595
- let myUserId = null;
596
- let myName = null;
597
- let myColor = null;
598
- let docId = null;
599
 
600
  let serverVersion = 0;
601
- let inFlight = null; // { op, op_id }
602
- let pendingQueue = []; // ops waiting for in-flight ack
603
 
604
  let isApplyingRemote = false;
605
- let prevContent = '';
606
 
607
  const remoteCursors = {};
608
- const cursorElems = {};
609
-
610
- const typingUsers = {};
611
- const typingTimers = {};
612
 
613
  let reconnectAttempts = 0;
614
- let reconnectTimer = null;
615
-
616
- // ── DOM ──────────────────────────────────────────────────────────────────
617
- const editor = document.getElementById('editor');
618
- const statusBadge = document.getElementById('status-badge');
619
- const statusText = document.getElementById('status-text');
620
- const statusDot = document.querySelector('.status-dot');
621
- const userAvatars = document.getElementById('user-avatars');
622
- const shareBtn = document.getElementById('share-btn');
623
- const shareModal = document.getElementById('share-modal');
624
- const shareLinkInput = document.getElementById('share-link');
625
- const copyBtn = document.getElementById('copy-btn');
626
- const modalClose = document.getElementById('modal-close');
627
- const newDocBtn = document.getElementById('new-doc-btn');
628
- const docTitleEl = document.getElementById('doc-title');
629
- const versionChip = document.getElementById('version-chip');
630
- const toastsEl = document.getElementById('toasts');
631
- const activityBar = document.getElementById('activity-bar');
632
  const activityText = document.getElementById('activity-text');
633
- const saveIndicator = document.getElementById('save-indicator');
 
 
634
 
635
- // ── OT Pairwise Transformations ───────────────────────
636
 
637
  function transform_ii(op, against) {
638
- let result = { ...op };
639
  if (against.position < op.position) {
640
- result.position += against.value.length;
641
  } else if (against.position === op.position) {
642
- // Tie-break: lower user_id goes first
643
- if (against.user_id <= op.user_id) result.position += against.value.length;
644
  }
645
- return result;
646
  }
647
 
648
  function transform_id(op, against) {
649
- let result = { ...op };
 
650
  const del_start = against.position;
651
- const del_end = against.position + against.length;
652
- if (del_end <= op.position) {
653
- result.position -= against.length;
654
- } else if (del_start < op.position) {
655
- result.position = del_start;
656
- }
657
- return result;
658
  }
659
 
660
  function transform_di(op, against) {
661
- let result = { ...op };
 
662
  const ins_pos = against.position;
663
  const ins_len = against.value.length;
664
  const del_end = op.position + op.length;
665
- if (ins_pos < op.position) {
666
- result.position += ins_len;
667
- } else if (ins_pos <= del_end) {
668
- result.length += ins_len;
669
- }
670
- return result;
671
  }
672
 
673
  function transform_dd(op, against) {
674
- let result = { ...op };
675
  const op_start = op.position;
676
- const op_end = op.position + op.length;
677
  const ag_start = against.position;
678
- const ag_end = against.position + against.length;
679
  if (ag_end <= op_start) {
680
- result.position -= against.length;
681
- } else if (ag_start >= op_end) {
682
- // no change
683
- } else {
684
- const overlap_start = Math.max(op_start, ag_start);
685
- const overlap_end = Math.min(op_end, ag_end);
686
- const overlap = overlap_end - overlap_start;
687
- if (ag_start < op_start) result.position = ag_start;
688
- result.length = Math.max(0, op.length - overlap);
689
  }
690
- return result;
691
  }
692
 
693
- function transformOperation(incoming, applied) {
694
- if (incoming.op_type === 'insert') {
695
- if (applied.op_type === 'insert' || applied.type === 'insert') return transform_ii(incoming, applied);
696
- else return transform_id(incoming, applied);
697
  } else {
698
- if (applied.op_type === 'insert' || applied.type === 'insert') return transform_di(incoming, applied);
699
- else return transform_dd(incoming, applied);
700
  }
701
  }
702
 
703
- // ── Routing ──────────────────────────────────────────────────────────────
704
  function getDocId() {
705
  return new URLSearchParams(window.location.search).get('doc') || 'welcome';
706
  }
@@ -714,33 +472,35 @@
714
  return id;
715
  }
716
 
717
- // ── Myers diff ────────────────────────────────────────────────────────────
718
  function myersDiff(oldStr, newStr) {
719
  const m = oldStr.length, n = newStr.length;
720
  if (m === 0 && n === 0) return [];
721
  if (m === 0) return [{ type: 'insert', pos: 0, text: newStr }];
722
- if (n === 0) return [{ type: 'delete', pos: 0, len: m }];
 
723
  let p = 0;
724
  while (p < m && p < n && oldStr[p] === newStr[p]) p++;
725
  let os = m - 1, ns = n - 1;
726
  while (os >= p && ns >= p && oldStr[os] === newStr[ns]) { os--; ns--; }
727
- const deletedLen = os - p + 1;
 
728
  const insertedStr = newStr.slice(p, ns + 1);
729
  const ops = [];
730
- if (deletedLen > 0) ops.push({ type: 'delete', pos: p, len: deletedLen });
731
  if (insertedStr.length > 0) ops.push({ type: 'insert', pos: p, text: insertedStr });
732
  return ops;
733
  }
734
 
735
- // ── OT client send pipeline ───────────────────────────────────────────────
736
  function composeAndSend(diffOps) {
737
  for (const d of diffOps) {
738
  const msg = d.type === 'insert'
739
  ? { op_type: 'insert', position: d.pos, value: d.text, length: d.text.length }
740
- : { op_type: 'delete', position: d.pos, value: '', length: d.len };
741
- msg.op_id = Math.random().toString(36).slice(2, 11);
742
  msg.base_version = serverVersion;
743
- msg.user_id = myUserId;
744
  pendingQueue.push(msg);
745
  }
746
  flushPending();
@@ -755,16 +515,17 @@
755
  setSaveIndicator('saving');
756
  }
757
 
758
- // ── WebSocket ─────────────────────────────────────────────────────────────
759
  function connect() {
760
- docId = getDocId();
761
  myUserId = getUserId();
762
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
763
- const url = `${proto}://${location.host}/ws/${docId}?user_id=${myUserId}`;
764
  ws = new WebSocket(url);
765
- ws.onopen = () => { setStatus('connected', 'Connected'); reconnectAttempts = 0; startHeartbeat(); };
766
- ws.onclose = () => { setStatus('', 'Reconnecting…'); stopHeartbeat(); scheduleReconnect(); };
767
- ws.onmessage = ({ data }) => { try { handleMessage(JSON.parse(data)); } catch (e) { console.error('[WS] parse error', e); } };
 
768
  }
769
 
770
  function scheduleReconnect() {
@@ -774,65 +535,93 @@
774
  reconnectTimer = setTimeout(connect, delay);
775
  }
776
 
777
- // ── Message dispatch ──────────────────────────────────────────────────────
778
  function handleMessage(msg) {
779
  switch (msg.type) {
780
- case 'init': onInit(msg); break;
781
- case 'operation': onRemoteOp(msg); break;
782
- case 'ack': onAck(msg); break;
783
- case 'cursor': onRemoteCursor(msg); break;
784
- case 'user_joined': onUserJoined(msg); break;
785
- case 'user_left': onUserLeft(msg); break;
786
- case 'title_change': onTitleChange(msg); break;
 
787
  }
788
  }
789
 
790
  function onInit(msg) {
791
- myUserId = msg.user_id; myName = msg.name; myColor = msg.color;
792
- serverVersion = msg.doc_state.version;
793
- isApplyingRemote = true;
794
- editor.value = msg.doc_state.content;
795
- prevContent = msg.doc_state.content;
796
- isApplyingRemote = false;
797
- docTitleEl.value = msg.doc_state.title || 'Untitled Document';
798
- document.title = `${docTitleEl.value} β€” CollabDocs`;
 
 
799
  updateVersionChip();
800
  renderAvatars(msg.users || []);
801
  }
802
 
 
 
 
 
 
 
803
  function onRemoteOp(msg) {
804
  if (msg.user_id === myUserId) return;
 
805
  serverVersion = msg.server_version;
806
  updateVersionChip();
807
 
808
- // MISSING LINK: Transform remote op against our un-acked local ops
809
- let transformedRemote = { ...msg };
 
 
 
 
 
 
 
 
 
 
810
  if (inFlight) {
811
- transformedRemote = transformOperation(transformedRemote, inFlight);
 
812
  }
 
813
  for (let i = 0; i < pendingQueue.length; i++) {
814
- const opInQueue = pendingQueue[i];
815
- transformedRemote = transformOperation(transformedRemote, opInQueue);
816
- pendingQueue[i] = transformOperation(opInQueue, transformedRemote);
 
 
 
817
  }
818
 
 
819
  const savedStart = editor.selectionStart;
820
- const savedEnd = editor.selectionEnd;
821
 
822
  isApplyingRemote = true;
823
- editor.value = applyOpToString(editor.value, transformedRemote);
824
- prevContent = editor.value;
825
  isApplyingRemote = false;
826
 
827
- const newStart = shiftCursor(savedStart, transformedRemote);
828
- const newEnd = shiftCursor(savedEnd, transformedRemote);
829
- editor.setSelectionRange(newStart, newEnd);
 
 
830
  showTyping(msg.user_id, remoteCursors[msg.user_id]?.name || 'Someone');
831
  }
832
 
833
  function onAck(msg) {
834
- if (inFlight && (inFlight.op_id === msg.op_id)) {
835
- inFlight = null;
836
  serverVersion = msg.server_version;
837
  updateVersionChip();
838
  setSaveIndicator('saved');
@@ -852,7 +641,7 @@
852
  }
853
 
854
  function onUserLeft(msg) {
855
- toast(`${msg.name} left`, '#999');
856
  removeCursor(msg.user_id);
857
  delete remoteCursors[msg.user_id];
858
  renderAvatars(msg.users || []);
@@ -860,34 +649,33 @@
860
 
861
  function onTitleChange(msg) {
862
  docTitleEl.value = msg.title;
863
- document.title = `${msg.title} β€” CollabDocs`;
864
  }
865
 
866
- // ── Apply remote op to string ──────────────────────────────────────────────
867
  function applyOpToString(content, op) {
868
- const pos = Math.max(0, Math.min(op.position, content.length));
869
- if (op.op_type === 'insert') {
 
870
  return content.slice(0, pos) + op.value + content.slice(pos);
871
- }
872
- if (op.op_type === 'delete') {
873
- const end = Math.max(0, Math.min(pos + op.length, content.length));
874
  return content.slice(0, pos) + content.slice(end);
875
  }
876
- return content;
877
  }
878
 
879
  function shiftCursor(cursor, op) {
880
- if (op.op_type === 'insert') {
881
  if (op.position <= cursor) return cursor + op.value.length;
882
- } else if (op.op_type === 'delete') {
883
  const delEnd = op.position + op.length;
884
- if (delEnd <= cursor) return cursor - op.length;
885
- if (op.position <= cursor) return op.position;
886
  }
887
  return cursor;
888
  }
889
 
890
- // ── Editor input ──────────────────────────────────────────────────────────
891
  editor.addEventListener('input', () => {
892
  if (isApplyingRemote) return;
893
  const newContent = editor.value;
@@ -897,18 +685,19 @@
897
  debounceSendCursor();
898
  });
899
 
900
- editor.addEventListener('keyup', debounceSendCursor);
901
- editor.addEventListener('click', debounceSendCursor);
902
- editor.addEventListener('mouseup', debounceSendCursor);
903
- editor.addEventListener('select', debounceSendCursor);
904
 
905
  let cursorTimer = null;
906
  function debounceSendCursor() { clearTimeout(cursorTimer); cursorTimer = setTimeout(sendCursor, 30); }
907
  function sendCursor() {
908
- send({ type: 'cursor', cursor_pos: editor.selectionStart, selection_start: editor.selectionStart, selection_end: editor.selectionEnd });
 
909
  }
910
 
911
- // ── Title ─────────────────────────────────────────────────────────────────
912
  let titleTimer = null;
913
  docTitleEl.addEventListener('input', () => {
914
  clearTimeout(titleTimer);
@@ -918,7 +707,7 @@
918
  }, 300);
919
  });
920
 
921
- // ── Cursor rendering ──────────────────────────────────────────────────────
922
  let mirrorEl = null;
923
  function getMirror() {
924
  if (!mirrorEl) {
@@ -937,23 +726,23 @@
937
  return mirrorEl;
938
  }
939
 
940
- const resizeObserver = new ResizeObserver(() => {
941
  if (mirrorEl) mirrorEl.style.width = editor.clientWidth + 'px';
942
- for (const [uid, c] of Object.entries(remoteCursors)) renderRemoteCursor(uid, c.pos, c.name, c.color);
943
- });
944
- resizeObserver.observe(editor);
945
 
946
  function getCharCoords(charIndex) {
947
- const mirror = getMirror();
948
- const text = editor.value.slice(0, charIndex);
949
  mirror.innerHTML = '';
950
  mirror.appendChild(document.createTextNode(text));
951
  const span = document.createElement('span');
952
  span.textContent = '\u200b';
953
  mirror.appendChild(span);
954
  const editorRect = editor.getBoundingClientRect();
955
- const pageRect = document.getElementById('page').getBoundingClientRect();
956
- const spanRect = span.getBoundingClientRect();
957
  const mirrorRect = mirror.getBoundingClientRect();
958
  return {
959
  x: spanRect.left - mirrorRect.left + (editorRect.left - pageRect.left),
@@ -965,12 +754,15 @@
965
  const coords = getCharCoords(charPos);
966
  let el = cursorElems[userId];
967
  if (!el) {
968
- el = document.createElement('div'); el.className = 'r-cursor';
969
- el.innerHTML = `<div class="r-cursor-label" style="background:${esc(color)}">${esc(name)}</div><div class="r-cursor-caret" style="background:${esc(color)}"></div>`;
 
 
970
  document.getElementById('cursor-layer').appendChild(el);
971
  cursorElems[userId] = el;
972
  }
973
- el.style.left = `${coords.x}px`; el.style.top = `${coords.y}px`;
 
974
  el.querySelector('.r-cursor-label').style.background = color;
975
  el.querySelector('.r-cursor-caret').style.background = color;
976
  el.querySelector('.r-cursor-label').textContent = name;
@@ -978,26 +770,35 @@
978
 
979
  function removeCursor(userId) { cursorElems[userId]?.remove(); delete cursorElems[userId]; }
980
 
981
- // ── Avatars ────────────────────────────────────────────────────────────────
982
  function renderAvatars(users) {
983
  userAvatars.innerHTML = '';
984
  const MAX = 5;
985
  users.slice(0, MAX).forEach(u => {
986
- const div = document.createElement('div'); div.className = 'avatar';
987
- div.style.background = u.color; div.textContent = (u.name || '?')[0];
988
- if (u.user_id === myUserId) { div.style.outline = `2px solid ${u.color}`; div.style.outlineOffset = '1px'; }
989
- const tip = document.createElement('div'); tip.className = 'avatar-tip';
990
- tip.textContent = u.user_id === myUserId ? `${u.name} (you)` : u.name;
991
- div.appendChild(tip); userAvatars.appendChild(div);
 
 
 
 
 
 
 
992
  });
993
  if (users.length > MAX) {
994
- const more = document.createElement('div'); more.className = 'avatar';
995
- more.style.background = '#888'; more.textContent = `+${users.length - MAX}`;
 
 
996
  userAvatars.appendChild(more);
997
  }
998
  }
999
 
1000
- // ── Typing activity ────────────────────────────────────────────────────────
1001
  function showTyping(userId, name) {
1002
  typingUsers[userId] = name;
1003
  clearTimeout(typingTimers[userId]);
@@ -1007,57 +808,70 @@
1007
 
1008
  function updateActivityBar() {
1009
  const names = Object.values(typingUsers);
1010
- if (names.length === 0) { activityBar.classList.add('hidden'); return; }
1011
  activityBar.classList.remove('hidden');
1012
- activityText.textContent = names.length === 1 ? `${names[0]} is typing` : `${names.slice(0, -1).join(', ')} & ${names.at(-1)} are typing`;
 
 
1013
  }
1014
 
 
1015
  function toast(text, color) {
1016
- const div = document.createElement('div'); div.className = 'toast';
 
1017
  div.innerHTML = `<div class="toast-dot" style="background:${esc(color)}"></div><span>${esc(text)}</span>`;
1018
- toastsEl.appendChild(div); setTimeout(() => div.remove(), 3200);
 
1019
  }
1020
 
 
1021
  function setStatus(state, text) {
1022
- statusBadge.className = state ? `${state}` : '';
1023
  statusText.textContent = text;
1024
  statusDot.className = 'status-dot' + (state === 'connected' ? ' pulse' : '');
1025
  }
1026
 
1027
  function updateVersionChip() { versionChip.textContent = `v${serverVersion}`; }
1028
 
1029
- let saveTimer = null;
1030
  function setSaveIndicator(state) {
1031
- clearTimeout(saveTimer);
1032
  if (state === 'saving') { saveIndicator.textContent = 'Saving…'; saveIndicator.className = 'saving'; }
1033
- else { saveIndicator.textContent = 'Saved'; saveIndicator.className = ''; }
1034
  }
1035
 
1036
  let heartbeatId = null;
1037
  function startHeartbeat() { clearInterval(heartbeatId); heartbeatId = setInterval(() => send({ type: 'ping' }), 10000); }
1038
- function stopHeartbeat() { clearInterval(heartbeatId); }
1039
- function send(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }
 
 
 
 
 
 
1040
 
 
1041
  shareBtn.addEventListener('click', () => {
1042
- const url = `${location.origin}${location.pathname}?doc=${docId}`;
1043
- shareLinkInput.value = url; shareModal.classList.add('open'); shareLinkInput.select();
 
1044
  });
1045
  modalClose.addEventListener('click', () => shareModal.classList.remove('open'));
1046
  shareModal.addEventListener('click', e => { if (e.target === shareModal) shareModal.classList.remove('open'); });
1047
  copyBtn.addEventListener('click', () => {
1048
- navigator.clipboard.writeText(shareLinkInput.value).then(() => {
1049
- copyBtn.textContent = 'βœ“ Copied'; copyBtn.classList.add('copied');
 
1050
  setTimeout(() => { copyBtn.textContent = 'Copy'; copyBtn.classList.remove('copied'); }, 2000);
1051
  });
1052
  });
1053
 
1054
  newDocBtn.addEventListener('click', async () => {
1055
- const res = await fetch('/api/docs', { method: 'POST' });
1056
- const data = await res.json(); window.location.href = data.url;
 
1057
  });
1058
 
1059
- function esc(str) { return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
1060
-
1061
  connect();
1062
  })();
1063
  </script>
 
84
  letter-spacing: 0.5px;
85
  }
86
 
87
+ .toolbar-divider { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
 
 
 
 
 
88
 
89
  #doc-title {
90
  flex: 1;
 
100
  border-radius: var(--radius-sm);
101
  transition: background 0.15s, border-color 0.15s;
102
  }
 
103
  #doc-title:hover { background: var(--surface2); border-color: var(--border); }
104
  #doc-title:focus { background: var(--surface2); border-color: var(--border-strong); }
105
 
106
+ .toolbar-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
 
 
 
 
 
107
 
 
108
  #version-chip {
109
+ font-size: 11px; font-weight: 500; color: var(--text-light);
110
+ background: var(--surface2); border: 1px solid var(--border);
111
+ border-radius: 20px; padding: 3px 9px; white-space: nowrap;
 
 
 
 
 
112
  font-variant-numeric: tabular-nums;
113
  }
114
 
 
115
  #status-badge {
116
+ display: flex; align-items: center; gap: 5px;
117
+ font-size: 12px; font-weight: 500;
118
+ padding: 4px 10px; border-radius: 20px;
119
+ border: 1px solid var(--border); background: var(--surface2);
120
+ color: var(--text-muted); transition: all 0.25s; white-space: nowrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  }
122
+ #status-badge.connected { color: var(--accent); background: var(--accent-dim); border-color: #A8DDB8; }
123
+ #status-badge.error { color: var(--danger); background: #FDEAEA; border-color: #EAB0B0; }
124
 
125
+ .status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
126
  .status-dot.pulse { animation: dot-pulse 2s ease-in-out infinite; }
127
 
128
  @keyframes dot-pulse {
129
  0%, 100% { opacity: 1; transform: scale(1); }
130
+ 50% { opacity: 0.5; transform: scale(0.85); }
131
  }
132
 
133
+ #user-avatars { display: flex; align-items: center; }
 
 
 
 
134
 
135
  .avatar {
136
+ width: 28px; height: 28px; border-radius: 50%;
 
137
  display: flex; align-items: center; justify-content: center;
138
+ font-size: 11px; font-weight: 700; color: white;
139
+ border: 2px solid var(--surface); margin-left: -7px;
140
+ cursor: default; position: relative;
 
 
 
 
141
  transition: transform 0.15s, z-index 0s;
142
+ box-shadow: 0 1px 4px rgba(0,0,0,0.15); text-transform: uppercase;
 
143
  }
 
144
  .avatar:first-child { margin-left: 0; }
145
+ .avatar:hover { transform: translateY(-2px); z-index: 20; }
 
 
 
 
146
 
147
  .avatar-tip {
148
+ position: absolute; bottom: calc(100% + 7px); left: 50%;
149
+ transform: translateX(-50%); background: var(--text); color: #fff;
150
+ font-size: 11px; font-weight: 500; padding: 3px 8px; border-radius: 4px;
151
+ white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.15s;
 
 
 
 
 
 
 
 
 
 
152
  }
 
153
  .avatar-tip::after {
154
+ content: ''; position: absolute; top: 100%; left: 50%;
155
+ transform: translateX(-50%); border: 4px solid transparent;
 
 
 
 
156
  border-top-color: var(--text);
157
  }
 
158
  .avatar:hover .avatar-tip { opacity: 1; }
159
 
 
160
  .btn {
161
+ display: inline-flex; align-items: center; gap: 5px;
162
+ padding: 6px 12px; border-radius: var(--radius-sm);
163
+ font-family: var(--font-sans); font-size: 13px; font-weight: 500;
164
+ cursor: pointer; border: 1px solid transparent;
165
+ transition: all 0.15s; white-space: nowrap; line-height: 1;
 
 
 
 
 
 
 
 
166
  }
167
+ .btn-ghost { background: var(--surface2); border-color: var(--border); color: var(--text-muted); }
 
 
 
 
 
 
168
  .btn-ghost:hover { background: var(--surface3); color: var(--text); }
169
+ .btn-primary { background: var(--accent); color: white; }
170
+ .btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: var(--shadow); }
 
 
 
 
 
 
 
 
 
 
171
  .btn-primary:active { transform: translateY(0); box-shadow: none; }
172
 
173
  /* ── Main layout ── */
174
+ #main { flex: 1; display: flex; justify-content: center; padding: 40px 20px 80px; }
175
+ #page-wrap { width: 100%; max-width: 800px; }
 
 
 
 
176
 
 
 
 
 
 
 
177
  #page {
178
+ background: var(--surface); border-radius: 2px;
179
+ box-shadow: var(--shadow-page); position: relative; min-height: 80vh;
 
 
 
180
  }
181
 
 
182
  #cursor-layer {
183
+ position: absolute; inset: 0; pointer-events: none;
184
+ overflow: hidden; z-index: 5; border-radius: 2px;
 
 
 
 
185
  }
186
 
187
+ .r-cursor { position: absolute; pointer-events: none; will-change: left, top; }
188
+ .r-cursor-caret { width: 2px; height: 20px; border-radius: 1px; animation: caret-blink 1.1s ease-in-out infinite; }
 
 
 
 
 
 
 
 
 
 
189
 
190
  @keyframes caret-blink {
191
  0%, 45%, 100% { opacity: 1; }
192
+ 55%, 90% { opacity: 0; }
193
  }
194
 
195
  .r-cursor-label {
196
+ position: absolute; top: -19px; left: 0;
197
+ font-size: 10px; font-weight: 600; color: white;
198
+ padding: 2px 6px; border-radius: 3px 3px 3px 0; white-space: nowrap;
199
+ font-family: var(--font-sans); line-height: 1.4;
200
+ box-shadow: 0 1px 4px rgba(0,0,0,0.2); letter-spacing: 0.2px;
 
 
 
 
 
 
 
 
201
  }
202
 
 
203
  #editor {
204
+ width: 100%; min-height: 80vh; padding: 64px 88px;
205
+ font-family: var(--font-serif); font-size: 16.5px; line-height: 1.85;
206
+ color: var(--text); border: none; outline: none; resize: none;
207
+ background: transparent; position: relative; z-index: 10;
208
+ white-space: pre-wrap; word-wrap: break-word;
209
+ caret-color: var(--accent); -webkit-font-smoothing: antialiased;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  }
211
+ #editor::placeholder { color: var(--text-light); font-style: italic; }
212
+ #editor::selection { background: rgba(26, 92, 58, 0.15); }
213
 
 
214
  #activity-bar {
215
+ position: fixed; bottom: 18px; left: 18px;
216
+ background: var(--surface); border: 1px solid var(--border);
217
+ border-radius: var(--radius); padding: 7px 12px;
218
+ font-size: 12px; color: var(--text-muted); box-shadow: var(--shadow);
219
+ display: flex; align-items: center; gap: 7px;
220
+ opacity: 1; transform: translateY(0); transition: opacity 0.2s, transform 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  }
222
+ #activity-bar.hidden { opacity: 0; transform: translateY(4px); pointer-events: none; }
223
 
224
+ .typing-dots { display: flex; gap: 3px; align-items: center; }
225
  .typing-dot {
226
+ width: 4px; height: 4px; border-radius: 50%;
227
+ background: var(--accent-light); animation: typing-bounce 1.3s ease-in-out infinite;
 
 
228
  }
 
229
  .typing-dot:nth-child(2) { animation-delay: 0.16s; }
230
  .typing-dot:nth-child(3) { animation-delay: 0.32s; }
231
 
232
  @keyframes typing-bounce {
233
  0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
234
+ 30% { transform: translateY(-4px); opacity: 1; }
235
  }
236
 
 
237
  #toasts {
238
+ position: fixed; bottom: 18px; right: 18px;
239
+ display: flex; flex-direction: column; gap: 8px;
240
+ z-index: 1000; pointer-events: none;
 
 
 
 
 
241
  }
 
242
  .toast {
243
+ background: var(--surface); border: 1px solid var(--border);
244
+ border-radius: var(--radius); padding: 9px 14px; font-size: 13px;
245
+ box-shadow: var(--shadow-lg); display: flex; align-items: center;
246
+ gap: 8px; max-width: 260px; animation: toast-in 0.25s ease;
 
 
 
 
 
 
 
247
  }
 
248
  @keyframes toast-in {
249
  from { transform: translateX(16px); opacity: 0; }
250
+ to { transform: translateX(0); opacity: 1; }
 
 
 
 
 
 
251
  }
252
+ .toast-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
253
 
 
254
  #share-modal {
255
+ display: none; position: fixed; inset: 0;
256
+ background: rgba(0,0,0,0.35); z-index: 500;
257
+ align-items: center; justify-content: center; backdrop-filter: blur(3px);
 
 
 
 
 
258
  }
 
259
  #share-modal.open { display: flex; }
260
 
261
  .modal {
262
+ background: var(--surface); border-radius: var(--radius-lg);
263
+ padding: 28px; width: 440px; max-width: 92vw;
 
 
 
264
  box-shadow: var(--shadow-lg);
265
  animation: modal-pop 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
266
  }
 
267
  @keyframes modal-pop {
268
  from { transform: scale(0.93); opacity: 0; }
269
+ to { transform: scale(1); opacity: 1; }
270
+ }
271
+ .modal-title { font-family: var(--font-serif); font-size: 21px; margin-bottom: 6px; color: var(--text); }
272
+ .modal-desc { font-size: 13.5px; color: var(--text-muted); margin-bottom: 18px; line-height: 1.6; }
273
+ .link-row { display: flex; gap: 8px; margin-bottom: 14px; }
274
+ .link-input {
275
+ flex: 1; padding: 9px 11px; border: 1px solid var(--border-strong);
276
+ border-radius: var(--radius-sm); font-size: 12.5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  font-family: 'SF Mono', 'Fira Code', monospace;
278
+ background: var(--surface2); color: var(--text-muted); outline: none;
279
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
 
 
 
 
280
  }
 
281
  .btn-copy { min-width: 72px; justify-content: center; }
282
  .btn-copy.copied { background: var(--accent-light); }
283
 
 
284
  @media (max-width: 640px) {
285
  #editor { padding: 32px 24px; font-size: 15px; }
286
+ .logo-badge { display: none; }
287
  #version-chip { display: none; }
288
  }
289
 
290
+ #save-indicator { font-size: 11.5px; color: var(--text-light); transition: opacity 0.3s; }
 
 
 
 
 
 
291
  #save-indicator.saving { color: var(--accent); }
292
  </style>
293
  </head>
 
344
  </div>
345
 
346
  <script>
347
+ // ══════════════════════════════════════════════════════════════════════════════
348
+ // CollabDocs β€” Client (v2)
349
+ // Changes:
350
+ // β€’ handles "batch" message type from server (op batching)
351
+ // β€’ fixed OT transform loop: pending queue ops must be re-transformed
352
+ // against each other correctly (diamond property)
353
+ // β€’ trimmed duplicate op_type field detection
354
+ // ══════════════════════════════════════════════════════════════════════════════
355
 
356
  (function () {
357
  'use strict';
358
 
359
+ // ── State ────────────────────────────────────────────────────────────────────
360
  let ws = null;
361
+ let myUserId = null;
362
+ let myName = null;
363
+ let myColor = null;
364
+ let docId = null;
365
 
366
  let serverVersion = 0;
367
+ let inFlight = null; // op currently waiting for ack { op, op_id, ... }
368
+ let pendingQueue = []; // ops queued behind inFlight
369
 
370
  let isApplyingRemote = false;
371
+ let prevContent = '';
372
 
373
  const remoteCursors = {};
374
+ const cursorElems = {};
375
+ const typingUsers = {};
376
+ const typingTimers = {};
 
377
 
378
  let reconnectAttempts = 0;
379
+ let reconnectTimer = null;
380
+
381
+ // ── DOM ──────────────────────────────────────────────────────────────────────
382
+ const editor = document.getElementById('editor');
383
+ const statusBadge = document.getElementById('status-badge');
384
+ const statusText = document.getElementById('status-text');
385
+ const statusDot = document.querySelector('.status-dot');
386
+ const userAvatars = document.getElementById('user-avatars');
387
+ const shareBtn = document.getElementById('share-btn');
388
+ const shareModal = document.getElementById('share-modal');
389
+ const shareLinkEl = document.getElementById('share-link');
390
+ const copyBtn = document.getElementById('copy-btn');
391
+ const modalClose = document.getElementById('modal-close');
392
+ const newDocBtn = document.getElementById('new-doc-btn');
393
+ const docTitleEl = document.getElementById('doc-title');
394
+ const versionChip = document.getElementById('version-chip');
395
+ const toastsEl = document.getElementById('toasts');
396
+ const activityBar = document.getElementById('activity-bar');
397
  const activityText = document.getElementById('activity-text');
398
+ const saveIndicator= document.getElementById('save-indicator');
399
+
400
+ // ── OT pairwise transforms ───────────────────────────────────────────────────
401
 
402
+ function isInsert(op) { return (op.op_type || op.type) === 'insert'; }
403
 
404
  function transform_ii(op, against) {
405
+ const r = { ...op };
406
  if (against.position < op.position) {
407
+ r.position += against.value.length;
408
  } else if (against.position === op.position) {
409
+ if (against.user_id <= op.user_id) r.position += against.value.length;
 
410
  }
411
+ return r;
412
  }
413
 
414
  function transform_id(op, against) {
415
+ // op = insert, against = delete
416
+ const r = { ...op };
417
  const del_start = against.position;
418
+ const del_end = against.position + against.length;
419
+ if (del_end <= op.position) r.position -= against.length;
420
+ else if (del_start < op.position) r.position = del_start;
421
+ return r;
 
 
 
422
  }
423
 
424
  function transform_di(op, against) {
425
+ // op = delete, against = insert
426
+ const r = { ...op };
427
  const ins_pos = against.position;
428
  const ins_len = against.value.length;
429
  const del_end = op.position + op.length;
430
+ if (ins_pos < op.position) r.position += ins_len;
431
+ else if (ins_pos <= del_end) r.length += ins_len;
432
+ return r;
 
 
 
433
  }
434
 
435
  function transform_dd(op, against) {
436
+ const r = { ...op };
437
  const op_start = op.position;
438
+ const op_end = op.position + op.length;
439
  const ag_start = against.position;
440
+ const ag_end = against.position + against.length;
441
  if (ag_end <= op_start) {
442
+ r.position -= against.length;
443
+ } else if (ag_start < op_end) {
444
+ const overlap = Math.min(op_end, ag_end) - Math.max(op_start, ag_start);
445
+ if (ag_start < op_start) r.position = ag_start;
446
+ r.length = Math.max(0, op.length - overlap);
 
 
 
 
447
  }
448
+ return r;
449
  }
450
 
451
+ function transformOp(incoming, applied) {
452
+ if (isInsert(incoming)) {
453
+ return isInsert(applied) ? transform_ii(incoming, applied)
454
+ : transform_id(incoming, applied);
455
  } else {
456
+ return isInsert(applied) ? transform_di(incoming, applied)
457
+ : transform_dd(incoming, applied);
458
  }
459
  }
460
 
461
+ // ── Routing ──────────────────────────────────────────────────────────────────
462
  function getDocId() {
463
  return new URLSearchParams(window.location.search).get('doc') || 'welcome';
464
  }
 
472
  return id;
473
  }
474
 
475
+ // ── Myers diff (simple LCS-based) ────────────────────────────────────────────
476
  function myersDiff(oldStr, newStr) {
477
  const m = oldStr.length, n = newStr.length;
478
  if (m === 0 && n === 0) return [];
479
  if (m === 0) return [{ type: 'insert', pos: 0, text: newStr }];
480
+ if (n === 0) return [{ type: 'delete', pos: 0, len: m }];
481
+
482
  let p = 0;
483
  while (p < m && p < n && oldStr[p] === newStr[p]) p++;
484
  let os = m - 1, ns = n - 1;
485
  while (os >= p && ns >= p && oldStr[os] === newStr[ns]) { os--; ns--; }
486
+
487
+ const deletedLen = os - p + 1;
488
  const insertedStr = newStr.slice(p, ns + 1);
489
  const ops = [];
490
+ if (deletedLen > 0) ops.push({ type: 'delete', pos: p, len: deletedLen });
491
  if (insertedStr.length > 0) ops.push({ type: 'insert', pos: p, text: insertedStr });
492
  return ops;
493
  }
494
 
495
+ // ── OT send pipeline ─────────────────────────────────────────────────────────
496
  function composeAndSend(diffOps) {
497
  for (const d of diffOps) {
498
  const msg = d.type === 'insert'
499
  ? { op_type: 'insert', position: d.pos, value: d.text, length: d.text.length }
500
+ : { op_type: 'delete', position: d.pos, value: '', length: d.len };
501
+ msg.op_id = Math.random().toString(36).slice(2, 11);
502
  msg.base_version = serverVersion;
503
+ msg.user_id = myUserId;
504
  pendingQueue.push(msg);
505
  }
506
  flushPending();
 
515
  setSaveIndicator('saving');
516
  }
517
 
518
+ // ── WebSocket ─────────────────────────────────────────────────────────────────
519
  function connect() {
520
+ docId = getDocId();
521
  myUserId = getUserId();
522
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
523
+ const url = `${proto}://${location.host}/ws/${docId}?user_id=${myUserId}`;
524
  ws = new WebSocket(url);
525
+ ws.onopen = () => { setStatus('connected', 'Connected'); reconnectAttempts = 0; startHeartbeat(); };
526
+ ws.onclose = () => { setStatus('', 'Reconnecting…'); stopHeartbeat(); scheduleReconnect(); };
527
+ ws.onmessage = ({ data }) => { try { handleMessage(JSON.parse(data)); } catch (e) { console.error('[WS]', e); } };
528
+ ws.onerror = (e) => console.error('[WS] error', e);
529
  }
530
 
531
  function scheduleReconnect() {
 
535
  reconnectTimer = setTimeout(connect, delay);
536
  }
537
 
538
+ // ── Message dispatch ──────────────────────────────────────────────────────────
539
  function handleMessage(msg) {
540
  switch (msg.type) {
541
+ case 'init': onInit(msg); break;
542
+ case 'operation': onRemoteOp(msg); break;
543
+ case 'batch': onBatch(msg); break; // NEW: server batching
544
+ case 'ack': onAck(msg); break;
545
+ case 'cursor': onRemoteCursor(msg); break;
546
+ case 'user_joined': onUserJoined(msg); break;
547
+ case 'user_left': onUserLeft(msg); break;
548
+ case 'title_change': onTitleChange(msg); break;
549
  }
550
  }
551
 
552
  function onInit(msg) {
553
+ myUserId = msg.user_id;
554
+ myName = msg.name;
555
+ myColor = msg.color;
556
+ serverVersion = msg.doc_state.version;
557
+ isApplyingRemote = true;
558
+ editor.value = msg.doc_state.content;
559
+ prevContent = msg.doc_state.content;
560
+ isApplyingRemote = false;
561
+ docTitleEl.value = msg.doc_state.title || 'Untitled Document';
562
+ document.title = `${docTitleEl.value} β€” CollabDocs`;
563
  updateVersionChip();
564
  renderAvatars(msg.users || []);
565
  }
566
 
567
+ // Handle a batch of ops from the server (reduces message count ~15-20Γ—)
568
+ function onBatch(msg) {
569
+ if (!Array.isArray(msg.ops)) return;
570
+ for (const op of msg.ops) onRemoteOp(op);
571
+ }
572
+
573
  function onRemoteOp(msg) {
574
  if (msg.user_id === myUserId) return;
575
+
576
  serverVersion = msg.server_version;
577
  updateVersionChip();
578
 
579
+ // ── Transform remote op against our un-acked local ops ───────────────────
580
+ // This implements the client-side of the OT diamond property:
581
+ // remote must be transformed against everything the server hasn't seen yet.
582
+ //
583
+ // inFlight: sent to server but not acked yet
584
+ // pendingQueue: queued locally, not yet sent
585
+ //
586
+ // After transforming the remote op, we also need to re-transform our
587
+ // local ops so they remain correct relative to the (now updated) server state.
588
+
589
+ let remoteT = { ...msg };
590
+
591
  if (inFlight) {
592
+ remoteT = transformOp(remoteT, inFlight);
593
+ // inFlight doesn't change β€” the server will transform it server-side
594
  }
595
+
596
  for (let i = 0; i < pendingQueue.length; i++) {
597
+ const local = pendingQueue[i];
598
+ // Transform remote against local
599
+ const remoteAgainstLocal = transformOp(remoteT, local);
600
+ // Transform local against (original) remote β€” keeps local consistent
601
+ pendingQueue[i] = transformOp(local, remoteT);
602
+ remoteT = remoteAgainstLocal;
603
  }
604
 
605
+ // ── Apply to editor ───────────────────────────────────────────────────────
606
  const savedStart = editor.selectionStart;
607
+ const savedEnd = editor.selectionEnd;
608
 
609
  isApplyingRemote = true;
610
+ editor.value = applyOpToString(editor.value, remoteT);
611
+ prevContent = editor.value;
612
  isApplyingRemote = false;
613
 
614
+ editor.setSelectionRange(
615
+ shiftCursor(savedStart, remoteT),
616
+ shiftCursor(savedEnd, remoteT)
617
+ );
618
+
619
  showTyping(msg.user_id, remoteCursors[msg.user_id]?.name || 'Someone');
620
  }
621
 
622
  function onAck(msg) {
623
+ if (inFlight && inFlight.op_id === msg.op_id) {
624
+ inFlight = null;
625
  serverVersion = msg.server_version;
626
  updateVersionChip();
627
  setSaveIndicator('saved');
 
641
  }
642
 
643
  function onUserLeft(msg) {
644
+ toast(`${msg.name} left`, msg.color || '#999');
645
  removeCursor(msg.user_id);
646
  delete remoteCursors[msg.user_id];
647
  renderAvatars(msg.users || []);
 
649
 
650
  function onTitleChange(msg) {
651
  docTitleEl.value = msg.title;
652
+ document.title = `${msg.title} β€” CollabDocs`;
653
  }
654
 
655
+ // ── String apply / cursor shift ───────────────────────────────────────────────
656
  function applyOpToString(content, op) {
657
+ const len = content.length;
658
+ const pos = Math.max(0, Math.min(op.position, len));
659
+ if (isInsert(op)) {
660
  return content.slice(0, pos) + op.value + content.slice(pos);
661
+ } else {
662
+ const end = Math.max(0, Math.min(pos + op.length, len));
 
663
  return content.slice(0, pos) + content.slice(end);
664
  }
 
665
  }
666
 
667
  function shiftCursor(cursor, op) {
668
+ if (isInsert(op)) {
669
  if (op.position <= cursor) return cursor + op.value.length;
670
+ } else {
671
  const delEnd = op.position + op.length;
672
+ if (delEnd <= cursor) return cursor - op.length;
673
+ if (op.position <= cursor) return op.position;
674
  }
675
  return cursor;
676
  }
677
 
678
+ // ── Editor input ──────────────────────────────────────────────────────────────
679
  editor.addEventListener('input', () => {
680
  if (isApplyingRemote) return;
681
  const newContent = editor.value;
 
685
  debounceSendCursor();
686
  });
687
 
688
+ editor.addEventListener('keyup', debounceSendCursor);
689
+ editor.addEventListener('click', debounceSendCursor);
690
+ editor.addEventListener('mouseup', debounceSendCursor);
691
+ editor.addEventListener('select', debounceSendCursor);
692
 
693
  let cursorTimer = null;
694
  function debounceSendCursor() { clearTimeout(cursorTimer); cursorTimer = setTimeout(sendCursor, 30); }
695
  function sendCursor() {
696
+ send({ type: 'cursor', cursor_pos: editor.selectionStart,
697
+ selection_start: editor.selectionStart, selection_end: editor.selectionEnd });
698
  }
699
 
700
+ // ── Title ──────────────────────────────────────────────────────────────────────
701
  let titleTimer = null;
702
  docTitleEl.addEventListener('input', () => {
703
  clearTimeout(titleTimer);
 
707
  }, 300);
708
  });
709
 
710
+ // ── Remote cursor rendering ────────────────────────────────────────────────────
711
  let mirrorEl = null;
712
  function getMirror() {
713
  if (!mirrorEl) {
 
726
  return mirrorEl;
727
  }
728
 
729
+ new ResizeObserver(() => {
730
  if (mirrorEl) mirrorEl.style.width = editor.clientWidth + 'px';
731
+ for (const [uid, c] of Object.entries(remoteCursors))
732
+ renderRemoteCursor(uid, c.pos, c.name, c.color);
733
+ }).observe(editor);
734
 
735
  function getCharCoords(charIndex) {
736
+ const mirror = getMirror();
737
+ const text = editor.value.slice(0, charIndex);
738
  mirror.innerHTML = '';
739
  mirror.appendChild(document.createTextNode(text));
740
  const span = document.createElement('span');
741
  span.textContent = '\u200b';
742
  mirror.appendChild(span);
743
  const editorRect = editor.getBoundingClientRect();
744
+ const pageRect = document.getElementById('page').getBoundingClientRect();
745
+ const spanRect = span.getBoundingClientRect();
746
  const mirrorRect = mirror.getBoundingClientRect();
747
  return {
748
  x: spanRect.left - mirrorRect.left + (editorRect.left - pageRect.left),
 
754
  const coords = getCharCoords(charPos);
755
  let el = cursorElems[userId];
756
  if (!el) {
757
+ el = document.createElement('div');
758
+ el.className = 'r-cursor';
759
+ el.innerHTML = `<div class="r-cursor-label" style="background:${esc(color)}">${esc(name)}</div>`
760
+ + `<div class="r-cursor-caret" style="background:${esc(color)}"></div>`;
761
  document.getElementById('cursor-layer').appendChild(el);
762
  cursorElems[userId] = el;
763
  }
764
+ el.style.left = `${coords.x}px`;
765
+ el.style.top = `${coords.y}px`;
766
  el.querySelector('.r-cursor-label').style.background = color;
767
  el.querySelector('.r-cursor-caret').style.background = color;
768
  el.querySelector('.r-cursor-label').textContent = name;
 
770
 
771
  function removeCursor(userId) { cursorElems[userId]?.remove(); delete cursorElems[userId]; }
772
 
773
+ // ── Avatars ────────────────────────────────────────────────────────────────────
774
  function renderAvatars(users) {
775
  userAvatars.innerHTML = '';
776
  const MAX = 5;
777
  users.slice(0, MAX).forEach(u => {
778
+ const div = document.createElement('div');
779
+ div.className = 'avatar';
780
+ div.style.background = u.color;
781
+ div.textContent = (u.name || '?')[0];
782
+ if (u.user_id === myUserId) {
783
+ div.style.outline = `2px solid ${u.color}`;
784
+ div.style.outlineOffset = '1px';
785
+ }
786
+ const tip = document.createElement('div');
787
+ tip.className = 'avatar-tip';
788
+ tip.textContent = (u.user_id === myUserId) ? `${u.name} (you)` : u.name;
789
+ div.appendChild(tip);
790
+ userAvatars.appendChild(div);
791
  });
792
  if (users.length > MAX) {
793
+ const more = document.createElement('div');
794
+ more.className = 'avatar';
795
+ more.style.background = '#888';
796
+ more.textContent = `+${users.length - MAX}`;
797
  userAvatars.appendChild(more);
798
  }
799
  }
800
 
801
+ // ── Typing activity ────────────────────────────────────────────────────────────
802
  function showTyping(userId, name) {
803
  typingUsers[userId] = name;
804
  clearTimeout(typingTimers[userId]);
 
808
 
809
  function updateActivityBar() {
810
  const names = Object.values(typingUsers);
811
+ if (!names.length) { activityBar.classList.add('hidden'); return; }
812
  activityBar.classList.remove('hidden');
813
+ activityText.textContent = names.length === 1
814
+ ? `${names[0]} is typing`
815
+ : `${names.slice(0,-1).join(', ')} & ${names.at(-1)} are typing`;
816
  }
817
 
818
+ // ── Toasts ─────────────────────────────────────────────────────────────────────
819
  function toast(text, color) {
820
+ const div = document.createElement('div');
821
+ div.className = 'toast';
822
  div.innerHTML = `<div class="toast-dot" style="background:${esc(color)}"></div><span>${esc(text)}</span>`;
823
+ toastsEl.appendChild(div);
824
+ setTimeout(() => div.remove(), 3200);
825
  }
826
 
827
+ // ── UI helpers ─────────────────────────────────────────────────────────────────
828
  function setStatus(state, text) {
829
+ statusBadge.className = state || '';
830
  statusText.textContent = text;
831
  statusDot.className = 'status-dot' + (state === 'connected' ? ' pulse' : '');
832
  }
833
 
834
  function updateVersionChip() { versionChip.textContent = `v${serverVersion}`; }
835
 
 
836
  function setSaveIndicator(state) {
 
837
  if (state === 'saving') { saveIndicator.textContent = 'Saving…'; saveIndicator.className = 'saving'; }
838
+ else { saveIndicator.textContent = 'Saved'; saveIndicator.className = ''; }
839
  }
840
 
841
  let heartbeatId = null;
842
  function startHeartbeat() { clearInterval(heartbeatId); heartbeatId = setInterval(() => send({ type: 'ping' }), 10000); }
843
+ function stopHeartbeat() { clearInterval(heartbeatId); }
844
+ function send(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }
845
+
846
+ function esc(str) {
847
+ return String(str)
848
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;')
849
+ .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
850
+ }
851
 
852
+ // ── Toolbar buttons ────────────────────────────────────────────────────────────
853
  shareBtn.addEventListener('click', () => {
854
+ shareLinkEl.value = `${location.origin}${location.pathname}?doc=${docId}`;
855
+ shareModal.classList.add('open');
856
+ shareLinkEl.select();
857
  });
858
  modalClose.addEventListener('click', () => shareModal.classList.remove('open'));
859
  shareModal.addEventListener('click', e => { if (e.target === shareModal) shareModal.classList.remove('open'); });
860
  copyBtn.addEventListener('click', () => {
861
+ navigator.clipboard.writeText(shareLinkEl.value).then(() => {
862
+ copyBtn.textContent = 'βœ“ Copied';
863
+ copyBtn.classList.add('copied');
864
  setTimeout(() => { copyBtn.textContent = 'Copy'; copyBtn.classList.remove('copied'); }, 2000);
865
  });
866
  });
867
 
868
  newDocBtn.addEventListener('click', async () => {
869
+ const res = await fetch('/api/docs', { method: 'POST' });
870
+ const data = await res.json();
871
+ window.location.href = data.url;
872
  });
873
 
874
+ // ── Boot ───────────────────────────────────────────────────────────────────────
 
875
  connect();
876
  })();
877
  </script>