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

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +126 -200
index.html CHANGED
@@ -584,14 +584,7 @@
584
 
585
  <script>
586
  // ══════════════════════════════════════════════════════════════════════════
587
- // CollabDocs β€” Client
588
- //
589
- // Key fixes over original:
590
- // 1. Myers diff for accurate op generation (handles paste, multi-delete, undo)
591
- // 2. Cursor mirror is cached and reused β€” not created/destroyed per update
592
- // 3. Proper OT client state machine with in-flight + queued op separation
593
- // 4. Exponential backoff reconnect
594
- // 5. Cursor rendering uses ResizeObserver to invalidate mirror on resize
595
  // ══════════════════════════════════════════════════════════════════════════
596
 
597
  (function () {
@@ -604,11 +597,6 @@
604
  let myColor = null;
605
  let docId = null;
606
 
607
- // OT client state:
608
- // serverVersion β€” last version confirmed by server
609
- // inFlight β€” single op sent, awaiting ack (null if none)
610
- // pendingQueue β€” ops composed locally, not yet sent
611
- // Classic OT client: only one op in-flight at a time.
612
  let serverVersion = 0;
613
  let inFlight = null; // { op, op_id }
614
  let pendingQueue = []; // ops waiting for in-flight ack
@@ -616,15 +604,12 @@
616
  let isApplyingRemote = false;
617
  let prevContent = '';
618
 
619
- // Remote cursors: userId -> { pos, name, color }
620
  const remoteCursors = {};
621
- const cursorElems = {}; // userId -> DOM element
622
 
623
- // Typing activity
624
  const typingUsers = {};
625
  const typingTimers = {};
626
 
627
- // Reconnect state
628
  let reconnectAttempts = 0;
629
  let reconnectTimer = null;
630
 
@@ -647,6 +632,74 @@
647
  const activityText = document.getElementById('activity-text');
648
  const saveIndicator = document.getElementById('save-indicator');
649
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  // ── Routing ──────────────────────────────────────────────────────────────
651
  function getDocId() {
652
  return new URLSearchParams(window.location.search).get('doc') || 'welcome';
@@ -662,28 +715,17 @@
662
  }
663
 
664
  // ── Myers diff ────────────────────────────────────────────────────────────
665
- // Produces a minimal list of {type, pos, text/len} edit ops.
666
- // This handles paste-over-selection, undo, multi-char delete correctly β€”
667
- // the original prefix/suffix heuristic fails on those cases.
668
  function myersDiff(oldStr, newStr) {
669
- const m = oldStr.length;
670
- const n = newStr.length;
671
-
672
  if (m === 0 && n === 0) return [];
673
  if (m === 0) return [{ type: 'insert', pos: 0, text: newStr }];
674
  if (n === 0) return [{ type: 'delete', pos: 0, len: m }];
675
-
676
- // Short-circuit: find common prefix
677
  let p = 0;
678
  while (p < m && p < n && oldStr[p] === newStr[p]) p++;
679
-
680
- // Find common suffix (after common prefix)
681
  let os = m - 1, ns = n - 1;
682
  while (os >= p && ns >= p && oldStr[os] === newStr[ns]) { os--; ns--; }
683
-
684
  const deletedLen = os - p + 1;
685
  const insertedStr = newStr.slice(p, ns + 1);
686
-
687
  const ops = [];
688
  if (deletedLen > 0) ops.push({ type: 'delete', pos: p, len: deletedLen });
689
  if (insertedStr.length > 0) ops.push({ type: 'insert', pos: p, text: insertedStr });
@@ -696,10 +738,9 @@
696
  const msg = d.type === 'insert'
697
  ? { op_type: 'insert', position: d.pos, value: d.text, length: d.text.length }
698
  : { op_type: 'delete', position: d.pos, value: '', length: d.len };
699
-
700
  msg.op_id = Math.random().toString(36).slice(2, 11);
701
  msg.base_version = serverVersion;
702
-
703
  pendingQueue.push(msg);
704
  }
705
  flushPending();
@@ -708,7 +749,6 @@
708
  function flushPending() {
709
  if (inFlight !== null || pendingQueue.length === 0) return;
710
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
711
-
712
  inFlight = pendingQueue.shift();
713
  inFlight.type = 'operation';
714
  ws.send(JSON.stringify(inFlight));
@@ -719,35 +759,16 @@
719
  function connect() {
720
  docId = getDocId();
721
  myUserId = getUserId();
722
-
723
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
724
  const url = `${proto}://${location.host}/ws/${docId}?user_id=${myUserId}`;
725
-
726
  ws = new WebSocket(url);
727
-
728
- ws.onopen = () => {
729
- setStatus('connected', 'Connected');
730
- reconnectAttempts = 0;
731
- startHeartbeat();
732
- };
733
-
734
- ws.onclose = () => {
735
- setStatus('', 'Reconnecting…');
736
- stopHeartbeat();
737
- scheduleReconnect();
738
- };
739
-
740
- ws.onerror = (e) => console.error('[WS] error', e);
741
-
742
- ws.onmessage = ({ data }) => {
743
- try { handleMessage(JSON.parse(data)); }
744
- catch (e) { console.error('[WS] parse error', e); }
745
- };
746
  }
747
 
748
  function scheduleReconnect() {
749
  clearTimeout(reconnectTimer);
750
- // Exponential backoff: 1s, 2s, 4s, 8s, capped at 16s
751
  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 16000);
752
  reconnectAttempts++;
753
  reconnectTimer = setTimeout(connect, delay);
@@ -767,17 +788,12 @@
767
  }
768
 
769
  function onInit(msg) {
770
- myUserId = msg.user_id;
771
- myName = msg.name;
772
- myColor = msg.color;
773
-
774
  serverVersion = msg.doc_state.version;
775
-
776
  isApplyingRemote = true;
777
  editor.value = msg.doc_state.content;
778
  prevContent = msg.doc_state.content;
779
  isApplyingRemote = false;
780
-
781
  docTitleEl.value = msg.doc_state.title || 'Untitled Document';
782
  document.title = `${docTitleEl.value} β€” CollabDocs`;
783
  updateVersionChip();
@@ -786,28 +802,36 @@
786
 
787
  function onRemoteOp(msg) {
788
  if (msg.user_id === myUserId) return;
789
-
790
  serverVersion = msg.server_version;
791
  updateVersionChip();
792
 
 
 
 
 
 
 
 
 
 
 
 
793
  const savedStart = editor.selectionStart;
794
  const savedEnd = editor.selectionEnd;
795
 
796
  isApplyingRemote = true;
797
- editor.value = applyOpToString(editor.value, msg);
798
  prevContent = editor.value;
799
  isApplyingRemote = false;
800
 
801
- // Adjust local cursor
802
- const newStart = shiftCursor(savedStart, msg);
803
- const newEnd = shiftCursor(savedEnd, msg);
804
  editor.setSelectionRange(newStart, newEnd);
805
-
806
  showTyping(msg.user_id, remoteCursors[msg.user_id]?.name || 'Someone');
807
  }
808
 
809
  function onAck(msg) {
810
- if (inFlight && inFlight.op_id === msg.op_id) {
811
  inFlight = null;
812
  serverVersion = msg.server_version;
813
  updateVersionChip();
@@ -879,18 +903,9 @@
879
  editor.addEventListener('select', debounceSendCursor);
880
 
881
  let cursorTimer = null;
882
- function debounceSendCursor() {
883
- clearTimeout(cursorTimer);
884
- cursorTimer = setTimeout(sendCursor, 30);
885
- }
886
-
887
  function sendCursor() {
888
- send({
889
- type: 'cursor',
890
- cursor_pos: editor.selectionStart,
891
- selection_start: editor.selectionStart,
892
- selection_end: editor.selectionEnd,
893
- });
894
  }
895
 
896
  // ── Title ─────────────────────────────────────────────────────────────────
@@ -904,65 +919,42 @@
904
  });
905
 
906
  // ── Cursor rendering ──────────────────────────────────────────────────────
907
- // We keep a single persistent mirror div (never destroyed) for measuring.
908
- // On resize, we invalidate all cursor positions.
909
  let mirrorEl = null;
910
-
911
  function getMirror() {
912
  if (!mirrorEl) {
913
  mirrorEl = document.createElement('div');
914
  const s = window.getComputedStyle(editor);
915
  Object.assign(mirrorEl.style, {
916
- position: 'fixed',
917
- top: '-9999px',
918
- left: '-9999px',
919
- visibility: 'hidden',
920
- fontFamily: s.fontFamily,
921
- fontSize: s.fontSize,
922
- fontWeight: s.fontWeight,
923
- lineHeight: s.lineHeight,
924
- letterSpacing: s.letterSpacing,
925
- whiteSpace: 'pre-wrap',
926
- wordWrap: 'break-word',
927
- overflowWrap: 'break-word',
928
- padding: s.padding,
929
- // Width must match editor to get correct line wrapping
930
- width: editor.clientWidth + 'px',
931
- boxSizing: 'border-box',
932
  });
933
  document.body.appendChild(mirrorEl);
934
  }
935
- // Keep width in sync
936
  mirrorEl.style.width = editor.clientWidth + 'px';
937
  return mirrorEl;
938
  }
939
 
940
- // Invalidate mirror on editor resize
941
  const resizeObserver = new ResizeObserver(() => {
942
  if (mirrorEl) mirrorEl.style.width = editor.clientWidth + 'px';
943
- // Re-render all remote cursors
944
- for (const [uid, c] of Object.entries(remoteCursors)) {
945
- renderRemoteCursor(uid, c.pos, c.name, c.color);
946
- }
947
  });
948
  resizeObserver.observe(editor);
949
 
950
  function getCharCoords(charIndex) {
951
  const mirror = getMirror();
952
  const text = editor.value.slice(0, charIndex);
953
-
954
- // Build mirror content with a sentinel span
955
  mirror.innerHTML = '';
956
  mirror.appendChild(document.createTextNode(text));
957
  const span = document.createElement('span');
958
- span.textContent = '\u200b'; // zero-width space
959
  mirror.appendChild(span);
960
-
961
  const editorRect = editor.getBoundingClientRect();
962
  const pageRect = document.getElementById('page').getBoundingClientRect();
963
  const spanRect = span.getBoundingClientRect();
964
  const mirrorRect = mirror.getBoundingClientRect();
965
-
966
  return {
967
  x: spanRect.left - mirrorRect.left + (editorRect.left - pageRect.left),
968
  y: spanRect.top - mirrorRect.top + (editorRect.top - pageRect.top),
@@ -971,55 +963,36 @@
971
 
972
  function renderRemoteCursor(userId, charPos, name, color) {
973
  const coords = getCharCoords(charPos);
974
-
975
  let el = cursorElems[userId];
976
  if (!el) {
977
- el = document.createElement('div');
978
- el.className = 'r-cursor';
979
- el.innerHTML = `
980
- <div class="r-cursor-label" style="background:${esc(color)}">${esc(name)}</div>
981
- <div class="r-cursor-caret" style="background:${esc(color)}"></div>
982
- `;
983
  document.getElementById('cursor-layer').appendChild(el);
984
  cursorElems[userId] = el;
985
  }
986
-
987
- el.style.left = `${coords.x}px`;
988
- el.style.top = `${coords.y}px`;
989
  el.querySelector('.r-cursor-label').style.background = color;
990
  el.querySelector('.r-cursor-caret').style.background = color;
991
  el.querySelector('.r-cursor-label').textContent = name;
992
  }
993
 
994
- function removeCursor(userId) {
995
- cursorElems[userId]?.remove();
996
- delete cursorElems[userId];
997
- }
998
 
999
  // ── Avatars ────────────────────────────────────────────────────────────────
1000
  function renderAvatars(users) {
1001
  userAvatars.innerHTML = '';
1002
  const MAX = 5;
1003
  users.slice(0, MAX).forEach(u => {
1004
- const div = document.createElement('div');
1005
- div.className = 'avatar';
1006
- div.style.background = u.color;
1007
- div.textContent = (u.name || '?')[0];
1008
- if (u.user_id === myUserId) {
1009
- div.style.outline = `2px solid ${u.color}`;
1010
- div.style.outlineOffset = '1px';
1011
- }
1012
- const tip = document.createElement('div');
1013
- tip.className = 'avatar-tip';
1014
  tip.textContent = u.user_id === myUserId ? `${u.name} (you)` : u.name;
1015
- div.appendChild(tip);
1016
- userAvatars.appendChild(div);
1017
  });
1018
  if (users.length > MAX) {
1019
- const more = document.createElement('div');
1020
- more.className = 'avatar';
1021
- more.style.background = '#888';
1022
- more.textContent = `+${users.length - MAX}`;
1023
  userAvatars.appendChild(more);
1024
  }
1025
  }
@@ -1028,111 +1001,64 @@
1028
  function showTyping(userId, name) {
1029
  typingUsers[userId] = name;
1030
  clearTimeout(typingTimers[userId]);
1031
- typingTimers[userId] = setTimeout(() => {
1032
- delete typingUsers[userId];
1033
- updateActivityBar();
1034
- }, 2000);
1035
  updateActivityBar();
1036
  }
1037
 
1038
  function updateActivityBar() {
1039
  const names = Object.values(typingUsers);
1040
- if (names.length === 0) {
1041
- activityBar.classList.add('hidden');
1042
- return;
1043
- }
1044
  activityBar.classList.remove('hidden');
1045
- activityText.textContent = names.length === 1
1046
- ? `${names[0]} is typing`
1047
- : `${names.slice(0, -1).join(', ')} & ${names.at(-1)} are typing`;
1048
  }
1049
 
1050
- // ── Toast notifications ────────────────────────────────────────────────────
1051
  function toast(text, color) {
1052
- const div = document.createElement('div');
1053
- div.className = 'toast';
1054
  div.innerHTML = `<div class="toast-dot" style="background:${esc(color)}"></div><span>${esc(text)}</span>`;
1055
- toastsEl.appendChild(div);
1056
- setTimeout(() => div.remove(), 3200);
1057
  }
1058
 
1059
- // ── Status / UI helpers ────────────────────────────────────────────────────
1060
  function setStatus(state, text) {
1061
  statusBadge.className = state ? `${state}` : '';
1062
  statusText.textContent = text;
1063
  statusDot.className = 'status-dot' + (state === 'connected' ? ' pulse' : '');
1064
  }
1065
 
1066
- function updateVersionChip() {
1067
- versionChip.textContent = `v${serverVersion}`;
1068
- }
1069
 
1070
  let saveTimer = null;
1071
  function setSaveIndicator(state) {
1072
  clearTimeout(saveTimer);
1073
- if (state === 'saving') {
1074
- saveIndicator.textContent = 'Saving…';
1075
- saveIndicator.className = 'saving';
1076
- } else {
1077
- saveIndicator.textContent = 'Saved';
1078
- saveIndicator.className = '';
1079
- }
1080
  }
1081
 
1082
- // ── Heartbeat ──────────────────────────────────────────────────────────────
1083
  let heartbeatId = null;
1084
- function startHeartbeat() {
1085
- clearInterval(heartbeatId);
1086
- heartbeatId = setInterval(() => send({ type: 'ping' }), 10000);
1087
- }
1088
-
1089
  function stopHeartbeat() { clearInterval(heartbeatId); }
 
1090
 
1091
- function send(msg) {
1092
- if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
1093
- }
1094
-
1095
- // ── Share modal ────────────────────────────────────────────────────────────
1096
  shareBtn.addEventListener('click', () => {
1097
  const url = `${location.origin}${location.pathname}?doc=${docId}`;
1098
- shareLinkInput.value = url;
1099
- shareModal.classList.add('open');
1100
- shareLinkInput.select();
1101
  });
1102
-
1103
  modalClose.addEventListener('click', () => shareModal.classList.remove('open'));
1104
  shareModal.addEventListener('click', e => { if (e.target === shareModal) shareModal.classList.remove('open'); });
1105
-
1106
  copyBtn.addEventListener('click', () => {
1107
  navigator.clipboard.writeText(shareLinkInput.value).then(() => {
1108
- copyBtn.textContent = 'βœ“ Copied';
1109
- copyBtn.classList.add('copied');
1110
- setTimeout(() => {
1111
- copyBtn.textContent = 'Copy';
1112
- copyBtn.classList.remove('copied');
1113
- }, 2000);
1114
  });
1115
  });
1116
 
1117
- // ── New doc ────────────────────────────────────────────────────────────────
1118
  newDocBtn.addEventListener('click', async () => {
1119
  const res = await fetch('/api/docs', { method: 'POST' });
1120
- const data = await res.json();
1121
- window.location.href = data.url;
1122
  });
1123
 
1124
- // ── Escape util ────────────────────────────────────────────────────────────
1125
- function esc(str) {
1126
- return String(str)
1127
- .replace(/&/g, '&amp;')
1128
- .replace(/</g, '&lt;')
1129
- .replace(/>/g, '&gt;')
1130
- .replace(/"/g, '&quot;');
1131
- }
1132
 
1133
- // ── Init ───────────────────────────────────────────────────────────────────
1134
  connect();
1135
-
1136
  })();
1137
  </script>
1138
  </body>
 
584
 
585
  <script>
586
  // ══════════════════════════════════════════════════════════════════════════
587
+ // CollabDocs β€” Updated Client with OT Transformation logic
 
 
 
 
 
 
 
588
  // ══════════════════════════════════════════════════════════════════════════
589
 
590
  (function () {
 
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
 
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
 
 
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';
 
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 });
 
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();
 
749
  function flushPending() {
750
  if (inFlight !== null || pendingQueue.length === 0) return;
751
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
 
752
  inFlight = pendingQueue.shift();
753
  inFlight.type = 'operation';
754
  ws.send(JSON.stringify(inFlight));
 
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() {
771
  clearTimeout(reconnectTimer);
 
772
  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 16000);
773
  reconnectAttempts++;
774
  reconnectTimer = setTimeout(connect, delay);
 
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();
 
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();
 
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 ─────────────────────────────────────────────────────────────────
 
919
  });
920
 
921
  // ── Cursor rendering ──────────────────────────────────────────────────────
 
 
922
  let mirrorEl = null;
 
923
  function getMirror() {
924
  if (!mirrorEl) {
925
  mirrorEl = document.createElement('div');
926
  const s = window.getComputedStyle(editor);
927
  Object.assign(mirrorEl.style, {
928
+ position: 'fixed', top: '-9999px', left: '-9999px', visibility: 'hidden',
929
+ fontFamily: s.fontFamily, fontSize: s.fontSize, fontWeight: s.fontWeight,
930
+ lineHeight: s.lineHeight, letterSpacing: s.letterSpacing,
931
+ whiteSpace: 'pre-wrap', wordWrap: 'break-word', overflowWrap: 'break-word',
932
+ padding: s.padding, width: editor.clientWidth + 'px', boxSizing: 'border-box',
 
 
 
 
 
 
 
 
 
 
 
933
  });
934
  document.body.appendChild(mirrorEl);
935
  }
 
936
  mirrorEl.style.width = editor.clientWidth + 'px';
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),
960
  y: spanRect.top - mirrorRect.top + (editorRect.top - pageRect.top),
 
963
 
964
  function renderRemoteCursor(userId, charPos, name, color) {
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;
977
  }
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
  }
 
1001
  function showTyping(userId, name) {
1002
  typingUsers[userId] = name;
1003
  clearTimeout(typingTimers[userId]);
1004
+ typingTimers[userId] = setTimeout(() => { delete typingUsers[userId]; updateActivityBar(); }, 2000);
 
 
 
1005
  updateActivityBar();
1006
  }
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>
1064
  </body>