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
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:
30
CHANGELOG.md
30
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user