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