/** * — Right sidebar panel for viewing/managing inline comments. * * Shows threaded comments anchored to highlighted text in the editor. * Comment thread data is stored in Automerge, while the highlight mark * position is stored in Yjs (part of the document content). */ import type { Editor } from '@tiptap/core'; import type { DocumentId } from '../../../shared/local-first/document'; interface CommentMessage { id: string; authorId: string; authorName: string; text: string; createdAt: number; } interface CommentThread { id: string; anchor: string; resolved: boolean; messages: CommentMessage[]; createdAt: number; } interface NotebookDoc { items: Record; [key: string]: any; }>; [key: string]: any; } class NotesCommentPanel extends HTMLElement { private shadow: ShadowRoot; private _noteId: string | null = null; private _doc: any = null; private _subscribedDocId: string | null = null; private _activeThreadId: string | null = null; private _editor: Editor | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } set noteId(v: string | null) { this._noteId = v; this.render(); } set doc(v: any) { this._doc = v; this.render(); } set subscribedDocId(v: string | null) { this._subscribedDocId = v; } set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); } set editor(v: Editor | null) { this._editor = v; } private getThreads(): CommentThread[] { if (!this._doc || !this._noteId) return []; const item = this._doc.items?.[this._noteId]; if (!item?.comments) return []; return Object.values(item.comments as Record) .sort((a, b) => a.createdAt - b.createdAt); } private render() { const threads = this.getThreads(); if (threads.length === 0 && !this._activeThreadId) { this.shadow.innerHTML = ''; return; } const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; const timeAgo = (ts: number) => { const diff = Date.now() - ts; if (diff < 60000) return 'just now'; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return `${Math.floor(diff / 86400000)}d ago`; }; this.shadow.innerHTML = `
Comments (${threads.filter(t => !t.resolved).length})
${threads.map(thread => `
${esc(thread.messages[0]?.authorName || 'Anonymous')} ${timeAgo(thread.createdAt)}
${thread.messages.map(msg => `
${esc(msg.authorName)}
${esc(msg.text)}
`).join('')} ${thread.messages.length === 0 ? '
Click to add a comment...
' : ''}
`).join('')}
`; this.wireEvents(); } private wireEvents() { // Click thread to scroll editor to it this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => { el.addEventListener('click', (e) => { const threadId = (el as HTMLElement).dataset.thread; if (!threadId || !this._editor) return; this._activeThreadId = threadId; // Find the comment mark in the editor and scroll to it this._editor.state.doc.descendants((node, pos) => { if (!node.isText) return; const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); if (mark) { this._editor!.commands.setTextSelection(pos); this._editor!.commands.scrollIntoView(); return false; } }); this.render(); }); }); // Reply this.shadow.querySelectorAll('[data-reply]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.reply; if (!threadId) return; const input = this.shadow.querySelector(`input[data-thread="${threadId}"]`) as HTMLInputElement; const text = input?.value?.trim(); if (!text) return; this.addReply(threadId, text); input.value = ''; }); }); // Reply on Enter this.shadow.querySelectorAll('.reply-input').forEach(input => { input.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') { e.stopPropagation(); const threadId = (input as HTMLInputElement).dataset.thread; const text = (input as HTMLInputElement).value.trim(); if (threadId && text) { this.addReply(threadId, text); (input as HTMLInputElement).value = ''; } } }); input.addEventListener('click', (e) => e.stopPropagation()); }); // Resolve / re-open this.shadow.querySelectorAll('[data-resolve]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.resolve; if (threadId) this.toggleResolve(threadId); }); }); // Delete this.shadow.querySelectorAll('[data-delete]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.delete; if (threadId) this.deleteThread(threadId); }); }); } private addReply(threadId: string, text: string) { if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; let authorName = 'Anonymous'; let authorId = 'anon'; try { const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); authorName = sess?.username || sess?.displayName || 'Anonymous'; authorId = sess?.userId || sess?.sub || 'anon'; } catch {} const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => { const item = d.items[noteId]; if (!item?.comments?.[threadId]) return; const thread = item.comments[threadId] as any; if (!thread.messages) thread.messages = []; thread.messages.push({ id: `m_${Date.now()}`, authorId, authorName, text, createdAt: Date.now(), }); }); this._doc = runtime.get(this._subscribedDocId as DocumentId); this.render(); } private toggleResolve(threadId: string) { if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Toggle comment resolve', (d: NotebookDoc) => { const item = d.items[noteId]; if (!item?.comments?.[threadId]) return; (item.comments[threadId] as any).resolved = !(item.comments[threadId] as any).resolved; }); this._doc = runtime.get(this._subscribedDocId as DocumentId); // Also update the mark in the editor if (this._editor) { const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId]; if (thread) { this._editor.state.doc.descendants((node, pos) => { if (!node.isText) return; const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); if (mark) { const { tr } = this._editor!.state; tr.removeMark(pos, pos + node.nodeSize, mark); tr.addMark(pos, pos + node.nodeSize, this._editor!.schema.marks.comment.create({ threadId, resolved: thread.resolved }) ); this._editor!.view.dispatch(tr); return false; } }); } } this.render(); } private deleteThread(threadId: string) { if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Delete comment thread', (d: NotebookDoc) => { const item = d.items[noteId]; if (item?.comments?.[threadId]) { delete (item.comments as any)[threadId]; } }); this._doc = runtime.get(this._subscribedDocId as DocumentId); // Remove the comment mark from the editor if (this._editor) { const { state } = this._editor; const { tr } = state; state.doc.descendants((node, pos) => { if (!node.isText) return; const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); if (mark) { tr.removeMark(pos, pos + node.nodeSize, mark); } }); if (tr.docChanged) { this._editor.view.dispatch(tr); } } if (this._activeThreadId === threadId) this._activeThreadId = null; this.render(); } } customElements.define('notes-comment-panel', NotesCommentPanel);