Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

feat: add WebSocket client, presence sidebar, and comment UI

+425 -1
+25 -1
internal/handler/handler.go
··· 45 45 OGImage string 46 46 } 47 47 48 + // DocumentEditData is passed to document_edit.html. 49 + type DocumentEditData struct { 50 + *model.Document 51 + // AccessToken is the ATProto access token for WebSocket auth. 52 + // Empty string if user has no ATProto session. 53 + AccessToken string 54 + // IsCollaborator is true when the current user is in the collaborators list. 55 + IsCollaborator bool 56 + } 57 + 48 58 func (h *Handler) currentUser(r *http.Request) *model.User { 49 59 uid := auth.UserIDFromContext(r.Context()) 50 60 if uid == "" { ··· 328 338 } 329 339 doc.RKey = rkey 330 340 341 + // Fetch ATProto session to pass access token to the WebSocket client. 342 + editData := &DocumentEditData{Document: doc} 343 + if session, err := h.DB.GetATProtoSession(user.ID); err == nil && session != nil { 344 + editData.AccessToken = session.AccessToken 345 + // Check if this user is listed as a collaborator on the doc. 346 + userDID := session.DID 347 + for _, did := range doc.Collaborators { 348 + if did == userDID { 349 + editData.IsCollaborator = true 350 + break 351 + } 352 + } 353 + } 354 + 331 355 h.render(w, "document_edit.html", PageData{ 332 356 Title: "Edit " + doc.Title, 333 357 User: user, 334 - Content: doc, 358 + Content: editData, 335 359 }) 336 360 } 337 361
+169
static/css/editor.css
··· 199 199 } 200 200 201 201 .link-tooltip button:hover { opacity: 0.8; } 202 + 203 + /* ── Collaboration: Presence ─────────────────────────────────────────────── */ 204 + 205 + .presence-list { 206 + display: flex; 207 + align-items: center; 208 + gap: 4px; 209 + } 210 + 211 + .presence-avatar { 212 + display: inline-block; 213 + width: 22px; 214 + height: 22px; 215 + border-radius: 50%; 216 + border: 2px solid var(--bg-card); 217 + box-shadow: 0 0 0 1px var(--border); 218 + flex-shrink: 0; 219 + transition: transform 0.15s; 220 + } 221 + 222 + .presence-avatar:hover { 223 + transform: scale(1.15); 224 + } 225 + 226 + /* ── Collaboration: Comment sidebar ──────────────────────────────────────── */ 227 + 228 + .comment-sidebar { 229 + position: fixed; 230 + right: 0; 231 + top: 49px; /* below navbar */ 232 + bottom: 0; 233 + width: 260px; 234 + background: var(--bg-card); 235 + border-left: 1px solid var(--border); 236 + display: flex; 237 + flex-direction: column; 238 + z-index: 50; 239 + overflow: hidden; 240 + } 241 + 242 + .comment-sidebar-header { 243 + padding: 0.75rem 1rem; 244 + font-size: 0.85rem; 245 + font-weight: 600; 246 + border-bottom: 1px solid var(--border); 247 + flex-shrink: 0; 248 + color: var(--text-muted); 249 + text-transform: uppercase; 250 + letter-spacing: 0.05em; 251 + } 252 + 253 + .comment-threads { 254 + flex: 1; 255 + overflow-y: auto; 256 + padding: 0.75rem; 257 + display: flex; 258 + flex-direction: column; 259 + gap: 0.75rem; 260 + } 261 + 262 + .comment-empty { 263 + color: var(--text-muted); 264 + font-size: 0.85rem; 265 + text-align: center; 266 + margin-top: 2rem; 267 + } 268 + 269 + .comment-thread { 270 + background: var(--bg); 271 + border: 1px solid var(--border); 272 + border-radius: var(--radius); 273 + overflow: hidden; 274 + } 275 + 276 + .comment-thread-label { 277 + font-size: 0.75rem; 278 + color: var(--text-muted); 279 + padding: 0.3rem 0.6rem; 280 + background: var(--border); 281 + border-bottom: 1px solid var(--border); 282 + } 283 + 284 + .comment-item { 285 + padding: 0.5rem 0.6rem; 286 + border-top: 1px solid var(--border); 287 + } 288 + 289 + .comment-item:first-of-type { 290 + border-top: none; 291 + } 292 + 293 + .comment-author { 294 + font-size: 0.75rem; 295 + font-weight: 600; 296 + color: var(--primary); 297 + margin-bottom: 0.2rem; 298 + } 299 + 300 + .comment-text { 301 + font-size: 0.85rem; 302 + line-height: 1.45; 303 + word-break: break-word; 304 + } 305 + 306 + .comment-time { 307 + font-size: 0.7rem; 308 + color: var(--text-muted); 309 + margin-top: 0.25rem; 310 + } 311 + 312 + /* ── Collaboration: Comment button & form ────────────────────────────────── */ 313 + 314 + .comment-btn { 315 + position: fixed; 316 + z-index: 100; 317 + padding: 0.25rem 0.6rem; 318 + font-size: 0.78rem; 319 + background: var(--primary); 320 + color: #fff; 321 + border: none; 322 + border-radius: var(--radius); 323 + cursor: pointer; 324 + box-shadow: 0 2px 8px rgba(0,0,0,0.15); 325 + } 326 + 327 + .comment-btn:hover { 328 + opacity: 0.88; 329 + } 330 + 331 + .comment-form { 332 + position: fixed; 333 + z-index: 110; 334 + background: var(--bg-card); 335 + border: 1px solid var(--border); 336 + border-radius: var(--radius); 337 + box-shadow: 0 4px 20px rgba(0,0,0,0.15); 338 + padding: 0.75rem; 339 + width: 280px; 340 + } 341 + 342 + .comment-form textarea { 343 + width: 100%; 344 + box-sizing: border-box; 345 + background: var(--bg); 346 + border: 1px solid var(--border); 347 + border-radius: var(--radius); 348 + color: var(--text); 349 + padding: 0.4rem 0.5rem; 350 + font-size: 0.85rem; 351 + resize: vertical; 352 + outline: none; 353 + font-family: inherit; 354 + } 355 + 356 + .comment-form textarea:focus { 357 + border-color: var(--primary); 358 + } 359 + 360 + .comment-form-actions { 361 + display: flex; 362 + gap: 0.5rem; 363 + margin-top: 0.5rem; 364 + justify-content: flex-end; 365 + } 366 + 367 + /* When comment sidebar is visible, shrink editor to avoid overlap */ 368 + .editor-page:has(~ .comment-sidebar) { 369 + right: 260px; 370 + }
+231
templates/document_edit.html
··· 14 14 <input type="text" id="doc-title" value="{{.Title}}" placeholder="Document title" class="title-input"> 15 15 </div> 16 16 <div class="toolbar-actions"> 17 + {{if .IsCollaborator}} 18 + <div id="presence-list" class="presence-list" title="Active collaborators"></div> 19 + {{end}} 17 20 <button class="btn btn-sm btn-outline source-only active" id="btn-preview" onclick="togglePreview()">Preview</button> 18 21 <button class="btn btn-sm btn-outline source-only" id="btn-wrap" onclick="toggleWrap()">Wrap</button> 19 22 <button class="btn btn-sm btn-outline rich-only" id="btn-undo" onclick="richUndo()" title="Undo (⌘Z)">↩</button> ··· 28 31 <!-- Rich text editor (default) --> 29 32 <div id="editor-rich" class="editor-rich"></div> 30 33 34 + <!-- Comment button (shown on paragraph hover/selection) --> 35 + <button id="comment-btn" class="comment-btn" style="display:none" onclick="openCommentForm()">Comment</button> 36 + 37 + <!-- Comment form (floating) --> 38 + <div id="comment-form" class="comment-form" style="display:none"> 39 + <textarea id="comment-text" placeholder="Add a comment..." rows="3"></textarea> 40 + <div class="comment-form-actions"> 41 + <button class="btn btn-sm" onclick="submitComment()">Post</button> 42 + <button class="btn btn-sm btn-outline" onclick="closeCommentForm()">Cancel</button> 43 + </div> 44 + </div> 45 + 31 46 <!-- Link editing tooltip --> 32 47 <div id="link-tooltip" class="link-tooltip"> 33 48 <input type="url" id="link-tooltip-input" placeholder="https://" spellcheck="false"> ··· 47 62 </div> 48 63 </div> 49 64 </div> 65 + 66 + <!-- Comment sidebar --> 67 + {{if .IsCollaborator}} 68 + <div id="comment-sidebar" class="comment-sidebar"> 69 + <div class="comment-sidebar-header">Comments</div> 70 + <div id="comment-threads" class="comment-threads"></div> 71 + </div> 72 + {{end}} 50 73 {{end}} 51 74 {{end}} 52 75 ··· 65 88 const saveStatus = document.getElementById('save-status'); 66 89 const titleInput = document.getElementById('doc-title'); 67 90 const rkey = '{{.Content.RKey}}'; 91 + const accessToken = '{{.Content.AccessToken}}'; 92 + const isCollaborator = {{if .Content.IsCollaborator}}true{{else}}false{{end}}; 68 93 69 94 const STORAGE_KEY = 'editor-mode'; 70 95 let currentMode = localStorage.getItem(STORAGE_KEY) || 'rich'; // 'rich' | 'source' ··· 390 415 } 391 416 }); 392 417 418 + // ── WebSocket / Collaboration ───────────────────────────────────────────── 419 + 420 + let ws = null; 421 + let wsReconnectDelay = 1000; 422 + let wsReconnectTimer = null; 423 + let wsPingTimer = null; 424 + let wsMissedPings = 0; 425 + 426 + function connectWebSocket() { 427 + if (!isCollaborator || !accessToken) return; 428 + 429 + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 430 + const wsUrl = `${protocol}//${window.location.host}/ws/doc/${rkey}?access_token=${encodeURIComponent(accessToken)}&dpop_proof=placeholder`; 431 + 432 + ws = new WebSocket(wsUrl); 433 + 434 + ws.onopen = () => { 435 + wsReconnectDelay = 1000; 436 + wsMissedPings = 0; 437 + startHeartbeat(); 438 + }; 439 + 440 + ws.onmessage = (event) => { 441 + try { 442 + const msg = JSON.parse(event.data); 443 + handleWSMessage(msg); 444 + } catch (e) { 445 + console.error('WS parse error:', e); 446 + } 447 + }; 448 + 449 + ws.onclose = () => { 450 + stopHeartbeat(); 451 + updatePresence([]); 452 + scheduleReconnect(); 453 + }; 454 + 455 + ws.onerror = () => { 456 + ws.close(); 457 + }; 458 + } 459 + 460 + function scheduleReconnect() { 461 + clearTimeout(wsReconnectTimer); 462 + wsReconnectTimer = setTimeout(() => { 463 + connectWebSocket(); 464 + wsReconnectDelay = Math.min(wsReconnectDelay * 2, 30000); 465 + }, wsReconnectDelay); 466 + } 467 + 468 + function startHeartbeat() { 469 + stopHeartbeat(); 470 + wsPingTimer = setInterval(() => { 471 + if (ws && ws.readyState === WebSocket.OPEN) { 472 + ws.send(JSON.stringify({ type: 'ping' })); 473 + wsMissedPings++; 474 + if (wsMissedPings >= 3) { 475 + ws.close(); 476 + } 477 + } 478 + }, 30000); 479 + } 480 + 481 + function stopHeartbeat() { 482 + clearInterval(wsPingTimer); 483 + } 484 + 485 + function handleWSMessage(msg) { 486 + switch (msg.type) { 487 + case 'presence': 488 + updatePresence(msg.users || []); 489 + break; 490 + case 'pong': 491 + wsMissedPings = 0; 492 + break; 493 + case 'sync': 494 + // On reconnect, server sends current state — only apply if editor is idle 495 + break; 496 + } 497 + } 498 + 499 + // ── Presence ────────────────────────────────────────────────────────────── 500 + 501 + function updatePresence(users) { 502 + const list = document.getElementById('presence-list'); 503 + if (!list) return; 504 + list.innerHTML = users.map(u => ` 505 + <span class="presence-avatar" style="background:${u.color}" title="${escHtml(u.name || u.did)}"></span> 506 + `).join(''); 507 + } 508 + 509 + function escHtml(str) { 510 + return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 511 + } 512 + 513 + // ── Comments ────────────────────────────────────────────────────────────── 514 + 515 + let activeCommentParagraphId = null; 516 + 517 + const commentBtn = document.getElementById('comment-btn'); 518 + const commentForm = document.getElementById('comment-form'); 519 + const commentTextEl = document.getElementById('comment-text'); 520 + 521 + function openCommentForm() { 522 + if (!commentBtn || !commentForm) return; 523 + const rect = commentBtn.getBoundingClientRect(); 524 + commentForm.style.top = rect.bottom + window.scrollY + 4 + 'px'; 525 + commentForm.style.left = Math.max(8, rect.left) + 'px'; 526 + commentForm.style.display = 'block'; 527 + commentTextEl.value = ''; 528 + commentTextEl.focus(); 529 + } 530 + 531 + function closeCommentForm() { 532 + if (commentForm) commentForm.style.display = 'none'; 533 + if (commentBtn) commentBtn.style.display = 'none'; 534 + activeCommentParagraphId = null; 535 + } 536 + 537 + async function submitComment() { 538 + if (!activeCommentParagraphId) return; 539 + const text = commentTextEl.value.trim(); 540 + if (!text) return; 541 + 542 + try { 543 + const resp = await fetch(`/api/docs/${rkey}/comments`, { 544 + method: 'POST', 545 + headers: { 'Content-Type': 'application/json' }, 546 + body: JSON.stringify({ paragraphId: activeCommentParagraphId, text }), 547 + }); 548 + if (!resp.ok) throw new Error(await resp.text()); 549 + closeCommentForm(); 550 + loadComments(); 551 + } catch (e) { 552 + console.error('Comment post failed:', e); 553 + } 554 + } 555 + 556 + commentTextEl && commentTextEl.addEventListener('keydown', e => { 557 + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitComment(); 558 + if (e.key === 'Escape') closeCommentForm(); 559 + }); 560 + 561 + // Close comment form on outside click 562 + document.addEventListener('click', e => { 563 + if (commentForm && commentForm.style.display !== 'none') { 564 + if (!commentForm.contains(e.target) && e.target !== commentBtn) { 565 + closeCommentForm(); 566 + } 567 + } 568 + }); 569 + 570 + function renderCommentThreads(comments) { 571 + const container = document.getElementById('comment-threads'); 572 + if (!container) return; 573 + 574 + if (!comments || comments.length === 0) { 575 + container.innerHTML = '<p class="comment-empty">No comments yet.</p>'; 576 + return; 577 + } 578 + 579 + // Group by paragraphId 580 + const byParagraph = {}; 581 + for (const c of comments) { 582 + const pid = c.paragraphId || 'general'; 583 + if (!byParagraph[pid]) byParagraph[pid] = []; 584 + byParagraph[pid].push(c); 585 + } 586 + 587 + container.innerHTML = Object.entries(byParagraph).map(([pid, thread]) => ` 588 + <div class="comment-thread" data-paragraph="${escHtml(pid)}"> 589 + <div class="comment-thread-label">¶ ${escHtml(pid)}</div> 590 + ${thread.map(c => ` 591 + <div class="comment-item"> 592 + <div class="comment-author">${escHtml(c.authorName || c.author)}</div> 593 + <div class="comment-text">${escHtml(c.text)}</div> 594 + <div class="comment-time">${formatTime(c.createdAt)}</div> 595 + </div> 596 + `).join('')} 597 + </div> 598 + `).join(''); 599 + } 600 + 601 + function formatTime(ts) { 602 + if (!ts) return ''; 603 + try { return new Date(ts).toLocaleString(); } catch { return ts; } 604 + } 605 + 606 + async function loadComments() { 607 + if (!isCollaborator) return; 608 + try { 609 + const resp = await fetch(`/api/docs/${rkey}/comments`); 610 + if (!resp.ok) return; 611 + const comments = await resp.json(); 612 + renderCommentThreads(comments); 613 + } catch (e) { 614 + console.error('Load comments failed:', e); 615 + } 616 + } 617 + 393 618 // ── Init ────────────────────────────────────────────────────────────────── 394 619 395 620 const initialMarkdown = textarea.value; ··· 403 628 } 404 629 405 630 applyMode(currentMode); 631 + 632 + // Start collaboration features 633 + if (isCollaborator) { 634 + connectWebSocket(); 635 + loadComments(); 636 + } 406 637 </script> 407 638 {{end}}