Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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}}