fix(rnotes): Google Docs-like comment sidebar, fix suggestions + duplicate extensions

- Fix duplicate tiptap extension warnings by disabling link/underline in
  StarterKit v3 (which now includes them by default)
- Move comment panel from metaZone (destroyed by renderMeta) to dedicated
  comment sidebar next to the editor, Google Docs style
- Add click-on-highlight to open comment thread in sidebar
- New comment creation shows inline textarea with auto-focus
- Fix suggestion plugin: pass view getter instead of broken state.view access
- Improve comment panel styling: avatars, Google Docs yellow active border,
  cleaner thread layout, Ctrl+Enter to submit, Escape to cancel
- Bump folk-notes-app cache version to v=7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 16:29:21 -07:00
parent 8ba14a0e15
commit 110b733f94
4 changed files with 309 additions and 64 deletions

View File

@ -119,50 +119,128 @@ class NotesCommentPanel extends HTMLElement {
return `${Math.floor(diff / 86400000)}d ago`; return `${Math.floor(diff / 86400000)}d ago`;
}; };
const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const { authorId: currentUserId } = this.getSessionInfo(); const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo();
const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?';
const avatarColor = (id: string) => {
let h = 0;
for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h);
return `hsl(${Math.abs(h) % 360}, 55%, 55%)`;
};
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style> <style>
:host { display: block; } :host { display: block; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; }
.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 { padding: 8px 12px; overflow-y: auto; max-height: calc(100vh - 180px); }
.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; } .panel-title {
.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; } font-weight: 600; font-size: 13px; padding: 8px 0;
.thread:hover { border-color: var(--rs-border, #e5e7eb); } color: var(--rs-text-secondary, #666);
.thread.active { border-color: var(--rs-primary, #3b82f6); } display: flex; justify-content: space-between; align-items: center;
.thread.resolved { opacity: 0.6; } border-bottom: 1px solid var(--rs-border-subtle, #f0f0f0);
.thread-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } margin-bottom: 8px;
.thread-author { font-weight: 600; color: var(--rs-text-primary, #111); } }
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; } .thread {
.thread-actions { display: flex; gap: 4px; } margin-bottom: 8px;
.thread-action { padding: 2px 6px; border: none; background: none; color: var(--rs-text-secondary, #666); cursor: pointer; font-size: 11px; border-radius: 4px; } padding: 12px;
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); } border-radius: 8px;
.message { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); } background: var(--rs-bg-surface, #fff);
border: 1px solid var(--rs-border-subtle, #e8e8e8);
cursor: pointer;
transition: all 0.15s;
border-left: 3px solid transparent;
}
.thread:hover { box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.thread.active {
border-left-color: #fbbc04;
box-shadow: 0 1px 6px rgba(251, 188, 4, 0.2);
background: color-mix(in srgb, #fbbc04 4%, var(--rs-bg-surface, #fff));
}
.thread.resolved { opacity: 0.5; }
.thread.resolved:hover { opacity: 0.7; }
/* Author row with avatar */
.thread-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.avatar {
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: #fff; flex-shrink: 0;
}
.header-info { flex: 1; min-width: 0; }
.thread-author { font-weight: 600; font-size: 13px; color: var(--rs-text-primary, #111); }
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; margin-left: 6px; }
/* Messages */
.message { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
.message-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.message-avatar { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 600; color: #fff; flex-shrink: 0; }
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); } .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; } .message-time { font-size: 10px; color: var(--rs-text-muted, #aaa); }
.reply-form { margin-top: 8px; display: flex; gap: 6px; } .message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.5; padding-left: 26px; }
.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); } .first-message-text { color: var(--rs-text-primary, #111); line-height: 1.5; }
.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 form — Google Docs style */
.reply-btn:hover { opacity: 0.9; } .reply-form { margin-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); padding-top: 10px; }
.reply-input {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 8px; font-size: 13px; font-family: inherit;
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
resize: none; min-height: 36px;
}
.reply-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
.reply-input::placeholder { color: var(--rs-text-muted, #999); }
.reply-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
.reply-btn {
padding: 6px 14px; border: none; background: #1a73e8; color: #fff;
border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500;
}
.reply-btn:hover { background: #1557b0; }
.reply-cancel-btn {
padding: 6px 14px; border: none; background: transparent; color: var(--rs-text-secondary, #666);
border-radius: 6px; font-size: 12px; cursor: pointer;
}
.reply-cancel-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
/* Thread actions */
.thread-actions { display: flex; gap: 2px; margin-top: 8px; justify-content: flex-end; }
.thread-action {
padding: 4px 8px; border: none; background: none;
color: var(--rs-text-muted, #999); cursor: pointer;
font-size: 11px; border-radius: 4px;
}
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); color: var(--rs-text-primary, #111); }
.thread-action.resolve-btn { color: #1a73e8; }
.thread-action.resolve-btn:hover { background: color-mix(in srgb, #1a73e8 8%, transparent); }
/* Reactions */ /* Reactions */
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; align-items: center; } .reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; align-items: center; }
.reaction-pill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 12px; border: 1px solid var(--rs-border-subtle, #e0e0e0); background: var(--rs-bg-surface, #fff); font-size: 12px; cursor: pointer; transition: all 0.15s; user-select: none; } .reaction-pill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 12px; border: 1px solid var(--rs-border-subtle, #e0e0e0); background: var(--rs-bg-surface, #fff); font-size: 12px; cursor: pointer; transition: all 0.15s; user-select: none; }
.reaction-pill:hover { border-color: var(--rs-primary, #3b82f6); } .reaction-pill:hover { border-color: #1a73e8; }
.reaction-pill.active { border-color: var(--rs-primary, #3b82f6); background: color-mix(in srgb, var(--rs-primary, #3b82f6) 10%, transparent); } .reaction-pill.active { border-color: #1a73e8; background: color-mix(in srgb, #1a73e8 10%, transparent); }
.reaction-pill .count { font-size: 11px; color: var(--rs-text-secondary, #666); } .reaction-pill .count { font-size: 11px; color: var(--rs-text-secondary, #666); }
.reaction-add { padding: 2px 6px; border-radius: 12px; border: 1px dashed var(--rs-border-subtle, #ddd); background: none; font-size: 12px; cursor: pointer; color: var(--rs-text-muted, #999); } .reaction-add { padding: 2px 6px; border-radius: 12px; border: 1px dashed var(--rs-border-subtle, #ddd); background: none; font-size: 12px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reaction-add:hover { border-color: var(--rs-primary, #3b82f6); color: var(--rs-text-primary, #111); } .reaction-add:hover { border-color: #1a73e8; color: var(--rs-text-primary, #111); }
.emoji-picker { display: none; flex-wrap: wrap; gap: 2px; padding: 4px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); margin-top: 4px; } .emoji-picker { display: none; flex-wrap: wrap; gap: 2px; padding: 4px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); margin-top: 4px; }
.emoji-picker.open { display: flex; } .emoji-picker.open { display: flex; }
.emoji-pick { padding: 4px 6px; border: none; background: none; font-size: 16px; cursor: pointer; border-radius: 4px; } .emoji-pick { padding: 4px 6px; border: none; background: none; font-size: 16px; cursor: pointer; border-radius: 4px; }
.emoji-pick:hover { background: var(--rs-bg-hover, #f5f5f5); } .emoji-pick:hover { background: var(--rs-bg-hover, #f5f5f5); }
/* Reminders */ /* Reminders */
.reminder-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; font-size: 12px; } .reminder-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; font-size: 12px; }
.reminder-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: color-mix(in srgb, var(--rs-warning, #f59e0b) 15%, transparent); color: var(--rs-text-primary, #111); font-size: 11px; } .reminder-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: color-mix(in srgb, var(--rs-warning, #f59e0b) 15%, transparent); color: var(--rs-text-primary, #111); font-size: 11px; }
.reminder-btn { padding: 2px 8px; border: 1px solid var(--rs-border-subtle, #ddd); border-radius: 12px; background: none; font-size: 11px; cursor: pointer; color: var(--rs-text-secondary, #666); } .reminder-btn { padding: 2px 8px; border: 1px solid var(--rs-border-subtle, #ddd); border-radius: 12px; background: none; font-size: 11px; cursor: pointer; color: var(--rs-text-secondary, #666); }
.reminder-btn:hover { border-color: var(--rs-primary, #3b82f6); } .reminder-btn:hover { border-color: #1a73e8; }
.reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); } .reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); } .reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
/* New comment input — shown when thread has no messages */
.new-comment-form { margin-top: 4px; }
.new-comment-input {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 8px; font-size: 13px; font-family: inherit;
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
resize: none; min-height: 60px;
}
.new-comment-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
.new-comment-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
</style> </style>
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
@ -171,19 +249,43 @@ class NotesCommentPanel extends HTMLElement {
${threads.map(thread => { ${threads.map(thread => {
const reactions = thread.reactions || {}; const reactions = thread.reactions || {};
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0); const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
const isActive = thread.id === this._activeThreadId;
const hasMessages = thread.messages.length > 0;
const firstMsg = thread.messages[0];
const authorName = firstMsg?.authorName || currentUserName;
const authorId = firstMsg?.authorId || currentUserId;
return ` return `
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}"> <div class="thread ${isActive ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
<div class="thread-header"> <div class="thread-header">
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span> <div class="avatar" style="background: ${avatarColor(authorId)}">${initials(authorName)}</div>
<span class="thread-time">${timeAgo(thread.createdAt)}</span> <div class="header-info">
</div> <span class="thread-author">${esc(authorName)}</span>
${thread.messages.map(msg => ` <span class="thread-time">${timeAgo(thread.createdAt)}</span>
<div class="message">
<div class="message-author">${esc(msg.authorName)}</div>
<div class="message-text">${esc(msg.text)}</div>
</div> </div>
`).join('')} </div>
${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>' : ''} ${hasMessages ? `
<div class="first-message-text">${esc(firstMsg.text)}</div>
${thread.messages.slice(1).map(msg => `
<div class="message">
<div class="message-header">
<div class="message-avatar" style="background: ${avatarColor(msg.authorId)}">${initials(msg.authorName)}</div>
<span class="message-author">${esc(msg.authorName)}</span>
<span class="message-time">${timeAgo(msg.createdAt)}</span>
</div>
<div class="message-text">${esc(msg.text)}</div>
</div>
`).join('')}
` : `
<div class="new-comment-form">
<textarea class="new-comment-input" placeholder="Add your comment..." data-new-thread="${thread.id}" autofocus></textarea>
<div class="new-comment-actions">
<button class="reply-cancel-btn" data-cancel-new="${thread.id}">Cancel</button>
<button class="reply-btn" data-submit-new="${thread.id}">Comment</button>
</div>
</div>
`}
${hasMessages && reactionEntries.length > 0 ? `
<div class="reactions-row"> <div class="reactions-row">
${reactionEntries.map(([emoji, users]) => ` ${reactionEntries.map(([emoji, users]) => `
<button class="reaction-pill ${users.includes(currentUserId) ? 'active' : ''}" data-react-thread="${thread.id}" data-react-emoji="${emoji}">${emoji} <span class="count">${users.length}</span></button> <button class="reaction-pill ${users.includes(currentUserId) ? 'active' : ''}" data-react-thread="${thread.id}" data-react-emoji="${emoji}">${emoji} <span class="count">${users.length}</span></button>
@ -193,19 +295,28 @@ class NotesCommentPanel extends HTMLElement {
<div class="emoji-picker" data-picker="${thread.id}"> <div class="emoji-picker" data-picker="${thread.id}">
${REACTION_EMOJIS.map(e => `<button class="emoji-pick" data-pick-thread="${thread.id}" data-pick-emoji="${e}">${e}</button>`).join('')} ${REACTION_EMOJIS.map(e => `<button class="emoji-pick" data-pick-thread="${thread.id}" data-pick-emoji="${e}">${e}</button>`).join('')}
</div> </div>
` : ''}
${hasMessages && thread.reminderAt ? `
<div class="reminder-row"> <div class="reminder-row">
${thread.reminderAt <span class="reminder-badge">&#9200; ${formatDate(thread.reminderAt)}</span>
? `<span class="reminder-badge">&#9200; ${formatDate(thread.reminderAt)}</span><button class="reminder-clear" data-remind-clear="${thread.id}">&#10005;</button>` <button class="reminder-clear" data-remind-clear="${thread.id}">&#10005;</button>
: `<button class="reminder-btn" data-remind-set="${thread.id}">&#9200; Remind me</button>`
}
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
</div> </div>
` : ''}
${hasMessages ? `
<div class="reply-form"> <div class="reply-form">
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}"> <input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
<button class="reply-btn" data-reply="${thread.id}">Reply</button> <div class="reply-actions">
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
</div>
</div> </div>
` : ''}
<div class="thread-actions"> <div class="thread-actions">
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button> ${hasMessages ? `
<button class="reaction-add" data-react-add="${thread.id}" title="React" style="font-size:13px">+</button>
<button class="thread-action" data-remind-set="${thread.id}" title="Set reminder">&#9200;</button>
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
` : ''}
<button class="thread-action resolve-btn" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
<button class="thread-action" data-delete="${thread.id}">Delete</button> <button class="thread-action" data-delete="${thread.id}">Delete</button>
</div> </div>
</div>`; </div>`;
@ -214,12 +325,21 @@ class NotesCommentPanel extends HTMLElement {
`; `;
this.wireEvents(); this.wireEvents();
// Auto-focus new comment textarea
requestAnimationFrame(() => {
const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement;
if (newInput) newInput.focus();
});
} }
private wireEvents() { private wireEvents() {
// Click thread to scroll editor to it // Click thread to scroll editor to it
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => { this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
// Don't handle clicks on inputs/buttons/textareas
const target = e.target as HTMLElement;
if (target.closest('input, textarea, button')) return;
const threadId = (el as HTMLElement).dataset.thread; const threadId = (el as HTMLElement).dataset.thread;
if (!threadId || !this._editor) return; if (!threadId || !this._editor) return;
this._activeThreadId = threadId; this._activeThreadId = threadId;
@ -236,6 +356,46 @@ class NotesCommentPanel extends HTMLElement {
}); });
}); });
// New comment submit (thread with no messages yet)
this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.submitNew;
if (!threadId) return;
const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement;
const text = textarea?.value?.trim();
if (!text) return;
this.addReply(threadId, text);
});
});
// New comment cancel — delete the empty thread
this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.cancelNew;
if (threadId) this.deleteThread(threadId);
});
});
// New comment textarea — Ctrl+Enter to submit, Escape to cancel
this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => {
textarea.addEventListener('keydown', (e) => {
const ke = e as KeyboardEvent;
if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) {
e.stopPropagation();
const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
const text = (textarea as HTMLTextAreaElement).value.trim();
if (threadId && text) this.addReply(threadId, text);
} else if (ke.key === 'Escape') {
e.stopPropagation();
const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
if (threadId) this.deleteThread(threadId);
}
});
textarea.addEventListener('click', (e) => e.stopPropagation());
});
// Reply // Reply
this.shadow.querySelectorAll('[data-reply]').forEach(btn => { this.shadow.querySelectorAll('[data-reply]').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {

View File

@ -1216,14 +1216,17 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const useYjs = !isDemo && isEditable; const useYjs = !isDemo && isEditable;
this.contentZone.innerHTML = ` this.contentZone.innerHTML = `
<div class="editor-wrapper"> <div class="editor-with-comments">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title..."> <div class="editor-wrapper">
${isEditable ? this.renderToolbar() : ''} <input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
<div class="collab-status-bar" id="collab-status-bar" style="display:none"> ${isEditable ? this.renderToolbar() : ''}
<span class="collab-status-dot"></span> <div class="collab-status-bar" id="collab-status-bar" style="display:none">
<span class="collab-status-text"></span> <span class="collab-status-dot"></span>
<span class="collab-status-text"></span>
</div>
<div class="tiptap-container" id="tiptap-container"></div>
</div> </div>
<div class="tiptap-container" id="tiptap-container"></div> <div class="comment-sidebar" id="comment-sidebar"></div>
</div> </div>
`; `;
@ -1257,6 +1260,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.wireTitleInput(note, isEditable, isDemo); this.wireTitleInput(note, isEditable, isDemo);
this.attachToolbarListeners(); this.attachToolbarListeners();
this.wireCommentHighlightClicks();
} }
/** Mount TipTap with Yjs collaboration (real-time co-editing). */ /** Mount TipTap with Yjs collaboration (real-time co-editing). */
@ -1306,6 +1310,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
codeBlock: false, codeBlock: false,
heading: { levels: [1, 2, 3, 4] }, heading: { levels: [1, 2, 3, 4] },
undoRedo: false, // Yjs has its own undo/redo undoRedo: false, // Yjs has its own undo/redo
link: false,
underline: false,
}), }),
Link.configure({ openOnClick: false }), Link.configure({ openOnClick: false }),
Image, TaskList, TaskItem.configure({ nested: true }), Image, TaskList, TaskItem.configure({ nested: true }),
@ -1329,6 +1335,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const s = this.getSessionInfo(); const s = this.getSessionInfo();
return { authorId: s.userId, authorName: s.username }; return { authorId: s.userId, authorName: s.username };
}, },
() => this.editor?.view ?? null,
); );
this.editor.registerPlugin(suggestionPlugin); this.editor.registerPlugin(suggestionPlugin);
@ -1379,7 +1386,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
element: container, element: container,
editable: isEditable, editable: isEditable,
extensions: [ extensions: [
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] } }), StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] }, link: false, underline: false }),
Link.configure({ openOnClick: false }), Link.configure({ openOnClick: false }),
Image, TaskList, TaskItem.configure({ nested: true }), Image, TaskList, TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }), Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
@ -1656,7 +1663,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.editor = new Editor({ this.editor = new Editor({
element: container, editable: isEditable, element: container, editable: isEditable,
extensions: [ extensions: [
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] } }), StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] }, link: false, underline: false }),
Link.configure({ openOnClick: false }), Image, Link.configure({ openOnClick: false }), Image,
Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }), Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }),
Typography, Underline, Typography, Underline,
@ -1726,7 +1733,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.editor = new Editor({ this.editor = new Editor({
element: container, editable: isEditable, element: container, editable: isEditable,
extensions: [ extensions: [
StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }), StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline, Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline,
], ],
content, content,
@ -1795,7 +1802,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.editor = new Editor({ this.editor = new Editor({
element: container, editable: isEditable, element: container, editable: isEditable,
extensions: [ extensions: [
StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }), StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline, Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline,
], ],
content, content,
@ -2300,10 +2307,13 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
/** Show comment panel for a specific thread. */ /** Show comment panel for a specific thread. */
private showCommentPanel(threadId?: string) { private showCommentPanel(threadId?: string) {
const sidebar = this.shadow.getElementById('comment-sidebar');
if (!sidebar) return;
let panel = this.shadow.querySelector('notes-comment-panel') as any; let panel = this.shadow.querySelector('notes-comment-panel') as any;
if (!panel) { if (!panel) {
panel = document.createElement('notes-comment-panel'); panel = document.createElement('notes-comment-panel');
this.metaZone.appendChild(panel); sidebar.appendChild(panel);
// Listen for demo thread mutations from comment panel // Listen for demo thread mutations from comment panel
panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => { panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => {
const { noteId, threads } = e.detail; const { noteId, threads } = e.detail;
@ -2322,6 +2332,44 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
} else { } else {
panel.demoThreads = null; panel.demoThreads = null;
} }
// Show sidebar when there are comments
sidebar.classList.add('has-comments');
}
/** Hide comment sidebar when no comments exist. */
private hideCommentPanel() {
const sidebar = this.shadow.getElementById('comment-sidebar');
if (sidebar) sidebar.classList.remove('has-comments');
}
/** Wire click handling on comment highlights in the editor to open comment panel. */
private wireCommentHighlightClicks() {
if (!this.editor) return;
// On selection change, check if cursor is inside a comment mark
this.editor.on('selectionUpdate', () => {
if (!this.editor) return;
const { $from } = this.editor.state.selection;
const commentMark = $from.marks().find(m => m.type.name === 'comment');
if (commentMark) {
const threadId = commentMark.attrs.threadId;
if (threadId) this.showCommentPanel(threadId);
}
});
// Direct click on comment highlight in the DOM
const container = this.shadow.getElementById('tiptap-container');
if (container) {
container.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const highlight = target.closest?.('.comment-highlight') as HTMLElement;
if (highlight) {
const threadId = highlight.getAttribute('data-thread-id');
if (threadId) this.showCommentPanel(threadId);
}
});
}
} }
private toggleDictation(btn: HTMLElement) { private toggleDictation(btn: HTMLElement) {
@ -2994,6 +3042,40 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
.notes-right-col #content-zone { flex: 1; overflow-y: auto; padding: 20px; } .notes-right-col #content-zone { flex: 1; overflow-y: auto; padding: 20px; }
.notes-right-col #meta-zone { padding: 0 20px 12px; } .notes-right-col #meta-zone { padding: 0 20px 12px; }
/* ── Google Docs-like comment sidebar layout ── */
.editor-with-comments {
display: flex;
gap: 0;
min-height: 100%;
}
.editor-with-comments > .editor-wrapper {
flex: 1;
min-width: 0;
}
.comment-sidebar {
width: 0;
overflow: hidden;
transition: width 0.2s ease;
flex-shrink: 0;
}
.comment-sidebar.has-comments {
width: 280px;
border-left: 1px solid var(--rs-border, #e5e7eb);
}
@media (max-width: 768px) {
.comment-sidebar.has-comments { width: 240px; }
}
@media (max-width: 480px) {
.editor-with-comments { flex-direction: column; }
.comment-sidebar.has-comments {
width: 100%;
border-left: none;
border-top: 1px solid var(--rs-border, #e5e7eb);
max-height: 250px;
overflow-y: auto;
}
}
/* Empty state */ /* Empty state */
.editor-empty-state { .editor-empty-state {
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
@ -3474,19 +3556,20 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
margin-left: 2px; margin-left: 2px;
} }
/* ── Collaboration: Comment Highlights ── */ /* ── Collaboration: Comment Highlights (Google Docs style) ── */
.tiptap-container .tiptap .comment-highlight { .tiptap-container .tiptap .comment-highlight {
background: rgba(250, 204, 21, 0.25); background: rgba(251, 188, 4, 0.2);
border-bottom: 2px solid rgba(250, 204, 21, 0.5); border-bottom: 2px solid rgba(251, 188, 4, 0.5);
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
border-radius: 1px;
} }
.tiptap-container .tiptap .comment-highlight:hover { .tiptap-container .tiptap .comment-highlight:hover {
background: rgba(250, 204, 21, 0.4); background: rgba(251, 188, 4, 0.35);
} }
.tiptap-container .tiptap .comment-highlight.resolved { .tiptap-container .tiptap .comment-highlight.resolved {
background: rgba(250, 204, 21, 0.08); background: rgba(251, 188, 4, 0.06);
border-bottom-color: rgba(250, 204, 21, 0.15); border-bottom-color: rgba(251, 188, 4, 0.12);
} }
/* ── Collaboration: Suggestions ── */ /* ── Collaboration: Suggestions ── */

View File

@ -10,6 +10,7 @@
*/ */
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'; import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import type { Editor } from '@tiptap/core'; import type { Editor } from '@tiptap/core';
const pluginKey = new PluginKey('suggestion-plugin'); const pluginKey = new PluginKey('suggestion-plugin');
@ -24,10 +25,12 @@ interface SuggestionPluginState {
* Create the suggestion mode ProseMirror plugin. * Create the suggestion mode ProseMirror plugin.
* @param getSuggesting - callback that returns current suggesting mode state * @param getSuggesting - callback that returns current suggesting mode state
* @param getAuthor - callback that returns { authorId, authorName } * @param getAuthor - callback that returns { authorId, authorName }
* @param getView - callback that returns the EditorView (needed to dispatch replacement transactions)
*/ */
export function createSuggestionPlugin( export function createSuggestionPlugin(
getSuggesting: () => boolean, getSuggesting: () => boolean,
getAuthor: () => { authorId: string; authorName: string }, getAuthor: () => { authorId: string; authorName: string },
getView?: () => EditorView | null,
): Plugin { ): Plugin {
return new Plugin({ return new Plugin({
key: pluginKey, key: pluginKey,
@ -125,10 +128,9 @@ export function createSuggestionPlugin(
}); });
if (blocked && newTr.docChanged) { if (blocked && newTr.docChanged) {
// Dispatch our modified transaction instead // Dispatch our modified transaction instead on the next tick
// We need to use view.dispatch in the next tick
setTimeout(() => { setTimeout(() => {
const view = (state as any).view; const view = getView?.();
if (view) view.dispatch(newTr); if (view) view.dispatch(newTr);
}, 0); }, 0);
return false; // Block the original transaction return false; // Block the original transaction

View File

@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`, body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=6"></script>`, scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=7"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`, styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
})); }));
}); });