CollabDocs / index.html
NOT-OMEGA's picture
Update index.html
d65f4c3 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CollabDocs</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;1,400&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #F8F6F1;
--surface: #FFFFFF;
--surface2: #F0EDE7;
--surface3: #E8E3DC;
--border: #DDD8D0;
--border-strong: #BFB9AF;
--text: #1C1A17;
--text-muted: #6B6459;
--text-light: #9E968A;
--accent: #1A5C3A;
--accent-hover: #144D30;
--accent-light: #4BAE72;
--accent-dim: #E2F4EA;
--danger: #B83030;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
--shadow: 0 2px 8px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06);
--shadow-page: 0 4px 40px rgba(0,0,0,0.12), 0 1px 4px rgba(0,0,0,0.06);
--radius-sm: 5px;
--radius: 8px;
--radius-lg: 12px;
--font-serif: 'Lora', Georgia, serif;
--font-sans: 'DM Sans', system-ui, sans-serif;
--toolbar-h: 54px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Toolbar ── */
#toolbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
height: var(--toolbar-h);
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-sm);
}
.logo {
font-family: var(--font-serif);
font-size: 19px;
color: var(--accent);
letter-spacing: -0.2px;
white-space: nowrap;
user-select: none;
flex-shrink: 0;
}
.logo-badge {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 500;
color: var(--text-light);
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
margin-left: 6px;
vertical-align: middle;
letter-spacing: 0.5px;
}
.toolbar-divider { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
#doc-title {
flex: 1;
min-width: 0;
background: transparent;
border: 1px solid transparent;
outline: none;
font-family: var(--font-sans);
font-size: 15px;
font-weight: 500;
color: var(--text);
padding: 5px 8px;
border-radius: var(--radius-sm);
transition: background 0.15s, border-color 0.15s;
}
#doc-title:hover { background: var(--surface2); border-color: var(--border); }
#doc-title:focus { background: var(--surface2); border-color: var(--border-strong); }
.toolbar-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
#version-chip {
font-size: 11px; font-weight: 500; color: var(--text-light);
background: var(--surface2); border: 1px solid var(--border);
border-radius: 20px; padding: 3px 9px; white-space: nowrap;
font-variant-numeric: tabular-nums;
}
#status-badge {
display: flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 500;
padding: 4px 10px; border-radius: 20px;
border: 1px solid var(--border); background: var(--surface2);
color: var(--text-muted); transition: all 0.25s; white-space: nowrap;
}
#status-badge.connected { color: var(--accent); background: var(--accent-dim); border-color: #A8DDB8; }
#status-badge.error { color: var(--danger); background: #FDEAEA; border-color: #EAB0B0; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-dot.pulse { animation: dot-pulse 2s ease-in-out infinite; }
@keyframes dot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
#user-avatars { display: flex; align-items: center; }
.avatar {
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: white;
border: 2px solid var(--surface); margin-left: -7px;
cursor: default; position: relative;
transition: transform 0.15s, z-index 0s;
box-shadow: 0 1px 4px rgba(0,0,0,0.15); text-transform: uppercase;
}
.avatar:first-child { margin-left: 0; }
.avatar:hover { transform: translateY(-2px); z-index: 20; }
.avatar-tip {
position: absolute; bottom: calc(100% + 7px); left: 50%;
transform: translateX(-50%); background: var(--text); color: #fff;
font-size: 11px; font-weight: 500; padding: 3px 8px; border-radius: 4px;
white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.15s;
}
.avatar-tip::after {
content: ''; position: absolute; top: 100%; left: 50%;
transform: translateX(-50%); border: 4px solid transparent;
border-top-color: var(--text);
}
.avatar:hover .avatar-tip { opacity: 1; }
.btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 12px; border-radius: var(--radius-sm);
font-family: var(--font-sans); font-size: 13px; font-weight: 500;
cursor: pointer; border: 1px solid transparent;
transition: all 0.15s; white-space: nowrap; line-height: 1;
}
.btn-ghost { background: var(--surface2); border-color: var(--border); color: var(--text-muted); }
.btn-ghost:hover { background: var(--surface3); color: var(--text); }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: var(--shadow); }
.btn-primary:active { transform: translateY(0); box-shadow: none; }
/* ── Main layout ── */
#main { flex: 1; display: flex; justify-content: center; padding: 40px 20px 80px; }
#page-wrap { width: 100%; max-width: 800px; }
#page {
background: var(--surface); border-radius: 2px;
box-shadow: var(--shadow-page); position: relative; min-height: 80vh;
}
#cursor-layer {
position: absolute; inset: 0; pointer-events: none;
overflow: hidden; z-index: 5; border-radius: 2px;
}
.r-cursor { position: absolute; pointer-events: none; will-change: left, top; }
.r-cursor-caret { width: 2px; height: 22px; border-radius: 1px; transform: translateY(4px); animation: caret-blink 1.1s ease-in-out infinite; }
@keyframes caret-blink {
0%, 45%, 100% { opacity: 1; }
55%, 90% { opacity: 0; }
}
.r-cursor-label {
position: absolute; top: -19px; left: 0;
font-size: 10px; font-weight: 600; color: white;
padding: 2px 6px; border-radius: 3px 3px 3px 0; white-space: nowrap;
font-family: var(--font-sans); line-height: 1.4;
box-shadow: 0 1px 4px rgba(0,0,0,0.2); letter-spacing: 0.2px;
}
#editor {
width: 100%; min-height: 80vh; padding: 64px 88px;
font-family: var(--font-serif); font-size: 16.5px; line-height: 1.85;
color: var(--text); border: none; outline: none; resize: none;
background: transparent; position: relative; z-index: 10;
white-space: pre-wrap; word-wrap: break-word;
caret-color: var(--accent); -webkit-font-smoothing: antialiased;
}
#editor::placeholder { color: var(--text-light); font-style: italic; }
#editor::selection { background: rgba(26, 92, 58, 0.15); }
#activity-bar {
position: fixed; bottom: 18px; left: 18px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 7px 12px;
font-size: 12px; color: var(--text-muted); box-shadow: var(--shadow);
display: flex; align-items: center; gap: 7px;
opacity: 1; transform: translateY(0); transition: opacity 0.2s, transform 0.2s;
}
#activity-bar.hidden { opacity: 0; transform: translateY(4px); pointer-events: none; }
.typing-dots { display: flex; gap: 3px; align-items: center; }
.typing-dot {
width: 4px; height: 4px; border-radius: 50%;
background: var(--accent-light); animation: typing-bounce 1.3s ease-in-out infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.16s; }
.typing-dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
30% { transform: translateY(-4px); opacity: 1; }
}
#toasts {
position: fixed; bottom: 18px; right: 18px;
display: flex; flex-direction: column; gap: 8px;
z-index: 1000; pointer-events: none;
}
.toast {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 9px 14px; font-size: 13px;
box-shadow: var(--shadow-lg); display: flex; align-items: center;
gap: 8px; max-width: 260px; animation: toast-in 0.25s ease;
}
@keyframes toast-in {
from { transform: translateX(16px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.toast-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
#share-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.35); z-index: 500;
align-items: center; justify-content: center; backdrop-filter: blur(3px);
}
#share-modal.open { display: flex; }
.modal {
background: var(--surface); border-radius: var(--radius-lg);
padding: 28px; width: 440px; max-width: 92vw;
box-shadow: var(--shadow-lg);
animation: modal-pop 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes modal-pop {
from { transform: scale(0.93); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.modal-title { font-family: var(--font-serif); font-size: 21px; margin-bottom: 6px; color: var(--text); }
.modal-desc { font-size: 13.5px; color: var(--text-muted); margin-bottom: 18px; line-height: 1.6; }
.link-row { display: flex; gap: 8px; margin-bottom: 14px; }
.link-input {
flex: 1; padding: 9px 11px; border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); font-size: 12.5px;
font-family: 'SF Mono', 'Fira Code', monospace;
background: var(--surface2); color: var(--text-muted); outline: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.btn-copy { min-width: 72px; justify-content: center; }
.btn-copy.copied { background: var(--accent-light); }
@media (max-width: 640px) {
#editor { padding: 32px 24px; font-size: 15px; }
.logo-badge { display: none; }
#version-chip { display: none; }
}
#save-indicator { font-size: 11.5px; color: var(--text-light); transition: opacity 0.3s; }
#save-indicator.saving { color: var(--accent); }
#latency-badge {
display: flex; align-items: center; gap: 8px;
font-size: 11px; font-weight: 600; font-family: 'SF Mono', 'Fira Code', monospace;
padding: 4px 10px; border-radius: 20px;
border: 1px solid var(--border); background: var(--surface2);
color: var(--text-muted); white-space: nowrap;
}
.latency-val { color: var(--text); }
</style>
</head>
<body>
<div id="toolbar">
<div class="logo">CollabDocs <span class="logo-badge">BETA</span></div>
<div class="toolbar-divider"></div>
<input id="doc-title" type="text" value="Untitled Document" spellcheck="false" maxlength="200" autocomplete="off">
<div class="toolbar-right">
<span id="save-indicator">Saved</span>
<span id="version-chip">v0</span>
<div id="latency-badge">
P90: <span class="latency-val" id="lat-p90">--</span>
P95: <span class="latency-val" id="lat-p95">--</span>
P99: <span class="latency-val" id="lat-p99">--</span>
</div>
<div id="user-avatars"></div>
<div id="status-badge">
<div class="status-dot pulse"></div>
<span id="status-text">Connecting…</span>
</div>
<button class="btn btn-ghost" id="new-doc-btn">+ New</button>
<button class="btn btn-primary" id="share-btn">↑ Share</button>
</div>
</div>
<div id="main">
<div id="page-wrap">
<div id="page">
<div id="cursor-layer"></div>
<textarea id="editor" spellcheck="true" autocomplete="off" autocorrect="on"
placeholder="Start typing to collaborate…"></textarea>
</div>
</div>
</div>
<div id="toasts"></div>
<div id="activity-bar" class="hidden">
<div class="typing-dots">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
<span id="activity-text"></span>
</div>
<div id="share-modal">
<div class="modal">
<div class="modal-title">Share Document</div>
<div class="modal-desc">Anyone with this link can view and edit this document in real time.</div>
<div class="link-row">
<input class="link-input" id="share-link" readonly>
<button class="btn btn-primary btn-copy" id="copy-btn">Copy</button>
</div>
<button class="btn btn-ghost" id="modal-close" style="width:100%;justify-content:center">Done</button>
</div>
</div>
<script>
// ══════════════════════════════════════════════════════════════════════════════
// CollabDocs β€” Client (v2)
// Changes:
// β€’ handles "batch" message type from server (op batching)
// β€’ fixed OT transform loop: pending queue ops must be re-transformed
// against each other correctly (diamond property)
// β€’ trimmed duplicate op_type field detection
// ══════════════════════════════════════════════════════════════════════════════
(function () {
'use strict';
// ── State ────────────────────────────────────────────────────────────────────
let ws = null;
let myUserId = null;
let myName = null;
let myColor = null;
let docId = null;
let serverVersion = 0;
let inFlight = null; // op currently waiting for ack { op, op_id, ... }
let pendingQueue = []; // ops queued behind inFlight
let isApplyingRemote = false;
let prevContent = '';
const remoteCursors = {};
const cursorElems = {};
const typingUsers = {};
const typingTimers = {};
let reconnectAttempts = 0;
let reconnectTimer = null;
const latencies = [];
const MAX_LATENCIES = 100;
const pendingPings = new Map();
let pingIdCounter = 0;
// ── DOM ──────────────────────────────────────────────────────────────────────
const editor = document.getElementById('editor');
const statusBadge = document.getElementById('status-badge');
const statusText = document.getElementById('status-text');
const statusDot = document.querySelector('.status-dot');
const userAvatars = document.getElementById('user-avatars');
const shareBtn = document.getElementById('share-btn');
const shareModal = document.getElementById('share-modal');
const shareLinkEl = document.getElementById('share-link');
const copyBtn = document.getElementById('copy-btn');
const modalClose = document.getElementById('modal-close');
const newDocBtn = document.getElementById('new-doc-btn');
const docTitleEl = document.getElementById('doc-title');
const versionChip = document.getElementById('version-chip');
const toastsEl = document.getElementById('toasts');
const activityBar = document.getElementById('activity-bar');
const activityText = document.getElementById('activity-text');
const saveIndicator= document.getElementById('save-indicator');
const latP90 = document.getElementById('lat-p90');
const latP95 = document.getElementById('lat-p95');
const latP99 = document.getElementById('lat-p99');
// ── OT pairwise transforms ───────────────────────────────────────────────────
function isInsert(op) { return (op.op_type || op.type) === 'insert'; }
function transform_ii(op, against) {
const r = { ...op };
if (against.position < op.position) {
r.position += against.value.length;
} else if (against.position === op.position) {
if (against.user_id <= op.user_id) r.position += against.value.length;
}
return r;
}
function transform_id(op, against) {
// op = insert, against = delete
const r = { ...op };
const del_start = against.position;
const del_end = against.position + against.length;
if (del_end <= op.position) r.position -= against.length;
else if (del_start < op.position) r.position = del_start;
return r;
}
function transform_di(op, against) {
// op = delete, against = insert
const r = { ...op };
const ins_pos = against.position;
const ins_len = against.value.length;
const del_end = op.position + op.length;
if (ins_pos < op.position) r.position += ins_len;
else if (ins_pos <= del_end) r.length += ins_len;
return r;
}
function transform_dd(op, against) {
const r = { ...op };
const op_start = op.position;
const op_end = op.position + op.length;
const ag_start = against.position;
const ag_end = against.position + against.length;
if (ag_end <= op_start) {
r.position -= against.length;
} else if (ag_start < op_end) {
const overlap = Math.min(op_end, ag_end) - Math.max(op_start, ag_start);
if (ag_start < op_start) r.position = ag_start;
r.length = Math.max(0, op.length - overlap);
}
return r;
}
function transformOp(incoming, applied) {
if (isInsert(incoming)) {
return isInsert(applied) ? transform_ii(incoming, applied)
: transform_id(incoming, applied);
} else {
return isInsert(applied) ? transform_di(incoming, applied)
: transform_dd(incoming, applied);
}
}
// ── Routing ──────────────────────────────────────────────────────────────────
function getDocId() {
return new URLSearchParams(window.location.search).get('doc') || 'welcome';
}
function getUserId() {
let id = sessionStorage.getItem('collab_uid');
if (!id) {
id = 'u_' + Math.random().toString(36).slice(2, 11);
sessionStorage.setItem('collab_uid', id);
}
return id;
}
// ── Myers diff (simple LCS-based) ────────────────────────────────────────────
function myersDiff(oldStr, newStr) {
const m = oldStr.length, n = newStr.length;
if (m === 0 && n === 0) return [];
if (m === 0) return [{ type: 'insert', pos: 0, text: newStr }];
if (n === 0) return [{ type: 'delete', pos: 0, len: m }];
let p = 0;
while (p < m && p < n && oldStr[p] === newStr[p]) p++;
let os = m - 1, ns = n - 1;
while (os >= p && ns >= p && oldStr[os] === newStr[ns]) { os--; ns--; }
const deletedLen = os - p + 1;
const insertedStr = newStr.slice(p, ns + 1);
const ops = [];
if (deletedLen > 0) ops.push({ type: 'delete', pos: p, len: deletedLen });
if (insertedStr.length > 0) ops.push({ type: 'insert', pos: p, text: insertedStr });
return ops;
}
// ── OT send pipeline ─────────────────────────────────────────────────────────
function composeAndSend(diffOps) {
for (const d of diffOps) {
const msg = d.type === 'insert'
? { op_type: 'insert', position: d.pos, value: d.text, length: d.text.length }
: { op_type: 'delete', position: d.pos, value: '', length: d.len };
msg.op_id = Math.random().toString(36).slice(2, 11);
msg.base_version = serverVersion;
msg.user_id = myUserId;
pendingQueue.push(msg);
}
flushPending();
}
function flushPending() {
if (inFlight !== null || pendingQueue.length === 0) return;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
inFlight = pendingQueue.shift();
inFlight.type = 'operation';
ws.send(JSON.stringify(inFlight));
setSaveIndicator('saving');
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
function connect() {
docId = getDocId();
myUserId = getUserId();
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws/${docId}?user_id=${myUserId}`;
ws = new WebSocket(url);
ws.onopen = () => { setStatus('connected', 'Connected'); reconnectAttempts = 0; startHeartbeat(); };
ws.onclose = () => { setStatus('', 'Reconnecting…'); stopHeartbeat(); scheduleReconnect(); };
ws.onmessage = ({ data }) => { try { handleMessage(JSON.parse(data)); } catch (e) { console.error('[WS]', e); } };
ws.onerror = (e) => console.error('[WS] error', e);
}
function scheduleReconnect() {
clearTimeout(reconnectTimer);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 16000);
reconnectAttempts++;
reconnectTimer = setTimeout(connect, delay);
}
// ── Message dispatch ──────────────────────────────────────────────────────────
function handleMessage(msg) {
switch (msg.type) {
case 'init': onInit(msg); break;
case 'operation': onRemoteOp(msg); break;
case 'batch': onBatch(msg); break; // NEW: server batching
case 'ack': onAck(msg); break;
case 'pong': onPong(msg); break;
case 'cursor': onRemoteCursor(msg); break;
case 'user_joined': onUserJoined(msg); break;
case 'user_left': onUserLeft(msg); break;
case 'title_change': onTitleChange(msg); break;
}
}
function onInit(msg) {
myUserId = msg.user_id;
myName = msg.name;
myColor = msg.color;
serverVersion = msg.doc_state.version;
isApplyingRemote = true;
editor.value = msg.doc_state.content;
prevContent = msg.doc_state.content;
isApplyingRemote = false;
docTitleEl.value = msg.doc_state.title || 'Untitled Document';
document.title = `${docTitleEl.value} β€” CollabDocs`;
updateVersionChip();
renderAvatars(msg.users || []);
}
// Handle a batch of ops from the server (reduces message count ~15-20Γ—)
function onBatch(msg) {
if (!Array.isArray(msg.ops)) return;
for (const op of msg.ops) onRemoteOp(op);
}
function onRemoteOp(msg) {
if (msg.user_id === myUserId) return;
serverVersion = msg.server_version;
updateVersionChip();
// ── Transform remote op against our un-acked local ops ───────────────────
// This implements the client-side of the OT diamond property:
// remote must be transformed against everything the server hasn't seen yet.
//
// inFlight: sent to server but not acked yet
// pendingQueue: queued locally, not yet sent
//
// After transforming the remote op, we also need to re-transform our
// local ops so they remain correct relative to the (now updated) server state.
let remoteT = { ...msg };
if (inFlight) {
remoteT = transformOp(remoteT, inFlight);
// inFlight doesn't change β€” the server will transform it server-side
}
for (let i = 0; i < pendingQueue.length; i++) {
const local = pendingQueue[i];
// Transform remote against local
const remoteAgainstLocal = transformOp(remoteT, local);
// Transform local against (original) remote β€” keeps local consistent
pendingQueue[i] = transformOp(local, remoteT);
remoteT = remoteAgainstLocal;
}
// ── Apply to editor ───────────────────────────────────────────────────────
const savedStart = editor.selectionStart;
const savedEnd = editor.selectionEnd;
isApplyingRemote = true;
editor.value = applyOpToString(editor.value, remoteT);
prevContent = editor.value;
isApplyingRemote = false;
editor.setSelectionRange(
shiftCursor(savedStart, remoteT),
shiftCursor(savedEnd, remoteT)
);
showTyping(msg.user_id, remoteCursors[msg.user_id]?.name || 'Someone');
}
function onAck(msg) {
if (inFlight && inFlight.op_id === msg.op_id) {
inFlight = null;
serverVersion = msg.server_version;
updateVersionChip();
setSaveIndicator('saved');
flushPending();
}
}
function onRemoteCursor(msg) {
if (msg.user_id === myUserId) return;
remoteCursors[msg.user_id] = { pos: msg.cursor_pos, name: msg.name, color: msg.color };
renderRemoteCursor(msg.user_id, msg.cursor_pos, msg.name, msg.color);
}
function onUserJoined(msg) {
if (msg.user_id !== myUserId) toast(`${msg.name} joined`, msg.color);
renderAvatars(msg.users || []);
}
function onUserLeft(msg) {
toast(`${msg.name} left`, msg.color || '#999');
removeCursor(msg.user_id);
delete remoteCursors[msg.user_id];
renderAvatars(msg.users || []);
}
function onTitleChange(msg) {
docTitleEl.value = msg.title;
document.title = `${msg.title} β€” CollabDocs`;
}
// ── String apply / cursor shift ───────────────────────────────────────────────
function applyOpToString(content, op) {
const len = content.length;
const pos = Math.max(0, Math.min(op.position, len));
if (isInsert(op)) {
return content.slice(0, pos) + op.value + content.slice(pos);
} else {
const end = Math.max(0, Math.min(pos + op.length, len));
return content.slice(0, pos) + content.slice(end);
}
}
function shiftCursor(cursor, op) {
if (isInsert(op)) {
if (op.position <= cursor) return cursor + op.value.length;
} else {
const delEnd = op.position + op.length;
if (delEnd <= cursor) return cursor - op.length;
if (op.position <= cursor) return op.position;
}
return cursor;
}
// ── Editor input ──────────────────────────────────────────────────────────────
editor.addEventListener('input', () => {
if (isApplyingRemote) return;
const newContent = editor.value;
const ops = myersDiff(prevContent, newContent);
prevContent = newContent;
if (ops.length > 0) composeAndSend(ops);
debounceSendCursor();
});
editor.addEventListener('keyup', debounceSendCursor);
editor.addEventListener('click', debounceSendCursor);
editor.addEventListener('mouseup', debounceSendCursor);
editor.addEventListener('select', debounceSendCursor);
let cursorTimer = null;
function debounceSendCursor() { clearTimeout(cursorTimer); cursorTimer = setTimeout(sendCursor, 30); }
function sendCursor() {
send({ type: 'cursor', cursor_pos: editor.selectionStart,
selection_start: editor.selectionStart, selection_end: editor.selectionEnd });
}
// ── Title ──────────────────────────────────────────────────────────────────────
let titleTimer = null;
docTitleEl.addEventListener('input', () => {
clearTimeout(titleTimer);
titleTimer = setTimeout(() => {
document.title = `${docTitleEl.value} β€” CollabDocs`;
send({ type: 'title_change', title: docTitleEl.value });
}, 300);
});
// ── Remote cursor rendering ────────────────────────────────────────────────────
let mirrorEl = null;
function getMirror() {
if (!mirrorEl) {
mirrorEl = document.createElement('div');
document.body.appendChild(mirrorEl);
}
const s = window.getComputedStyle(editor);
const props = [
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'fontVariant',
'lineHeight', 'letterSpacing', 'wordSpacing', 'textIndent', 'whiteSpace',
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
'boxSizing', 'tabSize', 'textAlign', 'textTransform', 'wordBreak', 'overflowWrap'
];
mirrorEl.style.position = 'fixed';
mirrorEl.style.top = '-9999px';
mirrorEl.style.left = '-9999px';
mirrorEl.style.visibility = 'hidden';
mirrorEl.style.overflow = 'hidden';
mirrorEl.style.whiteSpace = 'pre-wrap';
mirrorEl.style.wordWrap = 'break-word';
mirrorEl.style.overflowWrap = 'break-word';
for (const p of props) {
mirrorEl.style[p] = s[p];
}
mirrorEl.style.width = editor.clientWidth + 'px';
return mirrorEl;
}
new ResizeObserver(() => {
if (mirrorEl) mirrorEl.style.width = editor.clientWidth + 'px';
for (const [uid, c] of Object.entries(remoteCursors))
renderRemoteCursor(uid, c.pos, c.name, c.color);
}).observe(editor);
function getCharCoords(charIndex) {
const mirror = getMirror();
const text = editor.value.slice(0, charIndex);
mirror.textContent = text;
const span = document.createElement('span');
span.textContent = '\u200b';
mirror.appendChild(span);
const editorRect = editor.getBoundingClientRect();
const pageRect = document.getElementById('page').getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
const mirrorRect = mirror.getBoundingClientRect();
return {
x: spanRect.left - mirrorRect.left + (editorRect.left - pageRect.left) - editor.scrollLeft,
y: spanRect.top - mirrorRect.top + (editorRect.top - pageRect.top) - editor.scrollTop,
};
}
function renderRemoteCursor(userId, charPos, name, color) {
const coords = getCharCoords(charPos);
let el = cursorElems[userId];
if (!el) {
el = document.createElement('div');
el.className = 'r-cursor';
el.innerHTML = `<div class="r-cursor-label" style="background:${esc(color)}">${esc(name)}</div>`
+ `<div class="r-cursor-caret" style="background:${esc(color)}"></div>`;
document.getElementById('cursor-layer').appendChild(el);
cursorElems[userId] = el;
}
el.style.left = `${coords.x}px`;
el.style.top = `${coords.y}px`;
el.querySelector('.r-cursor-label').style.background = color;
el.querySelector('.r-cursor-caret').style.background = color;
el.querySelector('.r-cursor-label').textContent = name;
}
function removeCursor(userId) { cursorElems[userId]?.remove(); delete cursorElems[userId]; }
// ── Avatars ────────────────────────────────────────────────────────────────────
function renderAvatars(users) {
userAvatars.innerHTML = '';
const MAX = 5;
users.slice(0, MAX).forEach(u => {
const div = document.createElement('div');
div.className = 'avatar';
div.style.background = u.color;
div.textContent = (u.name || '?')[0];
if (u.user_id === myUserId) {
div.style.outline = `2px solid ${u.color}`;
div.style.outlineOffset = '1px';
}
const tip = document.createElement('div');
tip.className = 'avatar-tip';
tip.textContent = (u.user_id === myUserId) ? `${u.name} (you)` : u.name;
div.appendChild(tip);
userAvatars.appendChild(div);
});
if (users.length > MAX) {
const more = document.createElement('div');
more.className = 'avatar';
more.style.background = '#888';
more.textContent = `+${users.length - MAX}`;
userAvatars.appendChild(more);
}
}
// ── Typing activity ────────────────────────────────────────────────────────────
function showTyping(userId, name) {
typingUsers[userId] = name;
clearTimeout(typingTimers[userId]);
typingTimers[userId] = setTimeout(() => { delete typingUsers[userId]; updateActivityBar(); }, 2000);
updateActivityBar();
}
function updateActivityBar() {
const names = Object.values(typingUsers);
if (!names.length) { activityBar.classList.add('hidden'); return; }
activityBar.classList.remove('hidden');
activityText.textContent = names.length === 1
? `${names[0]} is typing`
: `${names.slice(0,-1).join(', ')} & ${names.at(-1)} are typing`;
}
// ── Toasts ─────────────────────────────────────────────────────────────────────
function toast(text, color) {
const div = document.createElement('div');
div.className = 'toast';
div.innerHTML = `<div class="toast-dot" style="background:${esc(color)}"></div><span>${esc(text)}</span>`;
toastsEl.appendChild(div);
setTimeout(() => div.remove(), 3200);
}
// ── UI helpers ─────────────────────────────────────────────────────────────────
function setStatus(state, text) {
statusBadge.className = state || '';
statusText.textContent = text;
statusDot.className = 'status-dot' + (state === 'connected' ? ' pulse' : '');
}
function updateVersionChip() { versionChip.textContent = `v${serverVersion}`; }
function setSaveIndicator(state) {
if (state === 'saving') { saveIndicator.textContent = 'Saving…'; saveIndicator.className = 'saving'; }
else { saveIndicator.textContent = 'Saved'; saveIndicator.className = ''; }
}
function onPong(msg) {
const sentAt = pendingPings.get(msg.ping_id);
if (!sentAt) return;
pendingPings.delete(msg.ping_id);
const rtt = performance.now() - sentAt;
latencies.push(rtt);
if (latencies.length > MAX_LATENCIES) latencies.shift();
updateLatencyUI();
}
function updateLatencyUI() {
if (latencies.length === 0) return;
const sorted = [...latencies].sort((a, b) => a - b);
const getP = (p) => {
const idx = Math.floor((p / 100) * (sorted.length - 1));
return Math.round(sorted[idx]);
};
latP90.textContent = getP(90) + 'ms';
latP95.textContent = getP(95) + 'ms';
latP99.textContent = getP(99) + 'ms';
}
let heartbeatId = null;
function startHeartbeat() {
clearInterval(heartbeatId);
heartbeatId = setInterval(() => {
const id = ++pingIdCounter;
pendingPings.set(id, performance.now());
send({ type: 'ping', ping_id: id });
}, 1000);
}
function stopHeartbeat() { clearInterval(heartbeatId); }
function send(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }
function esc(str) {
return String(str)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Toolbar buttons ────────────────────────────────────────────────────────────
shareBtn.addEventListener('click', () => {
shareLinkEl.value = `${location.origin}${location.pathname}?doc=${docId}`;
shareModal.classList.add('open');
shareLinkEl.select();
});
modalClose.addEventListener('click', () => shareModal.classList.remove('open'));
shareModal.addEventListener('click', e => { if (e.target === shareModal) shareModal.classList.remove('open'); });
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(shareLinkEl.value).then(() => {
copyBtn.textContent = 'βœ“ Copied';
copyBtn.classList.add('copied');
setTimeout(() => { copyBtn.textContent = 'Copy'; copyBtn.classList.remove('copied'); }, 2000);
});
});
newDocBtn.addEventListener('click', async () => {
const res = await fetch('/api/docs', { method: 'POST' });
const data = await res.json();
window.location.href = data.url;
});
// ── Boot ───────────────────────────────────────────────────────────────────────
connect();
})();
</script>
</body>
</html>