feat(rnotes): collapsible document sidebar with < / > toggle

Add collapse button (<) in sidebar header top-right and reopen tab (>)
on left edge when collapsed. CSS grid transition for smooth animation.
Hidden on mobile where slide navigation handles sidebar state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-28 16:28:01 -07:00
parent 9084de7adb
commit 0ec5edd1ee
1 changed files with 143 additions and 23 deletions

View File

@ -37,7 +37,7 @@ import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from '@tiptap/y-tiptap';
import { RSpaceYjsProvider } from '../yjs-ws-provider';
import { CommentMark } from './comment-mark';
import { SuggestionInsertMark, SuggestionDeleteMark } from './suggestion-marks';
import { createSuggestionPlugin, acceptSuggestion, rejectSuggestion, acceptAllSuggestions, rejectAllSuggestions } from './suggestion-plugin';
import { createSuggestionPlugin, acceptSuggestion, rejectSuggestion } from './suggestion-plugin';
import './comment-panel';
const lowlight = createLowlight(common);
@ -295,7 +295,16 @@ class FolkNotesApp extends HTMLElement {
rightCol.appendChild(this.contentZone);
rightCol.appendChild(this.metaZone);
// Sidebar reopen tab (lives on layout, outside navZone so it's visible when collapsed)
const reopenBtn = document.createElement('button');
reopenBtn.id = 'sidebar-reopen';
reopenBtn.className = 'sidebar-reopen';
reopenBtn.title = 'Show sidebar';
reopenBtn.textContent = '\u203A';
reopenBtn.addEventListener('click', () => this.toggleSidebar(true));
layout.appendChild(this.navZone);
layout.appendChild(reopenBtn);
layout.appendChild(rightCol);
this.shadow.appendChild(style);
@ -2372,25 +2381,12 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
bar.innerHTML = `
<span class="srb-label">${this.suggestingMode ? 'Suggesting' : 'Editing'}</span>
${ids.size > 0 ? `
<span class="srb-count">${ids.size} suggestion${ids.size !== 1 ? 's' : ''}</span>
<button class="srb-btn srb-accept-all" data-action="accept-all-suggestions" title="Accept all suggestions">Accept All</button>
<button class="srb-btn srb-reject-all" data-action="reject-all-suggestions" title="Reject all suggestions">Reject All</button>
<span class="srb-count">${ids.size} suggestion${ids.size !== 1 ? 's' : ''} review in sidebar</span>
` : '<span class="srb-hint">Start typing to suggest changes</span>'}
`;
// Wire buttons
bar.querySelector('[data-action="accept-all-suggestions"]')?.addEventListener('click', () => {
if (this.editor) {
acceptAllSuggestions(this.editor);
this.updateSuggestionReviewBar();
}
});
bar.querySelector('[data-action="reject-all-suggestions"]')?.addEventListener('click', () => {
if (this.editor) {
rejectAllSuggestions(this.editor);
this.updateSuggestionReviewBar();
}
});
// Open sidebar to show suggestions when there are any
if (ids.size > 0) this.showCommentPanel();
}
/** Show an accept/reject popover near a clicked suggestion mark. */
@ -2420,6 +2416,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
if (this.editor) {
acceptSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel();
}
pop.remove();
});
@ -2427,6 +2424,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
if (this.editor) {
rejectSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel();
}
pop.remove();
});
@ -2443,6 +2441,47 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
setTimeout(() => this.shadow.addEventListener('click', close), 0);
}
/** Collect all pending suggestions from the editor doc. */
private collectSuggestions(): { id: string; type: 'insert' | 'delete'; text: string; authorId: string; authorName: string; createdAt: number }[] {
if (!this.editor) return [];
const map = new Map<string, { id: string; type: 'insert' | 'delete'; text: string; authorId: string; authorName: string; createdAt: number }>();
this.editor.state.doc.descendants((node: any) => {
if (!node.isText) return;
for (const mark of node.marks) {
if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
const id = mark.attrs.suggestionId;
const existing = map.get(id);
if (existing) {
existing.text += node.text || '';
} else {
map.set(id, {
id,
type: mark.type.name === 'suggestionInsert' ? 'insert' : 'delete',
text: node.text || '',
authorId: mark.attrs.authorId || '',
authorName: mark.attrs.authorName || 'Unknown',
createdAt: mark.attrs.createdAt || Date.now(),
});
}
}
}
});
return Array.from(map.values());
}
/** Push current suggestions to the comment panel and ensure sidebar is visible. */
private syncSuggestionsToPanel() {
const panel = this.shadow.querySelector('notes-comment-panel') as any;
if (!panel) return;
const suggestions = this.collectSuggestions();
panel.suggestions = suggestions;
// Show sidebar if there are suggestions or comments
const sidebar = this.shadow.getElementById('comment-sidebar');
if (sidebar && suggestions.length > 0) {
sidebar.classList.add('has-comments');
}
}
/** Show comment panel for a specific thread. */
private showCommentPanel(threadId?: string) {
const sidebar = this.shadow.getElementById('comment-sidebar');
@ -2457,6 +2496,21 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const { noteId, threads } = e.detail;
if (noteId) this._demoThreads.set(noteId, threads);
});
// Listen for suggestion accept/reject from comment panel
panel.addEventListener('suggestion-accept', (e: CustomEvent) => {
if (this.editor && e.detail?.suggestionId) {
acceptSuggestion(this.editor, e.detail.suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel();
}
});
panel.addEventListener('suggestion-reject', (e: CustomEvent) => {
if (this.editor && e.detail?.suggestionId) {
rejectSuggestion(this.editor, e.detail.suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel();
}
});
}
panel.noteId = this.editorNoteId;
panel.doc = this.doc;
@ -2470,8 +2524,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
} else {
panel.demoThreads = null;
}
// Pass suggestions
panel.suggestions = this.collectSuggestions();
// Show sidebar when there are comments
// Show sidebar when there are comments or suggestions
sidebar.classList.add('has-comments');
}
@ -2496,9 +2552,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
});
// On any change, update the suggestion review bar
// On any change, update the suggestion review bar + sidebar panel
this.editor.on('update', () => {
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel();
});
// Direct click on comment highlight or suggestion marks in the DOM
@ -2802,6 +2859,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
<div class="notes-sidebar">
<div class="sidebar-header">
<input class="sidebar-search" type="text" placeholder="Search notes..." id="search-input" value="${this.esc(this.searchQuery)}">
<button class="sidebar-collapse" id="sidebar-collapse" title="Hide sidebar">\u2039</button>
</div>
<button class="sidebar-btn-new-nb" id="create-notebook">+ New Notebook</button>
<div class="sidebar-tree">
@ -2818,6 +2876,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
</div>
`;
// Apply collapsed state
const layout = this.shadow.getElementById('notes-layout');
if (layout) layout.classList.toggle('sidebar-collapsed', !this.sidebarOpen);
// Restore search focus
if (hadFocus) {
const newInput = this.navZone.querySelector('#search-input') as HTMLInputElement;
@ -2934,9 +2996,18 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
}
private toggleSidebar(open?: boolean) {
this.sidebarOpen = open !== undefined ? open : !this.sidebarOpen;
const layout = this.shadow.getElementById('notes-layout');
if (layout) layout.classList.toggle('sidebar-collapsed', !this.sidebarOpen);
}
private attachSidebarListeners() {
const isDemo = this.space === "demo";
// Sidebar collapse (reopen button is wired once in connectedCallback)
this.shadow.getElementById('sidebar-collapse')?.addEventListener('click', () => this.toggleSidebar(false));
// Search
const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement;
let searchTimeout: any;
@ -3091,7 +3162,21 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
grid-template-columns: 260px 1fr;
min-height: 400px;
height: calc(100vh - 120px);
position: relative;
transition: grid-template-columns 0.2s ease;
}
#notes-layout.sidebar-collapsed {
grid-template-columns: 0px 1fr;
}
#notes-layout.sidebar-collapsed .notes-sidebar {
opacity: 0;
pointer-events: none;
}
#notes-layout.sidebar-collapsed .sidebar-reopen {
opacity: 1;
pointer-events: auto;
}
#nav-zone { overflow: hidden; }
.notes-sidebar {
display: flex;
flex-direction: column;
@ -3099,10 +3184,45 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
background: var(--rs-bg-surface);
overflow: hidden;
height: 100%;
transition: opacity 0.15s ease;
}
.sidebar-header { padding: 12px 12px 8px; }
/* Collapse button in sidebar header */
.sidebar-collapse {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
width: 24px; height: 24px; border-radius: 4px;
border: 1px solid var(--rs-border-subtle, #333);
background: var(--rs-bg-surface, #1e1e2e);
color: var(--rs-text-muted, #888);
font-size: 16px; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.sidebar-collapse:hover {
color: var(--rs-text-primary);
border-color: var(--rs-primary, #6366f1);
background: var(--rs-bg-hover, #252538);
}
/* Reopen tab on left edge */
.sidebar-reopen {
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 20px; height: 48px; z-index: 10;
border: 1px solid var(--rs-border-subtle, #333);
border-left: none;
border-radius: 0 6px 6px 0;
background: var(--rs-bg-surface, #1e1e2e);
color: var(--rs-text-muted, #888);
font-size: 18px; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity 0.15s ease, color 0.15s, background 0.15s;
}
.sidebar-reopen:hover {
color: var(--rs-text-primary);
background: var(--rs-bg-hover, #252538);
}
.sidebar-header { padding: 12px 12px 8px; position: relative; }
.sidebar-search {
width: 100%; padding: 8px 12px; border-radius: 6px;
width: 100%; padding: 8px 36px 8px 12px; border-radius: 6px;
border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
color: var(--rs-input-text); font-size: 13px; font-family: inherit;
transition: border-color 0.15s;
@ -3550,8 +3670,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
/* Sidebar fills screen width */
.notes-sidebar { width: 100%; position: static; transform: none; box-shadow: none; }
/* Hide old overlay FAB (no longer needed) */
.mobile-sidebar-toggle, .sidebar-overlay { display: none !important; }
/* Hide old overlay FAB + desktop collapse on mobile */
.mobile-sidebar-toggle, .sidebar-overlay, .sidebar-collapse, .sidebar-reopen { display: none !important; }
/* Hide empty state on mobile — user sees doc list */
.editor-empty-state { display: none; }
/* Show back bar */