Feat v0.4.6 sidebar restructure
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m33s
CI/CD / test-sqlite (pull_request) Successful in 2m58s
CI/CD / build-and-deploy (pull_request) Successful in 1m16s

Restructure the notes sidebar with a two-tab header (Notes / Outline)
and relocate the document outline from the editor-side panel into the
sidebar's Outline tab with active heading tracking.

- Add SidebarTabs component with Notes/Outline tabs; Outline disabled
  when no note is selected
- Add SidebarOutline component with nested heading hierarchy
  (H1→H2→H3), depth indentation, and click-to-scroll
- Add RAF-throttled scroll listener for active heading highlighting
  based on scroll position (preview getBoundingClientRect / CM6 viewport)
- Lift headings, scrollToHeading, and activeHeadingIdx state to NotesApp
  via EditorPane callback props
- Remove old DocumentOutline component and .doc-outline CSS
- Auto-reset to Notes tab when active note is deleted
- Bump notes package to v0.7.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:16:31 +00:00
parent 6fbac0a33f
commit d5984f4762
6 changed files with 240 additions and 111 deletions

View File

@@ -2,6 +2,36 @@
All notable changes to Switchboard Core are documented here.
## v0.4.6 — Sidebar Restructure
### Added
- **Sidebar tabs**: Two-tab header ("Notes" / "Outline") replaces the static
sidebar header. Notes tab contains folder tree, tag filter, search, and note
list. Outline tab shows the document heading tree for the active note.
Outline tab disabled (grayed) when no note is selected.
- **Sidebar outline**: Document outline relocated from editor-side panel into
the sidebar's Outline tab. Nested heading hierarchy (H1→H2→H3) with depth
indentation via `padding-left`. Click heading scrolls to it in both preview
and CM6 editor modes.
- **Active heading tracking**: Scroll listener (RAF-throttled) highlights the
current heading in the sidebar outline based on scroll position. Uses
`getBoundingClientRect()` in preview/split mode and CM6 viewport line in
edit mode.
### Changed
- Notes package version bumped from 0.6.0 to 0.7.0.
- `EditorPane` exposes headings, scroll function, and active heading index to
parent via callback props (`onHeadingsChange`, `scrollToHeadingRef`,
`onActiveHeadingChange`).
- Sidebar auto-resets to Notes tab when active note is deleted.
### Removed
- Old `DocumentOutline` component and `.doc-outline` CSS styles (replaced by
sidebar outline).
## v0.4.5 — Editor Modes + Document Outline
### Added

View File

@@ -301,12 +301,12 @@ Zero platform special-casing. Proves the full extension stack E2E.
| View mode setting | ✅ | `editor_mode` in manifest settings + localStorage persistence. Toolbar cycles rendered → edit → split. |
| Document outline | ✅ | `parseHeadings()` extracts headings (skips code blocks). Collapsible `DocumentOutline` panel with click-to-scroll (CM6 line dispatch or preview `scrollIntoView`). Debounced 300ms updates. |
### v0.4.6 — Sidebar Restructure (planned)
### v0.4.6 — Sidebar Restructure (complete)
| Step | Status | Description |
|------|--------|-------------|
| Sidebar tabs | | Two-tab header at top of sidebar: "Notes" (folders → tags → search → list) and "Outline" (heading tree for active note). Outline tab enabled only when a note is selected. |
| Heading tree | | Nested heading hierarchy (H1 → H2 → H3) with depth indentation. Click → scroll to heading in editor/preview. Current heading highlighted based on scroll position. |
| Sidebar tabs | | Two-tab header at top of sidebar: "Notes" (folders → tags → search → list) and "Outline" (heading tree for active note). Outline tab enabled only when a note is selected. |
| Heading tree | | Nested heading hierarchy (H1 → H2 → H3) with depth indentation. Click → scroll to heading in editor/preview. Current heading highlighted based on scroll position. |
### v0.4.7 — Note Graph (planned)

View File

@@ -1 +1 @@
0.4.5
0.4.6

View File

@@ -632,57 +632,79 @@
font-size: 11px;
}
/* ── Document Outline ─────────────────────── */
.doc-outline {
width: 200px;
min-width: 160px;
border-left: 1px solid var(--border);
background: var(--bg-raised);
overflow-y: auto;
/* ── Sidebar Tabs ────────────────────────── */
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.doc-outline__header {
font-size: 11px;
font-weight: 600;
.sidebar-tabs__tab {
flex: 1;
padding: 10px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--border);
transition: var(--transition);
text-align: center;
font-family: var(--font);
}
.doc-outline__toggle {
font-size: 9px;
color: var(--text-2);
}
.doc-outline__count {
.sidebar-tabs__tab:hover {
color: var(--text);
background: var(--bg-hover);
color: var(--text-2);
font-size: 10px;
padding: 0 6px;
border-radius: 8px;
line-height: 16px;
}
.doc-outline__item {
padding: 4px 10px;
font-size: 12px;
.sidebar-tabs__tab--active {
color: var(--text);
border-bottom-color: var(--accent);
}
.sidebar-tabs__tab--disabled {
color: var(--text-3);
cursor: default;
opacity: 0.5;
}
.sidebar-tabs__tab--disabled:hover {
background: none;
color: var(--text-3);
}
/* ── Sidebar Outline ─────────────────────── */
.sidebar-outline {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.sidebar-outline__empty {
padding: 40px 16px;
text-align: center;
color: var(--text-3);
font-size: 13px;
}
.sidebar-outline__item {
padding: 5px 12px;
font-size: 13px;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: var(--transition);
border-radius: var(--radius);
margin: 1px 6px;
}
.doc-outline__item:hover {
.sidebar-outline__item:hover {
background: var(--bg-hover);
color: var(--text);
}
.doc-outline__item--h1 { font-weight: 600; color: var(--text); }
.doc-outline__item--h2 { font-weight: 500; }
.sidebar-outline__item--active {
background: var(--bg-active);
color: var(--text);
font-weight: 500;
}
.sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
.sidebar-outline__item--h2 { font-weight: 500; }
/* ── Responsive ──────────────────────────── */
@media (max-width: 700px) {
@@ -691,5 +713,4 @@
.folder-tree { max-height: 30vh; }
.notes-editor__body--split { grid-template-columns: 1fr; }
.notes-editor__body--split .notes-preview { display: none; }
.doc-outline { display: none; }
}

View File

@@ -1,5 +1,5 @@
/**
* Notes — Surface Entry Point (v0.6.0)
* Notes — Surface Entry Point (v0.7.0)
*
* Markdown notes surface using the SDK:
* sw.api.ext('notes') — scoped API client
@@ -628,44 +628,38 @@
return result;
}
function DocumentOutline({ headings, viewMode, cmEditorRef, previewRef }) {
var [collapsed, setCollapsed] = useState(false);
// ═══════════════════════════════════════════
// SidebarTabs — Notes / Outline tab header
// ═══════════════════════════════════════════
if (!headings || headings.length === 0) return null;
function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
return html`
<div class="sidebar-tabs">
<button class="sidebar-tabs__tab ${activeTab === 'notes' ? 'sidebar-tabs__tab--active' : ''}"
onClick=${function() { onTabChange('notes'); }}>Notes</button>
<button class="sidebar-tabs__tab ${activeTab === 'outline' ? 'sidebar-tabs__tab--active' : ''} ${!noteSelected ? 'sidebar-tabs__tab--disabled' : ''}"
onClick=${function() { if (noteSelected) onTabChange('outline'); }}>Outline</button>
</div>
`;
}
function scrollToHeading(heading) {
// scroll preview in rendered / split
if (viewMode !== 'edit' && previewRef.current) {
var targets = previewRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var i = 0; i < targets.length; i++) {
if (targets[i].textContent.trim() === heading.text) {
targets[i].scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
}
}
// scroll CM6 in edit / split
if (viewMode !== 'rendered' && cmEditorRef.current) {
var view = cmEditorRef.current.getView();
if (!view) return;
var lineNum = Math.min(heading.line + 1, view.state.doc.lines);
var lineObj = view.state.doc.line(lineNum);
view.dispatch({ effects: CM.EditorView.scrollIntoView(lineObj.from, { y: 'start' }) });
}
// ═══════════════════════════════════════════
// SidebarOutline — heading tree in sidebar
// ═══════════════════════════════════════════
function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) {
if (!headings || headings.length === 0) {
return html`<div class="sidebar-outline__empty">No headings found</div>`;
}
return html`
<div class="doc-outline">
<div class="doc-outline__header" onClick=${function() { setCollapsed(!collapsed); }}>
<span class="doc-outline__toggle">${collapsed ? '\u25B6' : '\u25BC'}</span>
<span>Outline</span>
<span class="doc-outline__count">${headings.length}</span>
</div>
${!collapsed && headings.map(function(h, i) {
<div class="sidebar-outline">
${headings.map(function(h, i) {
return html`
<div key=${i} class="doc-outline__item doc-outline__item--h${h.level}"
style="padding-left: ${8 + (h.level - 1) * 12}px"
onClick=${function() { scrollToHeading(h); }}>
<div key=${i} class="sidebar-outline__item sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? 'sidebar-outline__item--active' : ''}"
style="padding-left: ${10 + (h.level - 1) * 14}px"
onClick=${function() { onScrollToHeading(h); }}>
${h.text}
</div>
`;
@@ -676,7 +670,7 @@
var _modeStore = sw.storage.local('notes');
function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged, onNavigate, onNavigateToTitle }) {
function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged, onNavigate, onNavigateToTitle, onHeadingsChange, scrollToHeadingRef, onActiveHeadingChange }) {
var [title, setTitle] = useState(note ? note.title : '');
var [body, setBody] = useState(note ? note.body : '');
var [noteTags, setNoteTags] = useState([]);
@@ -914,10 +908,82 @@
var [headings, setHeadings] = useState([]);
useEffect(function() {
debounce('outline', function() {
setHeadings(parseHeadings(body));
var h = parseHeadings(body);
setHeadings(h);
if (onHeadingsChange) onHeadingsChange(h);
}, 300);
}, [body]);
// ── Expose scrollToHeading to parent via ref ──
useEffect(function() {
if (!scrollToHeadingRef) return;
scrollToHeadingRef.current = function(heading) {
// scroll preview in rendered / split
if (viewMode !== 'edit' && previewRef.current) {
var targets = previewRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var i = 0; i < targets.length; i++) {
if (targets[i].textContent.trim() === heading.text) {
targets[i].scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
}
}
// scroll CM6 in edit / split
if (viewMode !== 'rendered' && cmEditorRef.current) {
var view = cmEditorRef.current.getView();
if (!view) return;
var lineNum = Math.min(heading.line + 1, view.state.doc.lines);
var lineObj = view.state.doc.line(lineNum);
view.dispatch({ effects: CM.EditorView.scrollIntoView(lineObj.from, { y: 'start' }) });
}
};
}, [viewMode]);
// ── Active heading tracking on scroll ──
useEffect(function() {
if (!onActiveHeadingChange || !note) return;
var scrollEl = null;
var usePreview = viewMode !== 'edit';
if (usePreview && previewRef.current) {
scrollEl = previewRef.current;
} else if (viewMode !== 'rendered' && cmEditorRef.current) {
var view = cmEditorRef.current.getView();
if (view) scrollEl = view.scrollDOM;
}
if (!scrollEl) return;
var ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(function() {
ticking = false;
if (usePreview && previewRef.current) {
var els = previewRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');
var active = -1;
for (var i = 0; i < els.length; i++) {
if (els[i].getBoundingClientRect().top <= previewRef.current.getBoundingClientRect().top + 30) {
active = i;
}
}
onActiveHeadingChange(active);
} else if (cmEditorRef.current) {
var v = cmEditorRef.current.getView();
if (!v) return;
var topLine = v.state.doc.lineAt(v.viewport.from).number - 1;
var active = -1;
for (var i = 0; i < headings.length; i++) {
if (headings[i].line <= topLine) active = i;
}
onActiveHeadingChange(active);
}
});
}
scrollEl.addEventListener('scroll', onScroll);
onScroll(); // initial check
return function() { scrollEl.removeEventListener('scroll', onScroll); };
}, [viewMode, note ? note.id : null, headings]);
// build indented folder list for the select dropdown
var folderOptions = useMemo(function() {
var list = folders || [];
@@ -1025,8 +1091,6 @@
<div class="notes-editor__body ${viewMode === 'split' ? 'notes-editor__body--split' : ''}">
${renderEditorBody()}
</div>
<${DocumentOutline} headings=${headings} viewMode=${viewMode}
cmEditorRef=${cmEditorRef} previewRef=${previewRef} />
</div>
<${BacklinksPanel} noteId=${note.id} onNavigate=${onNavigate} />
</div>
@@ -1050,6 +1114,10 @@
var [showUnfiled, setShowUnfiled] = useState(false);
var [allTags, setAllTags] = useState([]);
var [activeTag, setActiveTag] = useState(null);
var [sidebarTab, setSidebarTab] = useState('notes');
var [sidebarHeadings, setSidebarHeadings] = useState([]);
var [activeHeadingIdx, setActiveHeadingIdx] = useState(-1);
var scrollToHeadingRef = useRef(null);
// ── Load note list ────────────────────────
var loadNotes = useCallback(async function() {
@@ -1161,6 +1229,9 @@
function handleDelete() {
setActiveId(null);
setActiveNote(null);
setSidebarTab('notes');
setSidebarHeadings([]);
setActiveHeadingIdx(-1);
loadNotes();
loadStats();
loadTags();
@@ -1317,38 +1388,42 @@
<//>
<div class="notes-app">
<div class="notes-sidebar">
<div class="notes-sidebar__header">
<span class="notes-sidebar__title">Notes</span>
${stats && html`<span class="notes-sidebar__count">${stats.total}</span>`}
</div>
<${FolderTree} folders=${folders}
activeFolderId=${activeFolderId} showUnfiled=${showUnfiled}
onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll}
onSelectUnfiled=${handleSelectUnfiled}
onCreateFolder=${handleCreateFolder}
onRenameFolder=${handleRenameFolder}
onDeleteFolder=${handleDeleteFolder}
onDropNote=${handleDropNote} />
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
<div class="notes-search">
<input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} />
</div>
<div class="notes-list">
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
${!loading && notes.length === 0 && html`
<div class="notes-list__empty">
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
</div>
`}
${!loading && notes.map(function(n) {
return html`
<${NoteCard} key=${n.id} note=${n}
active=${n.id === activeId}
onClick=${() => handleSelect(n.id)} />
`;
})}
</div>
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
${sidebarTab === 'notes' && html`
<${FolderTree} folders=${folders}
activeFolderId=${activeFolderId} showUnfiled=${showUnfiled}
onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll}
onSelectUnfiled=${handleSelectUnfiled}
onCreateFolder=${handleCreateFolder}
onRenameFolder=${handleRenameFolder}
onDeleteFolder=${handleDeleteFolder}
onDropNote=${handleDropNote} />
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
<div class="notes-search">
<input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} />
</div>
<div class="notes-list">
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
${!loading && notes.length === 0 && html`
<div class="notes-list__empty">
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
</div>
`}
${!loading && notes.map(function(n) {
return html`
<${NoteCard} key=${n.id} note=${n}
active=${n.id === activeId}
onClick=${() => handleSelect(n.id)} />
`;
})}
</div>
`}
${sidebarTab === 'outline' && html`
<${SidebarOutline} headings=${sidebarHeadings}
activeHeadingIdx=${activeHeadingIdx}
onScrollToHeading=${function(h) { if (scrollToHeadingRef.current) scrollToHeadingRef.current(h); }} />
`}
</div>
<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
onSave=${handleRefresh}
@@ -1356,7 +1431,10 @@
onRefresh=${handleRefresh}
onTagsChanged=${handleTagsChanged}
onNavigate=${handleSelect}
onNavigateToTitle=${handleNavigateToTitle} />
onNavigateToTitle=${handleNavigateToTitle}
onHeadingsChange=${setSidebarHeadings}
scrollToHeadingRef=${scrollToHeadingRef}
onActiveHeadingChange=${setActiveHeadingIdx} />
</div>
`;
}

View File

@@ -6,9 +6,9 @@
"route": "/s/notes",
"auth": "authenticated",
"layout": "single",
"version": "0.6.0",
"version": "0.7.0",
"icon": "📝",
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, and document outline.",
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, sidebar tabs, and document outline.",
"author": "switchboard",
"permissions": ["db.write"],