Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at 9199d4f348146b93a8d99c8a2fb6f3bf911d45ca 846 lines 35 kB view raw
1{{template "base" .}} 2{{define "head"}} 3<link rel="stylesheet" href="/static/css/editor.css"> 4<link rel="stylesheet" href="/static/css/markdown.css"> 5{{end}} 6 7{{define "content"}} 8{{with .Content}} 9<div class="editor-page"> 10 <div class="editor-toolbar"> 11 <div class="breadcrumb"> 12 <a href="/">Documents</a> 13 <span>/</span> 14 <input type="text" id="doc-title" value="{{.Title}}" placeholder="Document title" class="title-input"> 15 </div> 16 <div class="toolbar-actions"> 17 {{if .IsCollaborator}} 18 <div id="presence-list" class="presence-list" title="Active collaborators"></div> 19 {{end}} 20 {{if .IsOwner}} 21 <button class="btn btn-sm btn-outline" id="btn-share" onclick="generateInvite()">Share</button> 22 {{end}} 23 <button class="btn btn-sm btn-outline source-only active" id="btn-preview" onclick="togglePreview()">Preview</button> 24 <button class="btn btn-sm btn-outline source-only" id="btn-wrap" onclick="toggleWrap()">Wrap</button> 25 <button class="btn btn-sm btn-outline rich-only" id="btn-undo" onclick="richUndo()" title="Undo (⌘Z)"></button> 26 <button class="btn btn-sm btn-outline rich-only" id="btn-redo" onclick="richRedo()" title="Redo (⌘⇧Z)"></button> 27 <button class="btn btn-sm btn-outline" id="btn-source" onclick="toggleSourceMode()">Source</button> 28 <span id="save-status"></span> 29 <button class="btn btn-sm" id="btn-save" onclick="saveDocument()">Save</button> 30 <a href="/docs/{{.RKey}}" class="btn btn-sm btn-outline">View</a> 31 </div> 32 </div> 33 34 <!-- Rich text editor (default) --> 35 <div id="editor-rich" class="editor-rich"></div> 36 37 <!-- Comment button (shown on paragraph hover/selection) --> 38 <button id="comment-btn" class="comment-btn" style="display:none" onclick="openCommentForm()">Comment</button> 39 40 <!-- Comment form (floating) --> 41 <div id="comment-form" class="comment-form" style="display:none"> 42 <textarea id="comment-text" placeholder="Add a comment..." rows="3"></textarea> 43 <div class="comment-form-actions"> 44 <button class="btn btn-sm" onclick="submitComment()">Post</button> 45 <button class="btn btn-sm btn-outline" onclick="closeCommentForm()">Cancel</button> 46 </div> 47 </div> 48 49 <!-- Link editing tooltip --> 50 <div id="link-tooltip" class="link-tooltip"> 51 <input type="url" id="link-tooltip-input" placeholder="https://" spellcheck="false"> 52 <button id="link-tooltip-confirm">Update</button> 53 <button id="link-tooltip-remove">Remove</button> 54 <button id="link-tooltip-cancel"></button> 55 </div> 56 57 <!-- Source editor (CodeMirror + preview split) --> 58 <div id="editor-source" class="editor-split" style="display:none"> 59 <div class="editor-pane"> 60 <textarea id="editor-textarea" style="display:none">{{if .Content}}{{.Content.Text.RawMarkdown}}{{end}}</textarea> 61 <div id="editor"></div> 62 </div> 63 <div class="preview-pane"> 64 <div id="preview" class="markdown-body"></div> 65 </div> 66 </div> 67</div> 68 69<!-- Invite modal --> 70{{if .IsOwner}} 71<div id="invite-modal" class="invite-modal" style="display:none"> 72 <div class="invite-modal-box"> 73 <div class="invite-modal-header"> 74 <span>Share document</span> 75 <button class="invite-modal-close" onclick="closeInviteModal()"></button> 76 </div> 77 <div id="invite-modal-body" class="invite-modal-body"> 78 <p>Generating invite link...</p> 79 </div> 80 </div> 81</div> 82{{end}} 83 84<!-- Comment sidebar --> 85{{if .IsCollaborator}} 86<div id="comment-sidebar" class="comment-sidebar"> 87 <div class="comment-sidebar-header">Comments</div> 88 <div id="comment-threads" class="comment-threads"></div> 89</div> 90{{end}} 91{{end}} 92{{end}} 93 94{{define "scripts"}} 95<script type="module"> 96 import {EditorView, basicSetup, markdown, oneDark, Compartment, Annotation} from '/static/vendor/editor.js'; 97 import { 98 Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx, 99 commonmark, 100 listener, listenerCtx, 101 history, undoCommand, redoCommand, callCommand, 102 collab, sendableSteps, receiveTransaction, getVersion, Step, 103 } from '/static/vendor/milkdown.js'; 104 import { CollabClient } from '/static/collab-client.js'; 105 106 const textarea = document.getElementById('editor-textarea'); 107 const previewEl = document.getElementById('preview'); 108 const saveStatus = document.getElementById('save-status'); 109 const titleInput = document.getElementById('doc-title'); 110 const rkey = '{{.Content.RKey}}'; 111 const accessToken = '{{.Content.AccessToken}}'; 112 const isCollaborator = {{if .Content.IsCollaborator}}true{{else}}false{{end}}; 113 const ownerDID = '{{.Content.OwnerDID}}'; // empty string when current user is owner 114 115 // Fetch the authoritative step version for this document. 116 let serverVersion = 0; 117 try { 118 const vResp = await fetch(`/api/docs/${rkey}/steps?since=0`); 119 if (vResp.ok) { 120 const vData = await vResp.json(); 121 serverVersion = vData.version || 0; 122 } 123 } catch(e) { /* start at 0 */ } 124 125 const myClientID = accessToken || Math.random().toString(36).slice(2); 126 127 const STORAGE_KEY = 'editor-mode'; 128 let currentMode = localStorage.getItem(STORAGE_KEY) || 'rich'; // 'rich' | 'source' 129 130 let autoSaveTimer = null; 131 132 // Annotation to tag dispatches that originate from remote edits, 133 // so the update listener can skip re-broadcasting them. 134 const remoteEditAnnotation = Annotation.define(); 135 136 // ── Shared helpers ──────────────────────────────────────────────────────── 137 138 function isDark() { 139 const stored = localStorage.getItem('theme'); 140 if (stored) return stored === 'dark'; 141 return window.matchMedia('(prefers-color-scheme: dark)').matches; 142 } 143 144 function scheduleAutoSave(content) { 145 clearTimeout(autoSaveTimer); 146 saveStatus.textContent = 'Unsaved changes'; 147 saveStatus.className = 'status-unsaved'; 148 autoSaveTimer = setTimeout(async () => { 149 try { 150 await fetch(`/api/docs/${rkey}/autosave`, { 151 method: 'PUT', 152 headers: {'Content-Type': 'application/json'}, 153 body: JSON.stringify({content, title: titleInput.value, ownerDID}), 154 }); 155 saveStatus.textContent = 'Auto-saved'; 156 saveStatus.className = 'status-saved'; 157 } catch (e) { 158 saveStatus.textContent = 'Save failed'; 159 saveStatus.className = 'status-error'; 160 } 161 }, 2000); 162 } 163 164 function getMarkdown() { 165 if (currentMode === 'source') { 166 return cmView.state.doc.toString(); 167 } else { 168 return milkdownEditor.action((ctx) => { 169 const editorView = ctx.get(editorViewCtx); 170 const serializer = ctx.get(serializerCtx); 171 return serializer(editorView.state.doc); 172 }); 173 } 174 } 175 176 // ── CodeMirror (source mode) ────────────────────────────────────────────── 177 178 const baseTheme = EditorView.theme({ 179 '&': {height: '100%', fontSize: '14px'}, 180 '.cm-scroller': {overflow: 'auto'}, 181 '.cm-content': {fontFamily: '"JetBrains Mono", "Fira Code", monospace'}, 182 }); 183 184 const darkCompartment = new Compartment(); 185 const wrapCompartment = new Compartment(); 186 187 const cmView = new EditorView({ 188 doc: textarea.value, 189 extensions: [ 190 basicSetup, 191 markdown(), 192 baseTheme, 193 darkCompartment.of(isDark() ? oneDark : []), 194 wrapCompartment.of([]), 195 EditorView.updateListener.of((update) => { 196 if (update.docChanged && currentMode === 'source') { 197 const content = update.state.doc.toString(); 198 updatePreview(content); 199 if (!update.transactions.some(tr => tr.annotation(remoteEditAnnotation))) { 200 scheduleAutoSave(content); 201 // Extract granular deltas from the ChangeSet. 202 // fromA/toA are positions in the OLD document (pre-change), 203 // which is what the server's OT engine needs. 204 const deltas = []; 205 update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { 206 deltas.push({ from: fromA, to: toA, insert: inserted.toString() }); 207 }); 208 if (deltas.length > 0) { 209 const pmSteps = deltas.map(d => ({type: 'text-patch', from: d.from, to: d.to, insert: d.insert})); 210 collabClient.sendSteps(pmSteps); 211 } 212 } 213 } 214 }), 215 ], 216 parent: document.getElementById('editor'), 217 }); 218 219 // ── CollabClient (step-authority protocol) ──────────────────────────────── 220 221 // Guard against applying a remote edit while we're already applying one 222 // (prevents echo loops). Moved here from the WebSocket section so collabClient 223 // can reference it during initialization. 224 let applyingRemote = false; 225 226 const collabClient = new CollabClient(rkey, serverVersion, (remoteSteps) => { 227 if (currentMode === 'source' && cmView) { 228 // Apply text-patch steps to CM without triggering our own send. 229 const changes = []; 230 let offset = 0; 231 for (const step of remoteSteps) { 232 if (step.type !== 'text-patch') continue; 233 const from = step.from + offset; 234 const to = step.to + offset; 235 const insert = step.insert || ''; 236 changes.push({ from, to, insert }); 237 offset += insert.length - (step.to - step.from); 238 } 239 if (changes.length === 0) return; 240 applyingRemote = true; 241 try { 242 cmView.dispatch({ 243 changes, 244 annotations: [remoteEditAnnotation.of(true)], 245 }); 246 } finally { 247 applyingRemote = false; 248 } 249 } else if (currentMode === 'rich' && milkdownEditor) { 250 // Apply PM steps to the Milkdown/ProseMirror editor without re-creating it. 251 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 252 const schema = pmView.state.schema; 253 const pmSteps = []; 254 const clientIDs = []; 255 for (const step of remoteSteps) { 256 if (step.type !== 'pm-step') continue; 257 try { 258 pmSteps.push(Step.fromJSON(schema, JSON.parse(step.json))); 259 clientIDs.push('remote'); 260 } catch(e) { 261 console.warn('CollabClient: failed to parse PM step', e); 262 } 263 } 264 if (pmSteps.length === 0) return; 265 applyingRemote = true; 266 try { 267 const tr = receiveTransaction(pmView.state, pmSteps, clientIDs); 268 pmView.dispatch(tr); 269 } finally { 270 applyingRemote = false; 271 } 272 } 273 }); 274 collabClient.setClientID(myClientID); 275 276 async function updatePreview(content) { 277 try { 278 const resp = await fetch('/api/render', { 279 method: 'POST', 280 headers: {'Content-Type': 'application/json'}, 281 body: JSON.stringify({content}), 282 }); 283 const data = await resp.json(); 284 previewEl.innerHTML = data.html; 285 } catch (e) { 286 console.error('Preview error:', e); 287 } 288 } 289 290 let wrapEnabled = false; 291 window.toggleWrap = function() { 292 wrapEnabled = !wrapEnabled; 293 cmView.dispatch({ effects: wrapCompartment.reconfigure(wrapEnabled ? EditorView.lineWrapping : []) }); 294 document.getElementById('btn-wrap').classList.toggle('active', wrapEnabled); 295 }; 296 297 let previewVisible = true; 298 window.togglePreview = function() { 299 previewVisible = !previewVisible; 300 document.querySelector('.preview-pane').style.display = previewVisible ? '' : 'none'; 301 document.getElementById('btn-preview').classList.toggle('active', previewVisible); 302 }; 303 304 window.__cmSetTheme = function(theme) { 305 cmView.dispatch({ 306 effects: darkCompartment.reconfigure(theme === 'dark' ? oneDark : []), 307 }); 308 }; 309 310 // ── Milkdown (rich text mode) ───────────────────────────────────────────── 311 312 let milkdownEditor = null; 313 314 async function createMilkdownEditor(initialMarkdown) { 315 const container = document.getElementById('editor-rich'); 316 container.innerHTML = ''; 317 318 milkdownEditor = await Editor.make() 319 .config((ctx) => { 320 ctx.set(rootCtx, container); 321 ctx.set(defaultValueCtx, initialMarkdown); 322 }) 323 .use(commonmark) 324 .use(history) 325 .use(listener) 326 .config((ctx) => { 327 ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => { 328 if (markdown === prevMarkdown || applyingRemote) return; 329 scheduleAutoSave(markdown); 330 // Use prosemirror-collab to extract pending steps from the PM state. 331 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 332 const sendable = sendableSteps(pmView.state); 333 if (sendable) { 334 const stepsJSON = sendable.steps.map(s => JSON.stringify(s.toJSON())); 335 collabClient.sendSteps(stepsJSON.map(j => ({type: 'pm-step', json: j}))); 336 } 337 }); 338 }) 339 .create(); 340 341 return milkdownEditor; 342 } 343 344 // ── Undo / Redo ─────────────────────────────────────────────────────────── 345 346 window.richUndo = function() { 347 if (milkdownEditor) milkdownEditor.action(callCommand(undoCommand.key)); 348 }; 349 window.richRedo = function() { 350 if (milkdownEditor) milkdownEditor.action(callCommand(redoCommand.key)); 351 }; 352 353 // ── Mode switching ──────────────────────────────────────────────────────── 354 355 function applyMode(mode, animate) { 356 const richEl = document.getElementById('editor-rich'); 357 const sourceEl = document.getElementById('editor-source'); 358 const sourceOnlyBtns = document.querySelectorAll('.source-only'); 359 const richOnlyBtns = document.querySelectorAll('.rich-only'); 360 const sourceBtn = document.getElementById('btn-source'); 361 362 if (mode === 'source') { 363 richEl.style.display = 'none'; 364 sourceEl.style.display = ''; 365 sourceOnlyBtns.forEach(b => b.style.display = 'inline-block'); 366 richOnlyBtns.forEach(b => b.style.display = 'none'); 367 sourceBtn.classList.add('active'); 368 } else { 369 richEl.style.display = ''; 370 sourceEl.style.display = 'none'; 371 sourceOnlyBtns.forEach(b => b.style.display = 'none'); 372 richOnlyBtns.forEach(b => b.style.display = ''); 373 sourceBtn.classList.remove('active'); 374 } 375 } 376 377 window.toggleSourceMode = async function() { 378 const nextMode = currentMode === 'rich' ? 'source' : 'rich'; 379 380 if (nextMode === 'source') { 381 // rich → source: extract markdown from Milkdown, load into CodeMirror 382 const md = getMarkdown(); 383 const doc = cmView.state.doc; 384 cmView.dispatch({ changes: { from: 0, to: doc.length, insert: md } }); 385 updatePreview(md); 386 } else { 387 // source → rich: extract markdown from CodeMirror, recreate Milkdown 388 const md = cmView.state.doc.toString(); 389 await createMilkdownEditor(md); 390 } 391 392 currentMode = nextMode; 393 localStorage.setItem(STORAGE_KEY, currentMode); 394 applyMode(currentMode); 395 }; 396 397 // ── Save ────────────────────────────────────────────────────────────────── 398 399 titleInput.addEventListener('input', () => { 400 scheduleAutoSave(getMarkdown()); 401 }); 402 403 window.saveDocument = async function() { 404 const content = getMarkdown(); 405 try { 406 const resp = await fetch(`/api/docs/${rkey}/save`, { 407 method: 'POST', 408 headers: {'Content-Type': 'application/json'}, 409 body: JSON.stringify({content, title: titleInput.value, ownerDID}), 410 }); 411 if (resp.ok) { 412 saveStatus.textContent = 'Saved!'; 413 saveStatus.className = 'status-saved'; 414 } 415 } catch (e) { 416 saveStatus.textContent = 'Save failed'; 417 saveStatus.className = 'status-error'; 418 } 419 }; 420 421 // ── Link tooltip ────────────────────────────────────────────────────────── 422 423 const linkTooltipEl = document.getElementById('link-tooltip'); 424 const linkInput = document.getElementById('link-tooltip-input'); 425 const linkConfirmBtn = document.getElementById('link-tooltip-confirm'); 426 const linkRemoveBtn = document.getElementById('link-tooltip-remove'); 427 const linkCancelBtn = document.getElementById('link-tooltip-cancel'); 428 429 let linkTooltipState = null; // { pmView, pos } 430 431 function findMarkExtent(state, searchPos, markType) { 432 try { 433 const $pos = state.doc.resolve(Math.min(searchPos, state.doc.content.size - 1)); 434 const parent = $pos.parent; 435 const parentStart = $pos.start(); 436 let currentHref = null; 437 parent.forEach((node, offset) => { 438 const nodeStart = parentStart + offset; 439 const nodeEnd = nodeStart + node.nodeSize; 440 const lm = markType.isInSet(node.marks); 441 if (lm && nodeStart <= searchPos && searchPos <= nodeEnd) currentHref = lm.attrs.href; 442 }); 443 if (!currentHref) return { from: -1, to: -1 }; 444 let linkFrom = -1, linkTo = -1; 445 parent.forEach((node, offset) => { 446 const lm = markType.isInSet(node.marks); 447 if (lm && lm.attrs.href === currentHref) { 448 const nodeStart = parentStart + offset; 449 if (linkFrom === -1) linkFrom = nodeStart; 450 linkTo = nodeStart + node.nodeSize; 451 } 452 }); 453 return { from: linkFrom, to: linkTo }; 454 } catch(e) { return { from: -1, to: -1 }; } 455 } 456 457 function showLinkTooltip(pmView, mark, pos) { 458 linkTooltipState = { pmView, pos }; 459 linkInput.value = mark.attrs.href || ''; 460 const coords = pmView.coordsAtPos(pos); 461 linkTooltipEl.style.left = Math.max(8, coords.left) + 'px'; 462 linkTooltipEl.style.top = (coords.bottom + 8) + 'px'; 463 linkTooltipEl.classList.add('visible'); 464 } 465 466 function hideLinkTooltip() { 467 linkTooltipEl.classList.remove('visible'); 468 linkTooltipState = null; 469 } 470 471 // Prevent buttons from stealing editor focus 472 [linkConfirmBtn, linkRemoveBtn, linkCancelBtn].forEach(btn => { 473 btn.addEventListener('mousedown', e => e.preventDefault()); 474 }); 475 476 linkCancelBtn.addEventListener('click', () => hideLinkTooltip()); 477 478 linkConfirmBtn.addEventListener('click', () => { 479 if (!linkTooltipState) return; 480 const { pmView, pos } = linkTooltipState; 481 const newHref = linkInput.value.trim(); 482 if (!newHref) return; 483 const { state, dispatch } = pmView; 484 const linkType = state.schema.marks.link; 485 const { from, to } = findMarkExtent(state, pos, linkType); 486 if (from === -1) return; 487 dispatch(state.tr.addMark(from, to, linkType.create({ href: newHref }))); 488 hideLinkTooltip(); 489 pmView.focus(); 490 }); 491 492 linkRemoveBtn.addEventListener('click', () => { 493 if (!linkTooltipState) return; 494 const { pmView, pos } = linkTooltipState; 495 const { state, dispatch } = pmView; 496 const linkType = state.schema.marks.link; 497 const { from, to } = findMarkExtent(state, pos, linkType); 498 if (from === -1) return; 499 dispatch(state.tr.removeMark(from, to, linkType)); 500 hideLinkTooltip(); 501 pmView.focus(); 502 }); 503 504 linkInput.addEventListener('keydown', e => { 505 if (e.key === 'Enter') linkConfirmBtn.click(); 506 if (e.key === 'Escape') hideLinkTooltip(); 507 }); 508 509 function checkForLinkTooltip() { 510 if (currentMode !== 'rich' || !milkdownEditor) return; 511 try { 512 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 513 const { selection, schema, doc } = pmView.state; 514 const linkType = schema.marks.link; 515 const pos = Math.min(selection.from, doc.content.size - 1); 516 const marks = doc.resolve(pos).marks(); 517 const linkMark = marks.find(m => m.type === linkType); 518 if (linkMark) showLinkTooltip(pmView, linkMark, pos); 519 else hideLinkTooltip(); 520 } catch(e) { hideLinkTooltip(); } 521 } 522 523 document.getElementById('editor-rich').addEventListener('click', () => setTimeout(checkForLinkTooltip, 0)); 524 document.getElementById('editor-rich').addEventListener('keyup', checkForLinkTooltip); 525 526 document.addEventListener('click', e => { 527 if (!linkTooltipEl.contains(e.target) && !document.getElementById('editor-rich').contains(e.target)) { 528 hideLinkTooltip(); 529 } 530 }); 531 532 // ── Invite ──────────────────────────────────────────────────────────────── 533 534 window.generateInvite = async function generateInvite() { 535 const modal = document.getElementById('invite-modal'); 536 const body = document.getElementById('invite-modal-body'); 537 if (!modal) return; 538 body.innerHTML = '<p>Generating invite link...</p>'; 539 modal.style.display = 'flex'; 540 541 try { 542 const resp = await fetch(`/api/docs/${rkey}/invite`, { method: 'POST' }); 543 const data = await resp.json(); 544 if (!resp.ok) throw new Error(data.error || resp.statusText); 545 const link = data.invite_url || data.inviteLink || data.url || ''; 546 body.innerHTML = ` 547 <p style="margin:0 0 0.5rem;font-size:0.85rem;color:var(--text-muted)"> 548 Share this link. It expires in 7 days and can be used once. 549 </p> 550 <div style="display:flex;gap:0.5rem;align-items:center"> 551 <input id="invite-link-input" type="text" value="${escHtml(link)}" readonly 552 style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius); 553 padding:0.4rem 0.5rem;font-size:0.85rem;color:var(--text);outline:none"> 554 <button class="btn btn-sm" onclick="copyInviteLink()">Copy</button> 555 </div> 556 <p id="invite-copy-msg" style="margin:0.4rem 0 0;font-size:0.8rem;color:var(--primary);display:none">Copied!</p> 557 `; 558 } catch (e) { 559 body.innerHTML = `<p style="color:var(--danger)">Failed to generate invite: ${escHtml(e.message)}</p>`; 560 } 561 } 562 563 window.copyInviteLink = function copyInviteLink() { 564 const input = document.getElementById('invite-link-input'); 565 if (!input) return; 566 navigator.clipboard.writeText(input.value).then(() => { 567 const msg = document.getElementById('invite-copy-msg'); 568 if (msg) { msg.style.display = 'block'; setTimeout(() => msg.style.display = 'none', 2000); } 569 }); 570 } 571 572 window.closeInviteModal = function closeInviteModal() { 573 const modal = document.getElementById('invite-modal'); 574 if (modal) modal.style.display = 'none'; 575 } 576 577 // Close invite modal on backdrop click 578 document.getElementById('invite-modal')?.addEventListener('click', e => { 579 if (e.target === document.getElementById('invite-modal')) closeInviteModal(); 580 }); 581 582 // ── WebSocket / Collaboration ───────────────────────────────────────────── 583 584 let ws = null; 585 let wsReconnectDelay = 1000; 586 let wsReconnectTimer = null; 587 let wsPingTimer = null; 588 let wsMissedPings = 0; 589 590 function connectWebSocket() { 591 if (!accessToken) return; 592 593 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 594 const ownerParam = ownerDID ? `&owner_did=${encodeURIComponent(ownerDID)}` : ''; 595 const wsUrl = `${protocol}//${window.location.host}/ws/docs/${rkey}?access_token=${encodeURIComponent(accessToken)}&dpop_proof=placeholder${ownerParam}`; 596 597 ws = new WebSocket(wsUrl); 598 599 ws.onopen = () => { 600 wsReconnectDelay = 1000; 601 wsMissedPings = 0; 602 startHeartbeat(); 603 }; 604 605 ws.onmessage = (event) => { 606 try { 607 const msg = JSON.parse(event.data); 608 handleWSMessage(msg); 609 } catch (e) { 610 console.error('WS parse error:', e); 611 } 612 }; 613 614 ws.onclose = () => { 615 stopHeartbeat(); 616 ws = null; 617 updatePresence([]); 618 scheduleReconnect(); 619 }; 620 621 ws.onerror = () => { 622 closeWS(); 623 }; 624 } 625 626 function scheduleReconnect() { 627 clearTimeout(wsReconnectTimer); 628 wsReconnectTimer = setTimeout(() => { 629 connectWebSocket(); 630 wsReconnectDelay = Math.min(wsReconnectDelay * 2, 30000); 631 }, wsReconnectDelay); 632 } 633 634 function startHeartbeat() { 635 stopHeartbeat(); 636 wsPingTimer = setInterval(() => { 637 if (ws && ws.readyState === WebSocket.OPEN) { 638 ws.send(JSON.stringify({ type: 'ping' })); 639 wsMissedPings++; 640 if (wsMissedPings >= 3) { 641 closeWS(); 642 } 643 } 644 }, 30000); 645 } 646 647 function stopHeartbeat() { 648 clearInterval(wsPingTimer); 649 } 650 651 function handleWSMessage(msg) { 652 switch (msg.type) { 653 case 'presence': 654 updatePresence(msg.users || []); 655 break; 656 case 'pong': 657 wsMissedPings = 0; 658 break; 659 case 'steps': 660 collabClient.handleWSMessage(msg, myClientID); 661 break; 662 case 'edit': 663 applyRemoteEdit(msg); // legacy full-replace path 664 break; 665 case 'sync': 666 applyRemoteEdit(msg.content); // sync is always full-content string 667 break; 668 } 669 } 670 671 function applyRemoteEdit(msg) { 672 // Legacy sync fallback — only used for 'sync' and legacy 'edit' message types. 673 // Remote edits via the new step protocol go through CollabClient instead. 674 if (applyingRemote) return; 675 const content = typeof msg === 'string' ? msg : msg.content; 676 if (!content) return; 677 678 if (currentMode === 'source' && cmView) { 679 if (cmView.state.doc.toString() !== content) { 680 applyingRemote = true; 681 try { 682 cmView.dispatch({ 683 changes: { from: 0, to: cmView.state.doc.length, insert: content }, 684 annotations: [remoteEditAnnotation.of(true)], 685 }); 686 } finally { 687 applyingRemote = false; 688 } 689 updatePreview(content); 690 } 691 } 692 // Rich mode no longer falls back to full recreate here; 693 // remote steps are applied via CollabClient in Task 8. 694 } 695 696 function closeWS() { 697 if (!ws) return; 698 ws.close(); 699 ws = null; 700 stopHeartbeat(); 701 } 702 703 // ── Presence ────────────────────────────────────────────────────────────── 704 705 function updatePresence(users) { 706 const list = document.getElementById('presence-list'); 707 if (!list) return; 708 list.innerHTML = users.map(u => ` 709 <span class="presence-avatar" style="background:${u.color}" title="${escHtml(u.name || u.did)}"></span> 710 `).join(''); 711 } 712 713 function escHtml(str) { 714 return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 715 } 716 717 // ── Comments ────────────────────────────────────────────────────────────── 718 719 let activeCommentParagraphId = null; 720 721 const commentBtn = document.getElementById('comment-btn'); 722 const commentForm = document.getElementById('comment-form'); 723 const commentTextEl = document.getElementById('comment-text'); 724 725 window.openCommentForm = function openCommentForm() { 726 if (!commentBtn || !commentForm) return; 727 const rect = commentBtn.getBoundingClientRect(); 728 commentForm.style.top = rect.bottom + window.scrollY + 4 + 'px'; 729 commentForm.style.left = Math.max(8, rect.left) + 'px'; 730 commentForm.style.display = 'block'; 731 commentTextEl.value = ''; 732 commentTextEl.focus(); 733 } 734 735 window.closeCommentForm = function closeCommentForm() { 736 if (commentForm) commentForm.style.display = 'none'; 737 if (commentBtn) commentBtn.style.display = 'none'; 738 activeCommentParagraphId = null; 739 } 740 741 window.submitComment = async function submitComment() { 742 if (!activeCommentParagraphId) return; 743 const text = commentTextEl.value.trim(); 744 if (!text) return; 745 746 try { 747 const resp = await fetch(`/api/docs/${rkey}/comments`, { 748 method: 'POST', 749 headers: { 'Content-Type': 'application/json' }, 750 body: JSON.stringify({ paragraphId: activeCommentParagraphId, text }), 751 }); 752 if (!resp.ok) throw new Error(await resp.text()); 753 closeCommentForm(); 754 loadComments(); 755 } catch (e) { 756 console.error('Comment post failed:', e); 757 } 758 } 759 760 commentTextEl && commentTextEl.addEventListener('keydown', e => { 761 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitComment(); 762 if (e.key === 'Escape') closeCommentForm(); 763 }); 764 765 // Close comment form on outside click 766 document.addEventListener('click', e => { 767 if (commentForm && commentForm.style.display !== 'none') { 768 if (!commentForm.contains(e.target) && e.target !== commentBtn) { 769 closeCommentForm(); 770 } 771 } 772 }); 773 774 function renderCommentThreads(comments) { 775 const container = document.getElementById('comment-threads'); 776 if (!container) return; 777 778 if (!comments || comments.length === 0) { 779 container.innerHTML = '<p class="comment-empty">No comments yet.</p>'; 780 return; 781 } 782 783 // Group by paragraphId 784 const byParagraph = {}; 785 for (const c of comments) { 786 const pid = c.paragraphId || 'general'; 787 if (!byParagraph[pid]) byParagraph[pid] = []; 788 byParagraph[pid].push(c); 789 } 790 791 container.innerHTML = Object.entries(byParagraph).map(([pid, thread]) => ` 792 <div class="comment-thread" data-paragraph="${escHtml(pid)}"> 793 <div class="comment-thread-label">¶ ${escHtml(pid)}</div> 794 ${thread.map(c => ` 795 <div class="comment-item"> 796 <div class="comment-author">${escHtml(c.authorName || c.author)}</div> 797 <div class="comment-text">${escHtml(c.text)}</div> 798 <div class="comment-time">${formatTime(c.createdAt)}</div> 799 </div> 800 `).join('')} 801 </div> 802 `).join(''); 803 } 804 805 function formatTime(ts) { 806 if (!ts) return ''; 807 try { return new Date(ts).toLocaleString(); } catch { return ts; } 808 } 809 810 async function loadComments() { 811 if (!accessToken) return; 812 try { 813 const resp = await fetch(`/api/docs/${rkey}/comments`); 814 if (!resp.ok) return; 815 const comments = await resp.json(); 816 renderCommentThreads(comments); 817 } catch (e) { 818 console.error('Load comments failed:', e); 819 } 820 } 821 822 // ── Init ────────────────────────────────────────────────────────────────── 823 824 const initialMarkdown = textarea.value; 825 826 // Always create Milkdown (needed even if starting in source mode for first switch) 827 await createMilkdownEditor(initialMarkdown); 828 829 // If starting in source mode, do initial preview render 830 if (currentMode === 'source') { 831 updatePreview(initialMarkdown); 832 } 833 834 applyMode(currentMode); 835 836 // Start collaboration features (both owner and collaborators join the WS room) 837 if (accessToken) { 838 connectWebSocket(); 839 loadComments(); 840 } 841 842 window.addEventListener('beforeunload', () => { 843 closeWS(); 844 }); 845</script> 846{{end}}