Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 1258 lines 53 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 {{if or .IsCollaborator .IsOwner}}<button class="btn btn-sm btn-outline active" id="btn-comments" onclick="toggleCommentSidebar()">Comments</button>{{end}} 28 <button class="btn btn-sm btn-outline" id="btn-source" onclick="toggleSourceMode()">Source</button> 29 <span id="save-status"></span> 30 <button class="btn btn-sm" id="btn-save" onclick="saveDocument()">Save</button> 31 <a href="/docs/{{.RKey}}" class="btn btn-sm btn-outline">View</a> 32 </div> 33 </div> 34 35 <!-- Rich text editor (default) --> 36 <div id="editor-rich" class="editor-rich"></div> 37 38 <!-- Comment button (shown on paragraph hover/selection) --> 39 <button id="comment-btn" class="comment-btn" style="display:none" onclick="openCommentForm()">Comment</button> 40 41 <!-- Link editing tooltip --> 42 <div id="link-tooltip" class="link-tooltip"> 43 <input type="url" id="link-tooltip-input" placeholder="https://" spellcheck="false"> 44 <button id="link-tooltip-confirm">Update</button> 45 <button id="link-tooltip-remove">Remove</button> 46 <button id="link-tooltip-cancel"></button> 47 </div> 48 49 <!-- Source editor (CodeMirror + preview split) --> 50 <div id="editor-source" class="editor-split" style="display:none"> 51 <div class="editor-pane"> 52 <textarea id="editor-textarea" style="display:none">{{if .Content}}{{.Content.Text.RawMarkdown}}{{end}}</textarea> 53 <div id="editor"></div> 54 </div> 55 <div class="preview-pane"> 56 <div id="preview" class="markdown-body"></div> 57 </div> 58 </div> 59</div> 60 61<!-- Invite modal --> 62{{if .IsOwner}} 63<div id="invite-modal" class="invite-modal" style="display:none"> 64 <div class="invite-modal-box"> 65 <div class="invite-modal-header"> 66 <span>Share document</span> 67 <button class="invite-modal-close" onclick="closeInviteModal()"></button> 68 </div> 69 <div id="invite-modal-body" class="invite-modal-body"> 70 <p>Generating invite link...</p> 71 </div> 72 </div> 73</div> 74{{end}} 75 76<!-- Comment form (floating, outside editor-page so z-index beats sidebar) --> 77<div id="comment-form" class="comment-form" style="display:none"> 78 <textarea id="comment-text" placeholder="Add a comment..." rows="3"></textarea> 79 <div class="comment-form-actions"> 80 <button class="btn btn-sm" onclick="submitComment()">Post</button> 81 <button class="btn btn-sm btn-outline" onclick="closeCommentForm()">Cancel</button> 82 </div> 83</div> 84 85<!-- Comment sidebar --> 86{{if or .IsCollaborator .IsOwner}} 87<div id="comment-sidebar" class="comment-sidebar"> 88 <div class="comment-sidebar-header">Comments</div> 89 <div id="comment-threads" class="comment-threads"></div> 90</div> 91{{end}} 92{{end}} 93{{end}} 94 95{{define "scripts"}} 96<script type="module"> 97 import {EditorView, basicSetup, markdown, oneDark, Compartment, Annotation} from '/static/vendor/editor.js'; 98 import { 99 Editor, rootCtx, defaultValueCtx, editorViewCtx, editorViewOptionsCtx, serializerCtx, prosePluginsCtx, 100 commonmark, 101 listener, listenerCtx, 102 history, undoCommand, redoCommand, callCommand, 103 collab, sendableSteps, receiveTransaction, getVersion, Step, 104 $markSchema, $markAttr, 105 } from '/static/vendor/milkdown.js'; 106 import { CollabClient } from '/static/collab-client.js'; 107 108 // ── Comment mark schema ─────────────────────────────────────────────────── 109 // Defines a ProseMirror mark that wraps commented text with a threadId attr. 110 // Marks move with the text automatically (undo, collab, paste all work). 111 112 const commentAttr = $markAttr('comment'); 113 114 const commentSchema = $markSchema('comment', (ctx) => ({ 115 attrs: { 116 threadId: { default: null }, 117 }, 118 inclusive: false, // typing at the boundary does NOT extend the mark 119 parseDOM: [{ 120 tag: 'span[data-thread]', 121 getAttrs: (dom) => ({ threadId: dom.getAttribute('data-thread') }), 122 }], 123 toDOM: (mark) => ['span', { 124 'data-thread': mark.attrs.threadId, 125 class: 'comment-highlight', 126 }, 0], 127 parseMarkdown: { 128 // Comment marks are not in the markdown source; re-anchored on load via quotedText. 129 match: () => false, 130 runner: () => {}, 131 }, 132 toMarkdown: { 133 // Strip comment marks when serializing to markdown (stored as clean MD). 134 match: (mark) => mark.type.name === 'comment', 135 runner: (_state, _mark, _node) => {}, 136 }, 137 })); 138 139 const textarea = document.getElementById('editor-textarea'); 140 const previewEl = document.getElementById('preview'); 141 const saveStatus = document.getElementById('save-status'); 142 const titleInput = document.getElementById('doc-title'); 143 const rkey = '{{.Content.RKey}}'; 144 const accessToken = '{{.Content.AccessToken}}'; 145 const isCollaborator = {{if .Content.IsCollaborator}}true{{else}}false{{end}}; 146 const ownerDID = '{{.Content.OwnerDID}}'; // empty string when current user is owner 147 148 // Fetch the authoritative step version for this document. 149 let serverVersion = 0; 150 try { 151 const vResp = await fetch(`/api/docs/${rkey}/steps?since=0`); 152 if (vResp.ok) { 153 const vData = await vResp.json(); 154 serverVersion = vData.version || 0; 155 } 156 } catch(e) { /* start at 0 */ } 157 158 const myClientID = accessToken || Math.random().toString(36).slice(2); 159 160 const STORAGE_KEY = 'editor-mode'; 161 let currentMode = localStorage.getItem(STORAGE_KEY) || 'rich'; // 'rich' | 'source' 162 163 let autoSaveTimer = null; 164 165 // Annotation to tag dispatches that originate from remote edits, 166 // so the update listener can skip re-broadcasting them. 167 const remoteEditAnnotation = Annotation.define(); 168 169 // ── Shared helpers ──────────────────────────────────────────────────────── 170 171 function isDark() { 172 const stored = localStorage.getItem('theme'); 173 if (stored) return stored === 'dark'; 174 return window.matchMedia('(prefers-color-scheme: dark)').matches; 175 } 176 177 function scheduleAutoSave(content) { 178 clearTimeout(autoSaveTimer); 179 saveStatus.textContent = 'Unsaved changes'; 180 saveStatus.className = 'status-unsaved'; 181 autoSaveTimer = setTimeout(async () => { 182 try { 183 await fetch(`/api/docs/${rkey}/autosave`, { 184 method: 'PUT', 185 headers: {'Content-Type': 'application/json'}, 186 body: JSON.stringify({content, title: titleInput.value, ownerDID}), 187 }); 188 saveStatus.textContent = 'Auto-saved'; 189 saveStatus.className = 'status-saved'; 190 } catch (e) { 191 saveStatus.textContent = 'Save failed'; 192 saveStatus.className = 'status-error'; 193 } 194 }, 2000); 195 } 196 197 function getMarkdown() { 198 if (currentMode === 'source') { 199 return cmView.state.doc.toString(); 200 } else { 201 return milkdownEditor.action((ctx) => { 202 const editorView = ctx.get(editorViewCtx); 203 const serializer = ctx.get(serializerCtx); 204 return serializer(editorView.state.doc); 205 }); 206 } 207 } 208 209 // ── CodeMirror (source mode) ────────────────────────────────────────────── 210 211 const baseTheme = EditorView.theme({ 212 '&': {height: '100%', fontSize: '14px'}, 213 '.cm-scroller': {overflow: 'auto'}, 214 '.cm-content': {fontFamily: '"JetBrains Mono", "Fira Code", monospace'}, 215 }); 216 217 const darkCompartment = new Compartment(); 218 const wrapCompartment = new Compartment(); 219 220 const cmView = new EditorView({ 221 doc: textarea.value, 222 extensions: [ 223 basicSetup, 224 markdown(), 225 baseTheme, 226 darkCompartment.of(isDark() ? oneDark : []), 227 wrapCompartment.of([]), 228 EditorView.updateListener.of((update) => { 229 if (update.docChanged && currentMode === 'source') { 230 const content = update.state.doc.toString(); 231 updatePreview(content); 232 if (!update.transactions.some(tr => tr.annotation(remoteEditAnnotation))) { 233 scheduleAutoSave(content); 234 // Extract granular deltas from the ChangeSet. 235 // fromA/toA are positions in the OLD document (pre-change), 236 // which is what the server's OT engine needs. 237 const deltas = []; 238 update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { 239 deltas.push({ from: fromA, to: toA, insert: inserted.toString() }); 240 }); 241 if (deltas.length > 0) { 242 const pmSteps = deltas.map(d => ({type: 'text-patch', from: d.from, to: d.to, insert: d.insert})); 243 collabClient.sendSteps(pmSteps); 244 } 245 } 246 } 247 }), 248 ], 249 parent: document.getElementById('editor'), 250 }); 251 252 // ── CollabClient (step-authority protocol) ──────────────────────────────── 253 254 // Guard against applying a remote edit while we're already applying one 255 // (prevents echo loops). Moved here from the WebSocket section so collabClient 256 // can reference it during initialization. 257 let applyingRemote = false; 258 259 const collabClient = new CollabClient(rkey, serverVersion, (remoteSteps) => { 260 if (currentMode === 'source' && cmView) { 261 // Apply text-patch steps to CM without triggering our own send. 262 const changes = []; 263 let offset = 0; 264 for (const step of remoteSteps) { 265 if (step.type !== 'text-patch') continue; 266 const from = step.from + offset; 267 const to = step.to + offset; 268 const insert = step.insert || ''; 269 changes.push({ from, to, insert }); 270 offset += insert.length - (step.to - step.from); 271 } 272 if (changes.length === 0) return; 273 applyingRemote = true; 274 try { 275 cmView.dispatch({ 276 changes, 277 annotations: [remoteEditAnnotation.of(true)], 278 }); 279 } finally { 280 applyingRemote = false; 281 } 282 } else if (currentMode === 'rich' && milkdownEditor) { 283 // Apply PM steps to the Milkdown/ProseMirror editor without re-creating it. 284 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 285 const schema = pmView.state.schema; 286 const pmSteps = []; 287 const clientIDs = []; 288 for (const step of remoteSteps) { 289 if (step.type !== 'pm-step') continue; 290 try { 291 pmSteps.push(Step.fromJSON(schema, JSON.parse(step.json))); 292 clientIDs.push('remote'); 293 } catch(e) { 294 console.warn('CollabClient: failed to parse PM step', e); 295 } 296 } 297 if (pmSteps.length === 0) return; 298 applyingRemote = true; 299 try { 300 const tr = receiveTransaction(pmView.state, pmSteps, clientIDs); 301 pmView.dispatch(tr); 302 } finally { 303 applyingRemote = false; 304 } 305 } 306 }); 307 collabClient.setClientID(myClientID); 308 309 async function updatePreview(content) { 310 try { 311 const resp = await fetch('/api/render', { 312 method: 'POST', 313 headers: {'Content-Type': 'application/json'}, 314 body: JSON.stringify({content}), 315 }); 316 const data = await resp.json(); 317 previewEl.innerHTML = data.html; 318 } catch (e) { 319 console.error('Preview error:', e); 320 } 321 } 322 323 let wrapEnabled = false; 324 window.toggleWrap = function() { 325 wrapEnabled = !wrapEnabled; 326 cmView.dispatch({ effects: wrapCompartment.reconfigure(wrapEnabled ? EditorView.lineWrapping : []) }); 327 document.getElementById('btn-wrap').classList.toggle('active', wrapEnabled); 328 }; 329 330 let previewVisible = true; 331 window.togglePreview = function() { 332 previewVisible = !previewVisible; 333 document.querySelector('.preview-pane').style.display = previewVisible ? '' : 'none'; 334 document.getElementById('btn-preview').classList.toggle('active', previewVisible); 335 }; 336 337 window.__cmSetTheme = function(theme) { 338 cmView.dispatch({ 339 effects: darkCompartment.reconfigure(theme === 'dark' ? oneDark : []), 340 }); 341 }; 342 343 // ── Milkdown (rich text mode) ───────────────────────────────────────────── 344 345 let milkdownEditor = null; 346 347 async function createMilkdownEditor(initialMarkdown) { 348 const container = document.getElementById('editor-rich'); 349 container.innerHTML = ''; 350 351 milkdownEditor = await Editor.make() 352 .config((ctx) => { 353 ctx.set(rootCtx, container); 354 ctx.set(defaultValueCtx, initialMarkdown); 355 // Register the prosemirror-collab plugin into EditorState (not EditorView) 356 // so sendableSteps/receiveTransaction have a state slot to read from. 357 ctx.update(prosePluginsCtx, (plugins) => [ 358 ...plugins, 359 collab({ version: collabClient.version, clientID: myClientID }), 360 ]); 361 // Override dispatchTransaction to notify CollabClient after every 362 // local transaction. CollabClient calls sendableSteps itself at 363 // flush time, so it always sends exactly the current unconfirmed 364 // set — never duplicates across keystrokes. 365 ctx.update(editorViewOptionsCtx, (prev) => ({ 366 ...prev, 367 dispatchTransaction: function(tr) { 368 const newState = this.state.apply(tr); 369 this.updateState(newState); 370 if (!applyingRemote) { 371 collabClient.notifyPMChange(); 372 if (tr.docChanged) { 373 try { 374 const serializer = milkdownEditor.action(c => c.get(serializerCtx)); 375 scheduleAutoSave(serializer(newState.doc)); 376 } catch(_) {} 377 } 378 } 379 }, 380 })); 381 }) 382 .use(commonmark) 383 .use(commentAttr) 384 .use(commentSchema) 385 .use(history) 386 .use(listener) 387 .config((ctx) => { 388 ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => { 389 // Solo-mode auto-save fallback (no collab session). 390 if (markdown === prevMarkdown || applyingRemote || accessToken) return; 391 scheduleAutoSave(markdown); 392 }); 393 }) 394 .create(); 395 396 // Register PM handlers so CollabClient reads sendableSteps and confirms 397 // its own steps via receiveTransaction (advancing the collab plugin version). 398 collabClient.setPMHandlers( 399 () => { 400 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 401 return sendableSteps(pmView.state); 402 }, 403 (steps, clientIDs) => { 404 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 405 applyingRemote = true; 406 try { 407 const tr = receiveTransaction(pmView.state, steps, clientIDs); 408 pmView.dispatch(tr); 409 } finally { 410 applyingRemote = false; 411 } 412 }, 413 () => { 414 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 415 return getVersion(pmView.state); 416 } 417 ); 418 419 return milkdownEditor; 420 } 421 422 // ── Undo / Redo ─────────────────────────────────────────────────────────── 423 424 window.richUndo = function() { 425 if (milkdownEditor) milkdownEditor.action(callCommand(undoCommand.key)); 426 }; 427 window.richRedo = function() { 428 if (milkdownEditor) milkdownEditor.action(callCommand(redoCommand.key)); 429 }; 430 431 // ── Mode switching ──────────────────────────────────────────────────────── 432 433 function applyMode(mode, animate) { 434 const richEl = document.getElementById('editor-rich'); 435 const sourceEl = document.getElementById('editor-source'); 436 const sourceOnlyBtns = document.querySelectorAll('.source-only'); 437 const richOnlyBtns = document.querySelectorAll('.rich-only'); 438 const sourceBtn = document.getElementById('btn-source'); 439 440 if (mode === 'source') { 441 richEl.style.display = 'none'; 442 sourceEl.style.display = ''; 443 sourceOnlyBtns.forEach(b => b.style.display = 'inline-block'); 444 richOnlyBtns.forEach(b => b.style.display = 'none'); 445 sourceBtn.classList.add('active'); 446 } else { 447 richEl.style.display = ''; 448 sourceEl.style.display = 'none'; 449 sourceOnlyBtns.forEach(b => b.style.display = 'none'); 450 richOnlyBtns.forEach(b => b.style.display = ''); 451 sourceBtn.classList.remove('active'); 452 } 453 } 454 455 window.toggleCommentSidebar = function() { 456 const sidebar = document.getElementById('comment-sidebar'); 457 const btn = document.getElementById('btn-comments'); 458 if (!sidebar) return; 459 const isHidden = sidebar.style.display === 'none'; 460 sidebar.style.display = isHidden ? '' : 'none'; 461 // CSS :has(~ .comment-sidebar) still matches when sidebar is display:none, 462 // so override editor-page right manually. 463 const editorPage = document.querySelector('.editor-page'); 464 if (editorPage) editorPage.style.right = isHidden ? '' : '0'; 465 if (btn) btn.classList.toggle('active', isHidden); 466 }; 467 468 window.toggleSourceMode = async function() { 469 const nextMode = currentMode === 'rich' ? 'source' : 'rich'; 470 471 if (nextMode === 'source') { 472 // rich → source: extract markdown from Milkdown, load into CodeMirror 473 const md = getMarkdown(); 474 const doc = cmView.state.doc; 475 cmView.dispatch({ changes: { from: 0, to: doc.length, insert: md } }); 476 updatePreview(md); 477 collabClient.setPMHandlers(null, null); // detach from stale Milkdown instance 478 } else { 479 // source → rich: extract markdown from CodeMirror, recreate Milkdown 480 const md = cmView.state.doc.toString(); 481 await createMilkdownEditor(md); // setPMHandlers called inside 482 } 483 484 currentMode = nextMode; 485 localStorage.setItem(STORAGE_KEY, currentMode); 486 applyMode(currentMode); 487 }; 488 489 // ── Save ────────────────────────────────────────────────────────────────── 490 491 titleInput.addEventListener('input', () => { 492 scheduleAutoSave(getMarkdown()); 493 }); 494 495 window.saveDocument = async function() { 496 const content = getMarkdown(); 497 try { 498 const resp = await fetch(`/api/docs/${rkey}/save`, { 499 method: 'POST', 500 headers: {'Content-Type': 'application/json'}, 501 body: JSON.stringify({content, title: titleInput.value, ownerDID}), 502 }); 503 if (resp.ok) { 504 saveStatus.textContent = 'Saved!'; 505 saveStatus.className = 'status-saved'; 506 } 507 } catch (e) { 508 saveStatus.textContent = 'Save failed'; 509 saveStatus.className = 'status-error'; 510 } 511 }; 512 513 // ── Link tooltip ────────────────────────────────────────────────────────── 514 515 const linkTooltipEl = document.getElementById('link-tooltip'); 516 const linkInput = document.getElementById('link-tooltip-input'); 517 const linkConfirmBtn = document.getElementById('link-tooltip-confirm'); 518 const linkRemoveBtn = document.getElementById('link-tooltip-remove'); 519 const linkCancelBtn = document.getElementById('link-tooltip-cancel'); 520 521 let linkTooltipState = null; // { pmView, pos } 522 523 function findMarkExtent(state, searchPos, markType) { 524 try { 525 const $pos = state.doc.resolve(Math.min(searchPos, state.doc.content.size - 1)); 526 const parent = $pos.parent; 527 const parentStart = $pos.start(); 528 let currentHref = null; 529 parent.forEach((node, offset) => { 530 const nodeStart = parentStart + offset; 531 const nodeEnd = nodeStart + node.nodeSize; 532 const lm = markType.isInSet(node.marks); 533 if (lm && nodeStart <= searchPos && searchPos <= nodeEnd) currentHref = lm.attrs.href; 534 }); 535 if (!currentHref) return { from: -1, to: -1 }; 536 let linkFrom = -1, linkTo = -1; 537 parent.forEach((node, offset) => { 538 const lm = markType.isInSet(node.marks); 539 if (lm && lm.attrs.href === currentHref) { 540 const nodeStart = parentStart + offset; 541 if (linkFrom === -1) linkFrom = nodeStart; 542 linkTo = nodeStart + node.nodeSize; 543 } 544 }); 545 return { from: linkFrom, to: linkTo }; 546 } catch(e) { return { from: -1, to: -1 }; } 547 } 548 549 function showLinkTooltip(pmView, mark, pos) { 550 linkTooltipState = { pmView, pos }; 551 linkInput.value = mark.attrs.href || ''; 552 const coords = pmView.coordsAtPos(pos); 553 linkTooltipEl.style.left = Math.max(8, coords.left) + 'px'; 554 linkTooltipEl.style.top = (coords.bottom + 8) + 'px'; 555 linkTooltipEl.classList.add('visible'); 556 } 557 558 function hideLinkTooltip() { 559 linkTooltipEl.classList.remove('visible'); 560 linkTooltipState = null; 561 } 562 563 // Prevent buttons from stealing editor focus 564 [linkConfirmBtn, linkRemoveBtn, linkCancelBtn].forEach(btn => { 565 btn.addEventListener('mousedown', e => e.preventDefault()); 566 }); 567 568 linkCancelBtn.addEventListener('click', () => hideLinkTooltip()); 569 570 linkConfirmBtn.addEventListener('click', () => { 571 if (!linkTooltipState) return; 572 const { pmView, pos } = linkTooltipState; 573 const newHref = linkInput.value.trim(); 574 if (!newHref) return; 575 const { state, dispatch } = pmView; 576 const linkType = state.schema.marks.link; 577 const { from, to } = findMarkExtent(state, pos, linkType); 578 if (from === -1) return; 579 dispatch(state.tr.addMark(from, to, linkType.create({ href: newHref }))); 580 hideLinkTooltip(); 581 pmView.focus(); 582 }); 583 584 linkRemoveBtn.addEventListener('click', () => { 585 if (!linkTooltipState) return; 586 const { pmView, pos } = linkTooltipState; 587 const { state, dispatch } = pmView; 588 const linkType = state.schema.marks.link; 589 const { from, to } = findMarkExtent(state, pos, linkType); 590 if (from === -1) return; 591 dispatch(state.tr.removeMark(from, to, linkType)); 592 hideLinkTooltip(); 593 pmView.focus(); 594 }); 595 596 linkInput.addEventListener('keydown', e => { 597 if (e.key === 'Enter') linkConfirmBtn.click(); 598 if (e.key === 'Escape') hideLinkTooltip(); 599 }); 600 601 function checkForLinkTooltip() { 602 if (currentMode !== 'rich' || !milkdownEditor) return; 603 try { 604 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 605 const { selection, schema, doc } = pmView.state; 606 const linkType = schema.marks.link; 607 const pos = Math.min(selection.from, doc.content.size - 1); 608 const marks = doc.resolve(pos).marks(); 609 const linkMark = marks.find(m => m.type === linkType); 610 if (linkMark) showLinkTooltip(pmView, linkMark, pos); 611 else hideLinkTooltip(); 612 } catch(e) { hideLinkTooltip(); } 613 } 614 615 document.getElementById('editor-rich').addEventListener('click', () => setTimeout(checkForLinkTooltip, 0)); 616 document.getElementById('editor-rich').addEventListener('keyup', checkForLinkTooltip); 617 618 document.addEventListener('click', e => { 619 if (!linkTooltipEl.contains(e.target) && !document.getElementById('editor-rich').contains(e.target)) { 620 hideLinkTooltip(); 621 } 622 }); 623 624 // ── Invite ──────────────────────────────────────────────────────────────── 625 626 window.generateInvite = async function generateInvite() { 627 const modal = document.getElementById('invite-modal'); 628 const body = document.getElementById('invite-modal-body'); 629 if (!modal) return; 630 body.innerHTML = '<p>Generating invite link...</p>'; 631 modal.style.display = 'flex'; 632 633 try { 634 const resp = await fetch(`/api/docs/${rkey}/invite`, { method: 'POST' }); 635 const data = await resp.json(); 636 if (!resp.ok) throw new Error(data.error || resp.statusText); 637 const link = data.invite_url || data.inviteLink || data.url || ''; 638 body.innerHTML = ` 639 <p style="margin:0 0 0.5rem;font-size:0.85rem;color:var(--text-muted)"> 640 Share this link. It expires in 7 days and can be used once. 641 </p> 642 <div style="display:flex;gap:0.5rem;align-items:center"> 643 <input id="invite-link-input" type="text" value="${escHtml(link)}" readonly 644 style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius); 645 padding:0.4rem 0.5rem;font-size:0.85rem;color:var(--text);outline:none"> 646 <button class="btn btn-sm" onclick="copyInviteLink()">Copy</button> 647 </div> 648 <p id="invite-copy-msg" style="margin:0.4rem 0 0;font-size:0.8rem;color:var(--primary);display:none">Copied!</p> 649 `; 650 } catch (e) { 651 body.innerHTML = `<p style="color:var(--danger)">Failed to generate invite: ${escHtml(e.message)}</p>`; 652 } 653 } 654 655 window.copyInviteLink = function copyInviteLink() { 656 const input = document.getElementById('invite-link-input'); 657 if (!input) return; 658 navigator.clipboard.writeText(input.value).then(() => { 659 const msg = document.getElementById('invite-copy-msg'); 660 if (msg) { msg.style.display = 'block'; setTimeout(() => msg.style.display = 'none', 2000); } 661 }); 662 } 663 664 window.closeInviteModal = function closeInviteModal() { 665 const modal = document.getElementById('invite-modal'); 666 if (modal) modal.style.display = 'none'; 667 } 668 669 // Close invite modal on backdrop click 670 document.getElementById('invite-modal')?.addEventListener('click', e => { 671 if (e.target === document.getElementById('invite-modal')) closeInviteModal(); 672 }); 673 674 // ── WebSocket / Collaboration ───────────────────────────────────────────── 675 676 let ws = null; 677 let wsReconnectDelay = 1000; 678 let wsReconnectTimer = null; 679 let wsPingTimer = null; 680 let wsMissedPings = 0; 681 682 function connectWebSocket() { 683 if (!accessToken) return; 684 685 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 686 const ownerParam = ownerDID ? `&owner_did=${encodeURIComponent(ownerDID)}` : ''; 687 const wsUrl = `${protocol}//${window.location.host}/ws/docs/${rkey}?access_token=${encodeURIComponent(accessToken)}&dpop_proof=placeholder${ownerParam}`; 688 689 ws = new WebSocket(wsUrl); 690 691 ws.onopen = () => { 692 wsReconnectDelay = 1000; 693 wsMissedPings = 0; 694 startHeartbeat(); 695 // Fetch any steps missed while the WS was disconnected. 696 catchUpSteps(); 697 }; 698 699 ws.onmessage = (event) => { 700 try { 701 const msg = JSON.parse(event.data); 702 handleWSMessage(msg); 703 } catch (e) { 704 console.error('WS parse error:', e); 705 } 706 }; 707 708 ws.onclose = () => { 709 stopHeartbeat(); 710 ws = null; 711 updatePresence([]); 712 scheduleReconnect(); 713 }; 714 715 ws.onerror = () => { 716 closeWS(); 717 }; 718 } 719 720 function scheduleReconnect() { 721 clearTimeout(wsReconnectTimer); 722 wsReconnectTimer = setTimeout(() => { 723 connectWebSocket(); 724 wsReconnectDelay = Math.min(wsReconnectDelay * 2, 30000); 725 }, wsReconnectDelay); 726 } 727 728 function startHeartbeat() { 729 stopHeartbeat(); 730 wsPingTimer = setInterval(() => { 731 if (ws && ws.readyState === WebSocket.OPEN) { 732 ws.send(JSON.stringify({ type: 'ping' })); 733 wsMissedPings++; 734 if (wsMissedPings >= 3) { 735 closeWS(); 736 } 737 } 738 }, 30000); 739 } 740 741 function stopHeartbeat() { 742 clearInterval(wsPingTimer); 743 } 744 745 function handleWSMessage(msg) { 746 switch (msg.type) { 747 case 'presence': 748 updatePresence(msg.users || []); 749 break; 750 case 'pong': 751 wsMissedPings = 0; 752 break; 753 case 'steps': 754 collabClient.handleWSMessage(msg, myClientID); 755 break; 756 case 'edit': 757 applyRemoteEdit(msg); // legacy full-replace path 758 break; 759 case 'comments_updated': 760 loadComments(); 761 break; 762 case 'sync': 763 applyRemoteEdit(msg.content); // sync is always full-content string 764 break; 765 } 766 } 767 768 function applyRemoteEdit(msg) { 769 // Legacy sync fallback — only used for 'sync' and legacy 'edit' message types. 770 // Remote edits via the new step protocol go through CollabClient instead. 771 if (applyingRemote) return; 772 const content = typeof msg === 'string' ? msg : msg.content; 773 if (!content) return; 774 775 if (currentMode === 'source' && cmView) { 776 if (cmView.state.doc.toString() !== content) { 777 applyingRemote = true; 778 try { 779 cmView.dispatch({ 780 changes: { from: 0, to: cmView.state.doc.length, insert: content }, 781 annotations: [remoteEditAnnotation.of(true)], 782 }); 783 } finally { 784 applyingRemote = false; 785 } 786 updatePreview(content); 787 } 788 } 789 // Rich mode no longer falls back to full recreate here; 790 // remote steps are applied via CollabClient in Task 8. 791 } 792 793 function closeWS() { 794 if (!ws) return; 795 ws.close(); 796 ws = null; 797 stopHeartbeat(); 798 } 799 800 // Fetch steps missed during a WS disconnect and apply them to the PM state. 801 async function catchUpSteps() { 802 if (currentMode !== 'rich' || !milkdownEditor) return; 803 try { 804 const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 805 const since = getVersion(pmView.state); 806 const resp = await fetch(`/api/docs/${rkey}/steps?since=${since}`); 807 if (!resp.ok) return; 808 const {steps: stepsJSON} = await resp.json(); 809 if (!stepsJSON || stepsJSON.length === 0) return; 810 const missed = stepsJSON.map(s => JSON.parse(s)); 811 collabClient.applyRemoteSteps(missed); 812 } catch(e) { 813 console.warn('catchUpSteps:', e); 814 } 815 } 816 817 // ── Presence ────────────────────────────────────────────────────────────── 818 819 function updatePresence(users) { 820 const list = document.getElementById('presence-list'); 821 if (!list) return; 822 list.innerHTML = users.map(u => { 823 const label = escHtml(u.handle || u.name || u.did); 824 if (u.avatar) { 825 return `<img class="presence-avatar" src="${escHtml(u.avatar)}" title="${label}" alt="${label}">`; 826 } 827 return `<span class="presence-avatar" style="background:${u.color}" title="${label}"></span>`; 828 }).join(''); 829 } 830 831 function escHtml(str) { 832 return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 833 } 834 835 // ── Comments (mark-based anchoring) ─────────────────────────────────────── 836 837 // pending selection captured when user clicks "Comment" 838 let pendingCommentRange = null; // { from, to, quotedText } 839 840 const commentBtn = document.getElementById('comment-btn'); 841 const commentForm = document.getElementById('comment-form'); 842 const commentTextEl = document.getElementById('comment-text'); 843 844 // Show "Comment" button when user has a non-empty selection in the rich editor 845 function setupSelectionCommentTrigger() { 846 const editorEl = document.getElementById('editor-rich'); 847 if (!editorEl || !commentBtn) return; 848 function onSelectionChange() { 849 if (currentMode !== 'rich' || !milkdownEditor) { 850 commentBtn.style.display = 'none'; 851 return; 852 } 853 try { 854 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 855 const { selection } = pmView.state; 856 if (selection.empty) { commentBtn.style.display = 'none'; return; } 857 const coords = pmView.coordsAtPos(selection.to); 858 commentBtn.style.top = (coords.bottom + window.scrollY + 6) + 'px'; 859 commentBtn.style.left = Math.max(8, coords.left + window.scrollX) + 'px'; 860 commentBtn.style.display = 'block'; 861 } catch(e) { commentBtn.style.display = 'none'; } 862 } 863 editorEl.addEventListener('mouseup', onSelectionChange); 864 editorEl.addEventListener('keyup', onSelectionChange); 865 } 866 867 let pendingReplyTo = null; // set when replying to a comment 868 let pendingThreadId = null; // threadId to inherit when replying 869 870 window.openCommentForm = function openCommentForm() { 871 if (!commentBtn || !commentForm || !milkdownEditor) return; 872 try { 873 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 874 const { selection, doc } = pmView.state; 875 if (selection.empty) return; 876 pendingCommentRange = { from: selection.from, to: selection.to, 877 quotedText: doc.textBetween(selection.from, selection.to, ' ') }; 878 pendingReplyTo = null; 879 commentTextEl.placeholder = 'Add a comment...'; 880 const rect = commentBtn.getBoundingClientRect(); 881 commentForm.style.top = (rect.bottom + window.scrollY + 4) + 'px'; 882 commentForm.style.left = Math.max(8, rect.left + window.scrollX) + 'px'; 883 commentForm.style.display = 'block'; 884 commentTextEl.value = ''; 885 commentTextEl.focus(); 886 } catch(e) { console.error('openCommentForm:', e); } 887 } 888 889 window.closeCommentForm = function closeCommentForm() { 890 if (commentForm) commentForm.style.display = 'none'; 891 if (commentBtn) commentBtn.style.display = 'none'; 892 pendingCommentRange = null; 893 pendingReplyTo = null; 894 pendingThreadId = null; 895 if (commentTextEl) commentTextEl.placeholder = 'Add a comment...'; 896 } 897 898 window.submitComment = async function submitComment() { 899 if (!pendingCommentRange) return; 900 const text = commentTextEl.value.trim(); 901 if (!text) return; 902 const { from, to, quotedText } = pendingCommentRange; 903 try { 904 const body = { quotedText, text }; 905 if (pendingReplyTo) body.replyTo = pendingReplyTo; 906 if (pendingThreadId) body.threadId = pendingThreadId; 907 if (ownerDID) body.ownerDID = ownerDID; 908 const resp = await fetch(`/api/docs/${rkey}/comments`, { 909 method: 'POST', 910 headers: { 'Content-Type': 'application/json' }, 911 body: JSON.stringify(body), 912 }); 913 if (!resp.ok) throw new Error(await resp.text()); 914 const comment = await resp.json(); 915 const threadId = comment.threadId; 916 if (threadId && milkdownEditor) { 917 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 918 const markType = pmView.state.schema.marks.comment; 919 if (markType) { 920 pmView.dispatch(pmView.state.tr.addMark(from, to, markType.create({ threadId }))); 921 } 922 } 923 closeCommentForm(); 924 pendingReplyTo = null; 925 pendingThreadId = null; 926 loadComments(); 927 } catch (e) { console.error('Comment post failed:', e); } 928 } 929 930 commentTextEl && commentTextEl.addEventListener('keydown', e => { 931 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitComment(); 932 if (e.key === 'Escape') closeCommentForm(); 933 }); 934 935 // Close comment form on outside click 936 document.addEventListener('click', e => { 937 if (commentForm && commentForm.style.display !== 'none') { 938 if (!commentForm.contains(e.target) && e.target !== commentBtn) { 939 closeCommentForm(); 940 } 941 } 942 }); 943 944 // Click on comment highlight in editor → highlight sidebar thread 945 document.getElementById('editor-rich').addEventListener('click', e => { 946 const span = e.target.closest('.comment-highlight'); 947 if (!span) return; 948 const threadId = span.getAttribute('data-thread'); 949 if (!threadId) return; 950 const threadEl = document.querySelector(`.comment-thread[data-thread="${CSS.escape(threadId)}"]`); 951 if (threadEl) { 952 threadEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 953 threadEl.animate( 954 [{ boxShadow: '0 0 0 2px rgba(234,179,8,0.9)' }, 955 { boxShadow: '0 0 0 2px rgba(234,179,8,0)' }], 956 { duration: 1000, easing: 'ease-out' } 957 ); 958 } 959 }); 960 961 // Click on sidebar thread → scroll to mark in editor 962 (function attachThreadClickHandler() { 963 const container = document.getElementById('comment-threads'); 964 if (!container) return; 965 container.addEventListener('click', e => { 966 const thread = e.target.closest('.comment-thread'); 967 if (!thread) return; 968 if (thread.dataset.thread) jumpToCommentMark(thread.dataset.thread); 969 }); 970 })(); 971 972 function jumpToCommentMark(threadId) { 973 if (!milkdownEditor) return; 974 try { 975 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 976 const { doc, schema } = pmView.state; 977 const markType = schema.marks.comment; 978 if (!markType) return; 979 let markPos = -1; 980 doc.descendants((node, pos) => { 981 if (markPos !== -1) return false; 982 if (node.marks.some(mk => mk.type === markType && mk.attrs.threadId === threadId)) { 983 markPos = pos; 984 } 985 }); 986 if (markPos === -1) return; 987 const coords = pmView.coordsAtPos(markPos); 988 const editorRich = document.getElementById('editor-rich'); 989 const containerTop = editorRich.getBoundingClientRect().top; 990 editorRich.scrollTo({ top: coords.top - containerTop - 120, behavior: 'smooth' }); 991 const span = pmView.dom.querySelector(`span[data-thread="${CSS.escape(threadId)}"]`); 992 if (span) { 993 span.animate( 994 [{ backgroundColor: 'rgba(234,179,8,0.6)' }, 995 { backgroundColor: 'rgba(234,179,8,0.2)' }], 996 { duration: 900, easing: 'ease-out' } 997 ); 998 } 999 } catch(e) { console.error('jumpToCommentMark:', e); } 1000 } 1001 1002 // Returns true if a comment mark with this threadId exists in the PM doc 1003 function findCommentMark(threadId) { 1004 if (!milkdownEditor) return false; 1005 try { 1006 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 1007 const { doc, schema } = pmView.state; 1008 const markType = schema.marks.comment; 1009 if (!markType) return false; 1010 let found = false; 1011 doc.descendants((node) => { 1012 if (found) return false; 1013 if (node.marks.some(m => m.type === markType && m.attrs.threadId === threadId)) found = true; 1014 }); 1015 return found; 1016 } catch(e) { return false; } 1017 } 1018 1019 function findCommentMarkPos(threadId) { 1020 if (!milkdownEditor) return Infinity; 1021 try { 1022 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 1023 const { doc, schema } = pmView.state; 1024 const markType = schema.marks.comment; 1025 if (!markType) return Infinity; 1026 let pos = Infinity; 1027 doc.descendants((node, nodePos) => { 1028 if (pos !== Infinity) return false; 1029 if (node.marks.some(m => m.type === markType && m.attrs.threadId === threadId)) pos = nodePos; 1030 }); 1031 return pos; 1032 } catch(e) { return Infinity; } 1033 } 1034 1035 function formatTime(ts) { 1036 if (!ts) return ''; 1037 try { return new Date(ts).toLocaleString(); } catch { return ts; } 1038 } 1039 1040 async function replyToComment(comment) { 1041 if (!commentForm || !commentTextEl) return; 1042 pendingCommentRange = { from: 0, to: 0, quotedText: '' }; 1043 pendingReplyTo = `at://${comment.docOwnerDid}/com.diffdown.comment/${comment.id}`; 1044 pendingThreadId = comment.threadId; 1045 commentTextEl.placeholder = `Replying to ${comment.authorHandle || comment.author}...`; 1046 commentTextEl.value = ''; 1047 // comment-form is position:fixed; anchor to right edge of viewport near sidebar 1048 commentForm.style.right = '8px'; 1049 commentForm.style.left = 'auto'; 1050 commentForm.style.top = '80px'; 1051 commentForm.style.display = 'block'; 1052 commentTextEl.focus(); 1053 } 1054 1055 async function toggleResolve(threadId, resolved) { 1056 try { 1057 const body = { resolved }; 1058 if (ownerDID) body.ownerDID = ownerDID; 1059 await fetch(`/api/docs/${rkey}/comments/${threadId}`, { 1060 method: 'PATCH', 1061 headers: { 'Content-Type': 'application/json' }, 1062 body: JSON.stringify(body), 1063 }); 1064 loadComments(); 1065 } catch (e) { 1066 console.error('Toggle resolve failed:', e); 1067 } 1068 } 1069 1070 function renderCommentThreads(comments) { 1071 const container = document.getElementById('comment-threads'); 1072 if (!container) return; 1073 1074 if (!comments || comments.length === 0) { 1075 container.textContent = ''; 1076 const empty = document.createElement('p'); 1077 empty.className = 'comment-empty'; 1078 empty.textContent = 'No comments yet.'; 1079 container.appendChild(empty); 1080 return; 1081 } 1082 1083 const roots = []; 1084 const replies = new Map(); 1085 for (const c of comments) { 1086 if (c.replyTo) { 1087 // replyTo is a full AT URI (at://did/collection/rkey); key by rkey only 1088 const parentId = c.replyTo.split('/').pop(); 1089 if (!replies.has(parentId)) replies.set(parentId, []); 1090 replies.get(parentId).push(c); 1091 } else { 1092 roots.push(c); 1093 } 1094 } 1095 1096 roots.sort((a, b) => findCommentMarkPos(a.threadId || a.id) - findCommentMarkPos(b.threadId || b.id)); 1097 1098 container.textContent = ''; 1099 for (const root of roots) { 1100 const threadId = root.threadId || root.id; 1101 const threadReplies = replies.get(root.id) || []; 1102 const threadEl = createCommentThreadElement(threadId, root, threadReplies); 1103 container.appendChild(threadEl); 1104 } 1105 } 1106 1107 function createCommentThreadElement(threadId, rootComment, replies) { 1108 const isDetached = !findCommentMark(threadId); 1109 const threadDiv = document.createElement('div'); 1110 threadDiv.className = 'comment-thread' + (isDetached ? ' comment-thread-detached' : ''); 1111 threadDiv.dataset.thread = threadId; 1112 1113 const headerDiv = document.createElement('div'); 1114 headerDiv.className = 'comment-thread-header'; 1115 1116 const labelDiv = document.createElement('div'); 1117 labelDiv.className = 'comment-thread-label'; 1118 const labelText = rootComment.quotedText 1119 ? '\u201c' + (rootComment.quotedText.length > 40 ? rootComment.quotedText.slice(0, 40) + '\u2026' : rootComment.quotedText) + '\u201d' 1120 : '(no anchor)'; 1121 labelDiv.textContent = labelText; 1122 if (isDetached) { 1123 const warn = document.createElement('span'); 1124 warn.title = 'Text was deleted'; 1125 warn.textContent = ' \u26a0'; 1126 labelDiv.appendChild(warn); 1127 } 1128 headerDiv.appendChild(labelDiv); 1129 1130 const resolveBtn = document.createElement('button'); 1131 resolveBtn.className = 'btn btn-sm ' + (rootComment.resolved ? 'btn-active' : 'btn-outline'); 1132 resolveBtn.textContent = rootComment.resolved ? 'Reopen' : 'Resolve'; 1133 resolveBtn.onclick = () => toggleResolve(rootComment.id, !rootComment.resolved); 1134 headerDiv.appendChild(resolveBtn); 1135 1136 threadDiv.appendChild(headerDiv); 1137 1138 threadDiv.appendChild(createCommentItem(rootComment, true)); 1139 1140 for (const reply of replies) { 1141 threadDiv.appendChild(createCommentItem(reply, false)); 1142 } 1143 1144 return threadDiv; 1145 } 1146 1147 function createCommentItem(comment, isRoot) { 1148 const item = document.createElement('div'); 1149 item.className = 'comment-item' + (isRoot ? '' : ' comment-item-reply'); 1150 1151 const author = document.createElement('div'); 1152 author.className = 'comment-author'; 1153 author.textContent = comment.authorHandle || comment.author; 1154 1155 const body = document.createElement('div'); 1156 body.className = 'comment-text'; 1157 body.textContent = comment.text; 1158 1159 const time = document.createElement('div'); 1160 time.className = 'comment-time'; 1161 time.textContent = formatTime(comment.createdAt); 1162 1163 if (isRoot) { 1164 const replyBtn = document.createElement('button'); 1165 replyBtn.className = 'btn btn-sm btn-link'; 1166 replyBtn.textContent = 'Reply'; 1167 replyBtn.addEventListener('click', e => { e.stopPropagation(); replyToComment(comment); }); 1168 item.appendChild(replyBtn); 1169 } 1170 1171 item.appendChild(author); 1172 item.appendChild(body); 1173 item.appendChild(time); 1174 return item; 1175 } 1176 1177 async function loadComments() { 1178 if (!accessToken) return; 1179 try { 1180 const qs = ownerDID ? '?ownerDID=' + encodeURIComponent(ownerDID) : ''; 1181 const resp = await fetch(`/api/docs/${rkey}/comments${qs}`); 1182 if (!resp.ok) return; 1183 const comments = await resp.json(); 1184 reanchorCommentMarks(comments); 1185 renderCommentThreads(comments); 1186 } catch (e) { 1187 console.error('Load comments failed:', e); 1188 } 1189 } 1190 1191 // Re-anchor comment marks after load by searching for quotedText in the doc 1192 function reanchorCommentMarks(comments) { 1193 if (!milkdownEditor) return; 1194 try { 1195 const pmView = milkdownEditor.action(ctx => ctx.get(editorViewCtx)); 1196 const { doc, schema } = pmView.state; 1197 const markType = schema.marks.comment; 1198 if (!markType) return; 1199 // Clear all comment marks first, then re-add only for unresolved comments. 1200 // This ensures resolving a comment removes its highlight on the next load. 1201 let tr = pmView.state.tr.removeMark(0, doc.content.size, markType); 1202 for (const c of comments || []) { 1203 if (c.resolved) continue; 1204 const threadId = c.threadId || c.id; 1205 if (!c.quotedText) continue; 1206 const pos = findTextInDoc(doc, c.quotedText); 1207 if (pos === -1) continue; 1208 tr = tr.addMark(pos, pos + c.quotedText.length, markType.create({ threadId })); 1209 } 1210 pmView.dispatch(tr); 1211 } catch(e) { console.error('reanchorCommentMarks:', e); } 1212 } 1213 1214 // Find start doc-position of a text string in the ProseMirror doc 1215 function findTextInDoc(doc, searchText) { 1216 const fullText = doc.textContent; 1217 const idx = fullText.indexOf(searchText); 1218 if (idx === -1) return -1; 1219 let textOffset = 0; 1220 let found = -1; 1221 doc.descendants((node, pos) => { 1222 if (found !== -1) return false; 1223 if (node.isText) { 1224 const end = textOffset + node.text.length; 1225 if (textOffset <= idx && idx < end) found = pos + (idx - textOffset); 1226 textOffset += node.text.length; 1227 } 1228 }); 1229 return found; 1230 } 1231 1232 setupSelectionCommentTrigger(); 1233 1234 // ── Init ────────────────────────────────────────────────────────────────── 1235 1236 const initialMarkdown = textarea.value; 1237 1238 // Always create Milkdown (needed even if starting in source mode for first switch) 1239 await createMilkdownEditor(initialMarkdown); 1240 1241 // If starting in source mode, do initial preview render 1242 if (currentMode === 'source') { 1243 updatePreview(initialMarkdown); 1244 } 1245 1246 applyMode(currentMode); 1247 1248 // Start collaboration features (both owner and collaborators join the WS room) 1249 if (accessToken) { 1250 connectWebSocket(); 1251 loadComments(); 1252 } 1253 1254 window.addEventListener('beforeunload', () => { 1255 closeWS(); 1256 }); 1257</script> 1258{{end}}