Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Comments Sidebar UI — threaded comments rendering, reply/resolve/reopen
3 * actions, Yjs sync, and new-comment input wiring.
4 *
5 * Extracted from main.ts for decomposition.
6 */
7
8import type * as Y from 'yjs';
9import type { Editor } from '@tiptap/core';
10import {
11 type CommentThread,
12 createThread,
13 addReply,
14 resolveThread,
15 reopenThread,
16 sortThreads,
17 generateCommentId,
18} from '../lib/comment-threads.js';
19import { escapeHtml } from '../lib/ai-chat.js';
20
21// ── Types ───────────────────────────────────────────────────
22
23export interface CommentsSidebarDeps {
24 editor: Editor;
25 ydoc: Y.Doc;
26 userName: string;
27}
28
29export interface CommentsSidebarResult {
30 loadCommentsFromYjs: () => void;
31}
32
33// ── Comments Sidebar UI ─────────────────────────────────────
34
35export function wireCommentsSidebar(deps: CommentsSidebarDeps): CommentsSidebarResult {
36 const { editor, ydoc, userName } = deps;
37
38 const commentsSidebar = document.getElementById('comments-sidebar') as HTMLElement;
39 const commentsListEl = document.getElementById('comments-list') as HTMLElement;
40 const commentsSidebarClose = document.getElementById('comments-sidebar-close') as HTMLElement;
41 const commentNewInput = document.getElementById('comment-new-input') as HTMLInputElement;
42 const btnComments = document.getElementById('btn-comments') as HTMLElement;
43
44 let commentThreads: CommentThread[] = [];
45
46 function renderCommentThreads(): void {
47 const sorted = sortThreads(commentThreads, 'unresolved');
48 if (sorted.length === 0) {
49 commentsListEl.innerHTML = '<div style="padding:var(--space-sm);color:var(--color-text-faint);font-size:0.8rem;">No comments yet. Select text and click the comment button to start a thread.</div>';
50 return;
51 }
52 commentsListEl.innerHTML = sorted.map(t => {
53 const allComments = [t.root, ...t.replies];
54 return `<div class="comment-thread ${t.resolved ? 'resolved' : ''}" data-thread-id="${t.id}">
55 ${allComments.map(c => `<div>
56 <span class="comment-author">${escapeHtml(c.author)}</span>
57 <span class="comment-time">${new Date(c.createdAt).toLocaleString()}</span>
58 <div class="comment-body">${escapeHtml(c.text)}</div>
59 </div>`).join('')}
60 <div class="comment-actions">
61 ${t.resolved
62 ? `<button data-action="reopen" data-id="${t.id}">Reopen</button>`
63 : `<button data-action="resolve" data-id="${t.id}">Resolve</button>`}
64 <button data-action="reply" data-id="${t.id}">Reply</button>
65 </div>
66 </div>`;
67 }).join('');
68
69 commentsListEl.querySelectorAll('[data-action]').forEach(btn => {
70 btn.addEventListener('click', () => {
71 const action = btn.getAttribute('data-action');
72 const threadId = btn.getAttribute('data-id')!;
73 const idx = commentThreads.findIndex(t => t.id === threadId);
74 if (idx === -1) return;
75
76 if (action === 'resolve') {
77 commentThreads[idx] = resolveThread(commentThreads[idx], userName);
78 renderCommentThreads();
79 syncCommentsToYjs();
80 } else if (action === 'reopen') {
81 commentThreads[idx] = reopenThread(commentThreads[idx]);
82 renderCommentThreads();
83 syncCommentsToYjs();
84 } else if (action === 'reply') {
85 (async () => {
86 const { modalPrompt } = await import('../lib/modal-dialog.js');
87 const text = await modalPrompt({
88 title: 'Reply',
89 placeholder: 'Type your reply…',
90 okLabel: 'Reply',
91 });
92 if (text) {
93 commentThreads[idx] = addReply(commentThreads[idx], userName, text);
94 renderCommentThreads();
95 syncCommentsToYjs();
96 }
97 })();
98 }
99 });
100 });
101 }
102
103 function syncCommentsToYjs(): void {
104 ydoc.getMap('meta').set('commentThreads', JSON.stringify(commentThreads));
105 }
106
107 function loadCommentsFromYjs(): void {
108 const raw = ydoc.getMap('meta').get('commentThreads') as string | undefined;
109 if (raw) {
110 try { commentThreads = JSON.parse(raw); } catch { commentThreads = []; }
111 }
112 renderCommentThreads();
113 }
114
115 // Sync comments on yjs changes
116 ydoc.getMap('meta').observe(() => { loadCommentsFromYjs(); });
117
118 btnComments.addEventListener('click', () => {
119 const showing = commentsSidebar.style.display !== 'none';
120 commentsSidebar.style.display = showing ? 'none' : '';
121 if (!showing) renderCommentThreads();
122 });
123
124 commentsSidebarClose.addEventListener('click', () => {
125 commentsSidebar.style.display = 'none';
126 });
127
128 // Add comment from sidebar input on Enter (requires text selection in editor)
129 commentNewInput.addEventListener('keydown', (e) => {
130 if (e.key !== 'Enter') return;
131 const text = commentNewInput.value.trim();
132 if (!text) return;
133
134 const { from, to } = editor.state.selection;
135 if (from === to) {
136 commentNewInput.placeholder = 'Select text first...';
137 return;
138 }
139
140 const anchorId = generateCommentId();
141 editor.chain().focus().setComment({
142 commentId: anchorId,
143 author: userName,
144 timestamp: new Date().toISOString(),
145 text,
146 }).run();
147
148 const thread = createThread(anchorId, userName, text);
149 commentThreads.push(thread);
150 renderCommentThreads();
151 syncCommentsToYjs();
152 commentNewInput.value = '';
153 });
154
155 return { loadCommentsFromYjs };
156}