Spaces:
Sleeping
Sleeping
| <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,'&').replace(/</g,'<') | |
| .replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| // ββ 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> | |