From 99fa59c8dfccb01b4c5e2c3b80a9b7eb2ed951b1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Mar 2026 16:28:42 -0700 Subject: [PATCH] feat(rnotes): suggestions in sidebar panel with per-item accept/reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suggestions now appear as cards in the right-hand comment sidebar with author, type (Added/Deleted), text preview, and Accept/Reject - Clicking a suggestion card scrolls the editor to the marked text - Removed "Accept All" and "Reject All" from the review bar — too blunt - Review bar now directs users to the sidebar for granular review - Sidebar auto-opens when suggestions exist - Inline popover accept/reject still works for quick actions Co-Authored-By: Claude Opus 4.6 --- modules/rnotes/components/comment-panel.ts | 115 +++++++++++++++++++- modules/rnotes/components/folk-notes-app.ts | 10 -- modules/rnotes/mod.ts | 2 +- 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts index aaafd60..3f212a2 100644 --- a/modules/rnotes/components/comment-panel.ts +++ b/modules/rnotes/components/comment-panel.ts @@ -40,6 +40,15 @@ interface NotebookDoc { const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥']; +interface SuggestionEntry { + id: string; + type: 'insert' | 'delete'; + text: string; + authorId: string; + authorName: string; + createdAt: number; +} + class NotesCommentPanel extends HTMLElement { private shadow: ShadowRoot; private _noteId: string | null = null; @@ -49,6 +58,7 @@ class NotesCommentPanel extends HTMLElement { private _editor: Editor | null = null; private _demoThreads: Record | null = null; private _space = ''; + private _suggestions: SuggestionEntry[] = []; constructor() { super(); @@ -65,6 +75,10 @@ class NotesCommentPanel extends HTMLElement { this._demoThreads = v; this.render(); } + set suggestions(v: SuggestionEntry[]) { + this._suggestions = v; + this.render(); + } private get isDemo(): boolean { return this._space === 'demo'; @@ -105,7 +119,8 @@ class NotesCommentPanel extends HTMLElement { private render() { const threads = this.getThreads(); - if (threads.length === 0 && !this._activeThreadId) { + const suggestions = this._suggestions || []; + if (threads.length === 0 && suggestions.length === 0 && !this._activeThreadId) { this.shadow.innerHTML = ''; return; } @@ -241,6 +256,48 @@ class NotesCommentPanel extends HTMLElement { .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); } + /* ── Suggestion Cards ── */ + .suggestion-section-title { + font-weight: 600; font-size: 12px; color: #b45309; + padding: 6px 0 4px; margin-bottom: 4px; + border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, var(--rs-border-subtle, #f0f0f0)); + } + .suggestion-card { + margin-bottom: 8px; padding: 10px 12px; + border-radius: 8px; border: 1px solid color-mix(in srgb, #f59e0b 25%, var(--rs-border-subtle, #e8e8e8)); + background: color-mix(in srgb, #f59e0b 4%, var(--rs-bg-surface, #fff)); + border-left: 3px solid #f59e0b; + } + .suggestion-card .sg-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } + .suggestion-card .sg-avatar { + width: 22px; height: 22px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 9px; font-weight: 600; color: #fff; flex-shrink: 0; + } + .suggestion-card .sg-author { font-weight: 600; font-size: 12px; color: var(--rs-text-primary, #111); } + .suggestion-card .sg-time { font-size: 10px; color: var(--rs-text-muted, #aaa); margin-left: auto; } + .suggestion-card .sg-type { + font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; + } + .sg-type-insert { background: rgba(22, 163, 74, 0.1); color: #137333; } + .sg-type-delete { background: rgba(220, 38, 38, 0.1); color: #c5221f; } + .suggestion-card .sg-text { + font-size: 13px; line-height: 1.5; padding: 4px 6px; + border-radius: 4px; margin-bottom: 8px; + word-break: break-word; overflow-wrap: anywhere; + } + .sg-text-insert { background: rgba(22, 163, 74, 0.08); color: var(--rs-text-primary, #111); } + .sg-text-delete { background: rgba(220, 38, 38, 0.06); color: var(--rs-text-muted, #666); text-decoration: line-through; } + .suggestion-card .sg-actions { display: flex; gap: 6px; justify-content: flex-end; } + .sg-accept, .sg-reject { + padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 500; + cursor: pointer; border: 1px solid; transition: all 0.15s; + } + .sg-accept { color: #137333; border-color: #137333; background: rgba(22, 163, 74, 0.06); } + .sg-accept:hover { background: rgba(22, 163, 74, 0.15); } + .sg-reject { color: #c5221f; border-color: #c5221f; background: rgba(220, 38, 38, 0.04); } + .sg-reject:hover { background: rgba(220, 38, 38, 0.12); } + /* New comment input — shown when thread has no messages */ .new-comment-form { margin-top: 4px; } .new-comment-input { @@ -264,9 +321,26 @@ class NotesCommentPanel extends HTMLElement {
- Comments (${threads.filter(t => !t.resolved).length}) + ${suggestions.length > 0 ? `Suggestions (${suggestions.length})` : ''} ${threads.length > 0 ? `Comments (${threads.filter(t => !t.resolved).length})` : ''}
+ ${suggestions.length > 0 ? ` + ${suggestions.map(s => ` +
+
+
${initials(s.authorName)}
+ ${esc(s.authorName)} + ${s.type === 'insert' ? 'Added' : 'Deleted'} + ${timeAgo(s.createdAt)} +
+
${esc(s.text)}
+
+ + +
+
+ `).join('')} + ` : ''} ${threads.map(thread => { const reactions = thread.reactions || {}; const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0); @@ -355,6 +429,43 @@ class NotesCommentPanel extends HTMLElement { } private wireEvents() { + // Suggestion accept/reject + this.shadow.querySelectorAll('[data-accept-suggestion]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = (btn as HTMLElement).dataset.acceptSuggestion; + if (id) this.dispatchEvent(new CustomEvent('suggestion-accept', { detail: { suggestionId: id }, bubbles: true, composed: true })); + }); + }); + this.shadow.querySelectorAll('[data-reject-suggestion]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = (btn as HTMLElement).dataset.rejectSuggestion; + if (id) this.dispatchEvent(new CustomEvent('suggestion-reject', { detail: { suggestionId: id }, bubbles: true, composed: true })); + }); + }); + + // Click suggestion card to scroll editor to it + this.shadow.querySelectorAll('.suggestion-card[data-suggestion-id]').forEach(el => { + el.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('button')) return; + const id = (el as HTMLElement).dataset.suggestionId; + if (!id || !this._editor) return; + this._editor.state.doc.descendants((node, pos) => { + if (!node.isText) return; + const mark = node.marks.find(m => + (m.type.name === 'suggestionInsert' || m.type.name === 'suggestionDelete') && + m.attrs.suggestionId === id + ); + if (mark) { + this._editor!.commands.setTextSelection(pos); + this._editor!.commands.scrollIntoView(); + return false; + } + }); + }); + }); + // Collapse/expand panel const collapseBtn = this.shadow.querySelector('[data-action="toggle-collapse"]'); if (collapseBtn) { diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 062dd70..2b77724 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -3935,16 +3935,6 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .srb-label { font-weight: 600; color: #b45309; } .srb-count { margin-left: auto; color: var(--rs-text-muted, #999); } .srb-hint { color: var(--rs-text-muted, #999); font-style: italic; } - .srb-btn { - padding: 3px 10px; border-radius: 4px; border: 1px solid var(--rs-border, #ddd); - font-size: 11px; cursor: pointer; font-weight: 500; background: var(--rs-bg-surface, #fff); - color: var(--rs-text-primary, #111); - } - .srb-btn:hover { background: var(--rs-bg-hover, #f5f5f5); } - .srb-accept-all { color: #137333; border-color: #137333; } - .srb-accept-all:hover { background: rgba(22, 163, 74, 0.08); } - .srb-reject-all { color: #c5221f; border-color: #c5221f; } - .srb-reject-all:hover { background: rgba(220, 38, 38, 0.08); } /* ── Suggestion Popover (accept/reject on click) ── */ .suggestion-popover { diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 3ddc93f..4d9e899 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -1622,7 +1622,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); });