Feat v0.4.6 sidebar restructure (#28)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m35s
CI/CD / test-sqlite (push) Successful in 2m41s
CI/CD / build-and-deploy (push) Successful in 1m6s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #28.
This commit is contained in:
2026-03-29 21:36:26 +00:00
committed by xcaliber
parent 6fbac0a33f
commit 50d991001d
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);
if (!headings || headings.length === 0) return null;
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' }) });
}
}
// ═══════════════════════════════════════════
// SidebarTabs — Notes / Outline tab header
// ═══════════════════════════════════════════
function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
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 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>
${!collapsed && headings.map(function(h, i) {
`;
}
// ═══════════════════════════════════════════
// 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 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 class="sidebar-outline">
${headings.map(function(h, i) {
return html`
<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,10 +1388,8 @@
<//>
<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>
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
${sidebarTab === 'notes' && html`
<${FolderTree} folders=${folders}
activeFolderId=${activeFolderId} showUnfiled=${showUnfiled}
onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll}
@@ -1349,6 +1418,12 @@
`;
})}
</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"],