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 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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}}