Spaces:
Sleeping
Sleeping
Update index.html
Browse files- 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 = {};
|
| 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.
|
| 729 |
-
|
| 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,
|
| 798 |
prevContent = editor.value;
|
| 799 |
isApplyingRemote = false;
|
| 800 |
|
| 801 |
-
|
| 802 |
-
const
|
| 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 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 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 |
-
|
| 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';
|
| 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.
|
| 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.
|
| 1006 |
-
div.style.
|
| 1007 |
-
|
| 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.
|
| 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 |
-
|
| 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.
|
| 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 |
-
//
|
| 1125 |
-
function esc(str) {
|
| 1126 |
-
return String(str)
|
| 1127 |
-
.replace(/&/g, '&')
|
| 1128 |
-
.replace(/</g, '<')
|
| 1129 |
-
.replace(/>/g, '>')
|
| 1130 |
-
.replace(/"/g, '"');
|
| 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
|
|
|
|
| 1061 |
connect();
|
|
|
|
| 1062 |
})();
|
| 1063 |
</script>
|
| 1064 |
</body>
|