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

@@ -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>
`;
}