309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
/**
|
|
* <notes-comment-panel> — 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<string, {
|
|
comments?: Record<string, CommentThread>;
|
|
[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<string, CommentThread>)
|
|
.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 = `
|
|
<style>
|
|
:host { display: block; }
|
|
.panel { border-left: 1px solid var(--rs-border, #e5e7eb); padding: 12px; font-family: system-ui, sans-serif; font-size: 13px; max-height: 80vh; overflow-y: auto; }
|
|
.panel-title { font-weight: 600; font-size: 14px; margin-bottom: 12px; color: var(--rs-text-primary, #111); display: flex; justify-content: space-between; align-items: center; }
|
|
.thread { margin-bottom: 16px; padding: 10px; border-radius: 8px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border-subtle, #f0f0f0); cursor: pointer; transition: border-color 0.15s; }
|
|
.thread:hover { border-color: var(--rs-border, #e5e7eb); }
|
|
.thread.active { border-color: var(--rs-primary, #3b82f6); }
|
|
.thread.resolved { opacity: 0.6; }
|
|
.thread-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
.thread-author { font-weight: 600; color: var(--rs-text-primary, #111); }
|
|
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; }
|
|
.thread-actions { display: flex; gap: 4px; }
|
|
.thread-action { padding: 2px 6px; border: none; background: none; color: var(--rs-text-secondary, #666); cursor: pointer; font-size: 11px; border-radius: 4px; }
|
|
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); }
|
|
.message { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
|
|
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); }
|
|
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.4; }
|
|
.reply-form { margin-top: 8px; display: flex; gap: 6px; }
|
|
.reply-input { flex: 1; padding: 6px 8px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 12px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
|
|
.reply-input:focus { border-color: var(--rs-primary, #3b82f6); outline: none; }
|
|
.reply-btn { padding: 6px 10px; border: none; background: var(--rs-primary, #3b82f6); color: #fff; border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500; }
|
|
.reply-btn:hover { opacity: 0.9; }
|
|
</style>
|
|
<div class="panel">
|
|
<div class="panel-title">
|
|
<span>Comments (${threads.filter(t => !t.resolved).length})</span>
|
|
</div>
|
|
${threads.map(thread => `
|
|
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
|
|
<div class="thread-header">
|
|
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span>
|
|
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
|
|
</div>
|
|
${thread.messages.map(msg => `
|
|
<div class="message">
|
|
<div class="message-author">${esc(msg.authorName)}</div>
|
|
<div class="message-text">${esc(msg.text)}</div>
|
|
</div>
|
|
`).join('')}
|
|
${thread.messages.length === 0 ? '<div class="message"><div class="message-text" style="color: var(--rs-text-muted, #999)">Click to add a comment...</div></div>' : ''}
|
|
<div class="reply-form">
|
|
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
|
|
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
|
|
</div>
|
|
<div class="thread-actions">
|
|
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
|
|
<button class="thread-action" data-delete="${thread.id}">Delete</button>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
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);
|