Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at 837595af6d7cd0147ae213965883e157a1ab43fa 915 lines 38 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 or .IsCollaborator .IsOwner}} 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, editorViewOptionsCtx, serializerCtx, prosePluginsCtx, 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 // Register the prosemirror-collab plugin into EditorState (not EditorView) 323 // so sendableSteps/receiveTransaction have a state slot to read from. 324 ctx.update(prosePluginsCtx, (plugins) => [ 325 ...plugins, 326 collab({ version: collabClient.version, clientID: myClientID }), 327 ]); 328 // Override dispatchTransaction to notify CollabClient after every 329 // local transaction. CollabClient calls sendableSteps itself at 330 // flush time, so it always sends exactly the current unconfirmed 331 // set — never duplicates across keystrokes. 332 ctx.update(editorViewOptionsCtx, (prev) => ({ 333 ...prev, 334 dispatchTransaction: function(tr) { 335 const newState = this.state.apply(tr); 336 this.updateState(newState); 337 if (!applyingRemote) { 338 collabClient.notifyPMChange(); 339 if (tr.docChanged) { 340 try { 341 const serializer = milkdownEditor.action(c => c.get(serializerCtx)); 342 scheduleAutoSave(serializer(newState.doc)); 343 } catch(_) {} 344 } 345 } 346 }, 347 })); 348 }) 349 .use(commonmark) 350 .use(history) 351 .use(listener) 352 .config((ctx) => { 353 ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => { 354 // Solo-mode auto-save fallback (no collab session). 355 if (markdown === prevMarkdown || applyingRemote || accessToken) return; 356 scheduleAutoSave(markdown); 357 }); 358 }) 359 .create(); 360 361 // Register PM handlers so CollabClient reads sendableSteps and confirms 362 // its own steps via receiveTransaction (advancing the collab plugin version). 363 collabClient.setPMHandlers( 364 () => { 365 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 366 return sendableSteps(pmView.state); 367 }, 368 (steps, clientIDs) => { 369 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 370 applyingRemote = true; 371 try { 372 const tr = receiveTransaction(pmView.state, steps, clientIDs); 373 pmView.dispatch(tr); 374 } finally { 375 applyingRemote = false; 376 } 377 } 378 ); 379 380 return milkdownEditor; 381 } 382 383 // ── Undo / Redo ─────────────────────────────────────────────────────────── 384 385 window.richUndo = function() { 386 if (milkdownEditor) milkdownEditor.action(callCommand(undoCommand.key)); 387 }; 388 window.richRedo = function() { 389 if (milkdownEditor) milkdownEditor.action(callCommand(redoCommand.key)); 390 }; 391 392 // ── Mode switching ──────────────────────────────────────────────────────── 393 394 function applyMode(mode, animate) { 395 const richEl = document.getElementById('editor-rich'); 396 const sourceEl = document.getElementById('editor-source'); 397 const sourceOnlyBtns = document.querySelectorAll('.source-only'); 398 const richOnlyBtns = document.querySelectorAll('.rich-only'); 399 const sourceBtn = document.getElementById('btn-source'); 400 401 if (mode === 'source') { 402 richEl.style.display = 'none'; 403 sourceEl.style.display = ''; 404 sourceOnlyBtns.forEach(b => b.style.display = 'inline-block'); 405 richOnlyBtns.forEach(b => b.style.display = 'none'); 406 sourceBtn.classList.add('active'); 407 } else { 408 richEl.style.display = ''; 409 sourceEl.style.display = 'none'; 410 sourceOnlyBtns.forEach(b => b.style.display = 'none'); 411 richOnlyBtns.forEach(b => b.style.display = ''); 412 sourceBtn.classList.remove('active'); 413 } 414 } 415 416 window.toggleSourceMode = async function() { 417 const nextMode = currentMode === 'rich' ? 'source' : 'rich'; 418 419 if (nextMode === 'source') { 420 // rich → source: extract markdown from Milkdown, load into CodeMirror 421 const md = getMarkdown(); 422 const doc = cmView.state.doc; 423 cmView.dispatch({ changes: { from: 0, to: doc.length, insert: md } }); 424 updatePreview(md); 425 collabClient.setPMHandlers(null, null); // detach from stale Milkdown instance 426 } else { 427 // source → rich: extract markdown from CodeMirror, recreate Milkdown 428 const md = cmView.state.doc.toString(); 429 await createMilkdownEditor(md); // setPMHandlers called inside 430 } 431 432 currentMode = nextMode; 433 localStorage.setItem(STORAGE_KEY, currentMode); 434 applyMode(currentMode); 435 }; 436 437 // ── Save ────────────────────────────────────────────────────────────────── 438 439 titleInput.addEventListener('input', () => { 440 scheduleAutoSave(getMarkdown()); 441 }); 442 443 window.saveDocument = async function() { 444 const content = getMarkdown(); 445 try { 446 const resp = await fetch(`/api/docs/${rkey}/save`, { 447 method: 'POST', 448 headers: {'Content-Type': 'application/json'}, 449 body: JSON.stringify({content, title: titleInput.value, ownerDID}), 450 }); 451 if (resp.ok) { 452 saveStatus.textContent = 'Saved!'; 453 saveStatus.className = 'status-saved'; 454 } 455 } catch (e) { 456 saveStatus.textContent = 'Save failed'; 457 saveStatus.className = 'status-error'; 458 } 459 }; 460 461 // ── Link tooltip ────────────────────────────────────────────────────────── 462 463 const linkTooltipEl = document.getElementById('link-tooltip'); 464 const linkInput = document.getElementById('link-tooltip-input'); 465 const linkConfirmBtn = document.getElementById('link-tooltip-confirm'); 466 const linkRemoveBtn = document.getElementById('link-tooltip-remove'); 467 const linkCancelBtn = document.getElementById('link-tooltip-cancel'); 468 469 let linkTooltipState = null; // { pmView, pos } 470 471 function findMarkExtent(state, searchPos, markType) { 472 try { 473 const $pos = state.doc.resolve(Math.min(searchPos, state.doc.content.size - 1)); 474 const parent = $pos.parent; 475 const parentStart = $pos.start(); 476 let currentHref = null; 477 parent.forEach((node, offset) => { 478 const nodeStart = parentStart + offset; 479 const nodeEnd = nodeStart + node.nodeSize; 480 const lm = markType.isInSet(node.marks); 481 if (lm && nodeStart <= searchPos && searchPos <= nodeEnd) currentHref = lm.attrs.href; 482 }); 483 if (!currentHref) return { from: -1, to: -1 }; 484 let linkFrom = -1, linkTo = -1; 485 parent.forEach((node, offset) => { 486 const lm = markType.isInSet(node.marks); 487 if (lm && lm.attrs.href === currentHref) { 488 const nodeStart = parentStart + offset; 489 if (linkFrom === -1) linkFrom = nodeStart; 490 linkTo = nodeStart + node.nodeSize; 491 } 492 }); 493 return { from: linkFrom, to: linkTo }; 494 } catch(e) { return { from: -1, to: -1 }; } 495 } 496 497 function showLinkTooltip(pmView, mark, pos) { 498 linkTooltipState = { pmView, pos }; 499 linkInput.value = mark.attrs.href || ''; 500 const coords = pmView.coordsAtPos(pos); 501 linkTooltipEl.style.left = Math.max(8, coords.left) + 'px'; 502 linkTooltipEl.style.top = (coords.bottom + 8) + 'px'; 503 linkTooltipEl.classList.add('visible'); 504 } 505 506 function hideLinkTooltip() { 507 linkTooltipEl.classList.remove('visible'); 508 linkTooltipState = null; 509 } 510 511 // Prevent buttons from stealing editor focus 512 [linkConfirmBtn, linkRemoveBtn, linkCancelBtn].forEach(btn => { 513 btn.addEventListener('mousedown', e => e.preventDefault()); 514 }); 515 516 linkCancelBtn.addEventListener('click', () => hideLinkTooltip()); 517 518 linkConfirmBtn.addEventListener('click', () => { 519 if (!linkTooltipState) return; 520 const { pmView, pos } = linkTooltipState; 521 const newHref = linkInput.value.trim(); 522 if (!newHref) return; 523 const { state, dispatch } = pmView; 524 const linkType = state.schema.marks.link; 525 const { from, to } = findMarkExtent(state, pos, linkType); 526 if (from === -1) return; 527 dispatch(state.tr.addMark(from, to, linkType.create({ href: newHref }))); 528 hideLinkTooltip(); 529 pmView.focus(); 530 }); 531 532 linkRemoveBtn.addEventListener('click', () => { 533 if (!linkTooltipState) return; 534 const { pmView, pos } = linkTooltipState; 535 const { state, dispatch } = pmView; 536 const linkType = state.schema.marks.link; 537 const { from, to } = findMarkExtent(state, pos, linkType); 538 if (from === -1) return; 539 dispatch(state.tr.removeMark(from, to, linkType)); 540 hideLinkTooltip(); 541 pmView.focus(); 542 }); 543 544 linkInput.addEventListener('keydown', e => { 545 if (e.key === 'Enter') linkConfirmBtn.click(); 546 if (e.key === 'Escape') hideLinkTooltip(); 547 }); 548 549 function checkForLinkTooltip() { 550 if (currentMode !== 'rich' || !milkdownEditor) return; 551 try { 552 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 553 const { selection, schema, doc } = pmView.state; 554 const linkType = schema.marks.link; 555 const pos = Math.min(selection.from, doc.content.size - 1); 556 const marks = doc.resolve(pos).marks(); 557 const linkMark = marks.find(m => m.type === linkType); 558 if (linkMark) showLinkTooltip(pmView, linkMark, pos); 559 else hideLinkTooltip(); 560 } catch(e) { hideLinkTooltip(); } 561 } 562 563 document.getElementById('editor-rich').addEventListener('click', () => setTimeout(checkForLinkTooltip, 0)); 564 document.getElementById('editor-rich').addEventListener('keyup', checkForLinkTooltip); 565 566 document.addEventListener('click', e => { 567 if (!linkTooltipEl.contains(e.target) && !document.getElementById('editor-rich').contains(e.target)) { 568 hideLinkTooltip(); 569 } 570 }); 571 572 // ── Invite ──────────────────────────────────────────────────────────────── 573 574 window.generateInvite = async function generateInvite() { 575 const modal = document.getElementById('invite-modal'); 576 const body = document.getElementById('invite-modal-body'); 577 if (!modal) return; 578 body.innerHTML = '<p>Generating invite link...</p>'; 579 modal.style.display = 'flex'; 580 581 try { 582 const resp = await fetch(`/api/docs/${rkey}/invite`, { method: 'POST' }); 583 const data = await resp.json(); 584 if (!resp.ok) throw new Error(data.error || resp.statusText); 585 const link = data.invite_url || data.inviteLink || data.url || ''; 586 body.innerHTML = ` 587 <p style="margin:0 0 0.5rem;font-size:0.85rem;color:var(--text-muted)"> 588 Share this link. It expires in 7 days and can be used once. 589 </p> 590 <div style="display:flex;gap:0.5rem;align-items:center"> 591 <input id="invite-link-input" type="text" value="${escHtml(link)}" readonly 592 style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius); 593 padding:0.4rem 0.5rem;font-size:0.85rem;color:var(--text);outline:none"> 594 <button class="btn btn-sm" onclick="copyInviteLink()">Copy</button> 595 </div> 596 <p id="invite-copy-msg" style="margin:0.4rem 0 0;font-size:0.8rem;color:var(--primary);display:none">Copied!</p> 597 `; 598 } catch (e) { 599 body.innerHTML = `<p style="color:var(--danger)">Failed to generate invite: ${escHtml(e.message)}</p>`; 600 } 601 } 602 603 window.copyInviteLink = function copyInviteLink() { 604 const input = document.getElementById('invite-link-input'); 605 if (!input) return; 606 navigator.clipboard.writeText(input.value).then(() => { 607 const msg = document.getElementById('invite-copy-msg'); 608 if (msg) { msg.style.display = 'block'; setTimeout(() => msg.style.display = 'none', 2000); } 609 }); 610 } 611 612 window.closeInviteModal = function closeInviteModal() { 613 const modal = document.getElementById('invite-modal'); 614 if (modal) modal.style.display = 'none'; 615 } 616 617 // Close invite modal on backdrop click 618 document.getElementById('invite-modal')?.addEventListener('click', e => { 619 if (e.target === document.getElementById('invite-modal')) closeInviteModal(); 620 }); 621 622 // ── WebSocket / Collaboration ───────────────────────────────────────────── 623 624 let ws = null; 625 let wsReconnectDelay = 1000; 626 let wsReconnectTimer = null; 627 let wsPingTimer = null; 628 let wsMissedPings = 0; 629 630 function connectWebSocket() { 631 if (!accessToken) return; 632 633 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 634 const ownerParam = ownerDID ? `&owner_did=${encodeURIComponent(ownerDID)}` : ''; 635 const wsUrl = `${protocol}//${window.location.host}/ws/docs/${rkey}?access_token=${encodeURIComponent(accessToken)}&dpop_proof=placeholder${ownerParam}`; 636 637 ws = new WebSocket(wsUrl); 638 639 ws.onopen = () => { 640 wsReconnectDelay = 1000; 641 wsMissedPings = 0; 642 startHeartbeat(); 643 }; 644 645 ws.onmessage = (event) => { 646 try { 647 const msg = JSON.parse(event.data); 648 handleWSMessage(msg); 649 } catch (e) { 650 console.error('WS parse error:', e); 651 } 652 }; 653 654 ws.onclose = () => { 655 stopHeartbeat(); 656 ws = null; 657 updatePresence([]); 658 scheduleReconnect(); 659 }; 660 661 ws.onerror = () => { 662 closeWS(); 663 }; 664 } 665 666 function scheduleReconnect() { 667 clearTimeout(wsReconnectTimer); 668 wsReconnectTimer = setTimeout(() => { 669 connectWebSocket(); 670 wsReconnectDelay = Math.min(wsReconnectDelay * 2, 30000); 671 }, wsReconnectDelay); 672 } 673 674 function startHeartbeat() { 675 stopHeartbeat(); 676 wsPingTimer = setInterval(() => { 677 if (ws && ws.readyState === WebSocket.OPEN) { 678 ws.send(JSON.stringify({ type: 'ping' })); 679 wsMissedPings++; 680 if (wsMissedPings >= 3) { 681 closeWS(); 682 } 683 } 684 }, 30000); 685 } 686 687 function stopHeartbeat() { 688 clearInterval(wsPingTimer); 689 } 690 691 function handleWSMessage(msg) { 692 switch (msg.type) { 693 case 'presence': 694 updatePresence(msg.users || []); 695 break; 696 case 'pong': 697 wsMissedPings = 0; 698 break; 699 case 'steps': 700 collabClient.handleWSMessage(msg, myClientID); 701 break; 702 case 'edit': 703 applyRemoteEdit(msg); // legacy full-replace path 704 break; 705 case 'sync': 706 applyRemoteEdit(msg.content); // sync is always full-content string 707 break; 708 } 709 } 710 711 function applyRemoteEdit(msg) { 712 // Legacy sync fallback — only used for 'sync' and legacy 'edit' message types. 713 // Remote edits via the new step protocol go through CollabClient instead. 714 if (applyingRemote) return; 715 const content = typeof msg === 'string' ? msg : msg.content; 716 if (!content) return; 717 718 if (currentMode === 'source' && cmView) { 719 if (cmView.state.doc.toString() !== content) { 720 applyingRemote = true; 721 try { 722 cmView.dispatch({ 723 changes: { from: 0, to: cmView.state.doc.length, insert: content }, 724 annotations: [remoteEditAnnotation.of(true)], 725 }); 726 } finally { 727 applyingRemote = false; 728 } 729 updatePreview(content); 730 } 731 } 732 // Rich mode no longer falls back to full recreate here; 733 // remote steps are applied via CollabClient in Task 8. 734 } 735 736 function closeWS() { 737 if (!ws) return; 738 ws.close(); 739 ws = null; 740 stopHeartbeat(); 741 } 742 743 // ── Presence ────────────────────────────────────────────────────────────── 744 745 function updatePresence(users) { 746 const list = document.getElementById('presence-list'); 747 if (!list) return; 748 list.innerHTML = users.map(u => { 749 const label = escHtml(u.handle || u.name || u.did); 750 if (u.avatar) { 751 return `<img class="presence-avatar" src="${escHtml(u.avatar)}" title="${label}" alt="${label}">`; 752 } 753 return `<span class="presence-avatar" style="background:${u.color}" title="${label}"></span>`; 754 }).join(''); 755 } 756 757 function escHtml(str) { 758 return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 759 } 760 761 // ── Comments ────────────────────────────────────────────────────────────── 762 763 let activeCommentParagraphId = null; 764 765 const commentBtn = document.getElementById('comment-btn'); 766 const commentForm = document.getElementById('comment-form'); 767 const commentTextEl = document.getElementById('comment-text'); 768 769 window.openCommentForm = function openCommentForm() { 770 if (!commentBtn || !commentForm) return; 771 const rect = commentBtn.getBoundingClientRect(); 772 commentForm.style.top = rect.bottom + window.scrollY + 4 + 'px'; 773 commentForm.style.left = Math.max(8, rect.left) + 'px'; 774 commentForm.style.display = 'block'; 775 commentTextEl.value = ''; 776 commentTextEl.focus(); 777 } 778 779 window.closeCommentForm = function closeCommentForm() { 780 if (commentForm) commentForm.style.display = 'none'; 781 if (commentBtn) commentBtn.style.display = 'none'; 782 activeCommentParagraphId = null; 783 } 784 785 window.submitComment = async function submitComment() { 786 if (!activeCommentParagraphId) return; 787 const text = commentTextEl.value.trim(); 788 if (!text) return; 789 790 try { 791 const body = { paragraphId: activeCommentParagraphId, text }; 792 if (ownerDID) body.ownerDID = ownerDID; 793 const resp = await fetch(`/api/docs/${rkey}/comments`, { 794 method: 'POST', 795 headers: { 'Content-Type': 'application/json' }, 796 body: JSON.stringify(body), 797 }); 798 if (!resp.ok) throw new Error(await resp.text()); 799 closeCommentForm(); 800 loadComments(); 801 } catch (e) { 802 console.error('Comment post failed:', e); 803 } 804 } 805 806 commentTextEl && commentTextEl.addEventListener('keydown', e => { 807 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitComment(); 808 if (e.key === 'Escape') closeCommentForm(); 809 }); 810 811 // Close comment form on outside click 812 document.addEventListener('click', e => { 813 if (commentForm && commentForm.style.display !== 'none') { 814 if (!commentForm.contains(e.target) && e.target !== commentBtn) { 815 closeCommentForm(); 816 } 817 } 818 }); 819 820 function renderCommentThreads(comments) { 821 const container = document.getElementById('comment-threads'); 822 if (!container) return; 823 824 if (!comments || comments.length === 0) { 825 container.innerHTML = '<p class="comment-empty">No comments yet.</p>'; 826 return; 827 } 828 829 // Group by paragraphId 830 const byParagraph = {}; 831 for (const c of comments) { 832 const pid = c.paragraphId || 'general'; 833 if (!byParagraph[pid]) byParagraph[pid] = []; 834 byParagraph[pid].push(c); 835 } 836 837 container.innerHTML = Object.entries(byParagraph).map(([pid, thread]) => ` 838 <div class="comment-thread" data-paragraph="${escHtml(pid)}"> 839 <div class="comment-thread-label">¶ ${escHtml(pid)}</div> 840 ${thread.map(c => ` 841 <div class="comment-item"> 842 <div class="comment-author">${escHtml(c.authorName || c.author)}</div> 843 <div class="comment-text">${escHtml(c.text)}</div> 844 <div class="comment-time">${formatTime(c.createdAt)}</div> 845 </div> 846 `).join('')} 847 </div> 848 `).join(''); 849 } 850 851 function formatTime(ts) { 852 if (!ts) return ''; 853 try { return new Date(ts).toLocaleString(); } catch { return ts; } 854 } 855 856 async function loadComments() { 857 if (!accessToken) return; 858 try { 859 const qs = ownerDID ? '?ownerDID=' + encodeURIComponent(ownerDID) : ''; 860 const resp = await fetch(`/api/docs/${rkey}/comments${qs}`); 861 if (!resp.ok) return; 862 const comments = await resp.json(); 863 renderCommentThreads(comments); 864 } catch (e) { 865 console.error('Load comments failed:', e); 866 } 867 } 868 869 function setupParagraphCommentTrigger() { 870 const editorEl = document.getElementById('editor-rich'); 871 if (!editorEl) return; 872 editorEl.addEventListener('click', e => { 873 const pmEl = e.target.closest('.ProseMirror'); 874 if (!pmEl) return; 875 const paraEl = e.target.closest('p, h1, h2, h3, h4, h5, h6, li'); 876 if (!paraEl) return; 877 const siblings = Array.from(paraEl.parentElement.children); 878 const idx = siblings.indexOf(paraEl); 879 const pid = 'p-' + idx; 880 activeCommentParagraphId = pid; 881 if (commentBtn) { 882 const rect = paraEl.getBoundingClientRect(); 883 commentBtn.style.top = (rect.top + window.scrollY + rect.height / 2 - 12) + 'px'; 884 commentBtn.style.left = (rect.right + window.scrollX + 8) + 'px'; 885 commentBtn.style.display = 'block'; 886 } 887 }); 888 } 889 setupParagraphCommentTrigger(); 890 891 // ── Init ────────────────────────────────────────────────────────────────── 892 893 const initialMarkdown = textarea.value; 894 895 // Always create Milkdown (needed even if starting in source mode for first switch) 896 await createMilkdownEditor(initialMarkdown); 897 898 // If starting in source mode, do initial preview render 899 if (currentMode === 'source') { 900 updatePreview(initialMarkdown); 901 } 902 903 applyMode(currentMode); 904 905 // Start collaboration features (both owner and collaborators join the WS room) 906 if (accessToken) { 907 connectWebSocket(); 908 loadComments(); 909 } 910 911 window.addEventListener('beforeunload', () => { 912 closeWS(); 913 }); 914</script> 915{{end}}