Feat v0.4.5 editor modes outline (#27)
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 2m45s
CI/CD / test-sqlite (push) Successful in 2m50s
CI/CD / build-and-deploy (push) Successful in 1m21s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #27.
This commit is contained in:
2026-03-29 20:50:24 +00:00
committed by xcaliber
parent cee65c4136
commit 6fbac0a33f
7 changed files with 248 additions and 24 deletions

View File

@@ -21,6 +21,7 @@ Core CRUD with markdown textarea and inline preview.
- v0.4.2: Tags + enhanced search
- v0.4.3: Backlinks + `[[wikilinks]]`
- v0.4.4: Rich editor (CodeMirror 6) + import/export
- v0.4.5: Editor modes (rendered/edit/split) + document outline
## Data

View File

@@ -257,6 +257,14 @@
border-color: var(--accent);
}
/* ── Editor Content (body + outline wrapper) */
.notes-editor__content {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
/* ── Editor Body ─────────────────────────── */
.notes-editor__body {
flex: 1;
@@ -264,6 +272,13 @@
min-height: 0;
overflow: hidden;
}
.notes-editor__body--split {
display: grid;
grid-template-columns: 1fr 1fr;
}
.notes-editor__body--split .notes-preview {
border-left: 1px solid var(--border);
}
.notes-editor__textarea {
flex: 1;
resize: none;
@@ -617,9 +632,64 @@
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;
flex-shrink: 0;
}
.doc-outline__header {
font-size: 11px;
font-weight: 600;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--border);
}
.doc-outline__toggle {
font-size: 9px;
color: var(--text-2);
}
.doc-outline__count {
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;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: var(--transition);
}
.doc-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; }
/* ── Responsive ──────────────────────────── */
@media (max-width: 700px) {
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
.notes-app { flex-direction: column; }
.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.5.0)
* Notes — Surface Entry Point (v0.6.0)
*
* Markdown notes surface using the SDK:
* sw.api.ext('notes') — scoped API client
@@ -607,11 +607,80 @@
}
// ═══════════════════════════════════════════
// Heading parser + Document Outline
// ═══════════════════════════════════════════
function parseHeadings(body) {
if (!body) return [];
var lines = body.split('\n');
var result = [];
var inCode = false;
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.trim().indexOf('```') === 0) { inCode = !inCode; continue; }
if (inCode) continue;
var m = line.match(/^(#{1,6})\s+(.+)/);
if (m) {
result.push({ level: m[1].length, text: m[2].replace(/[*_`\[\]]/g, '').trim(), line: i });
}
}
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' }) });
}
}
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) {
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); }}>
${h.text}
</div>
`;
})}
</div>
`;
}
var _modeStore = sw.storage.local('notes');
function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged, onNavigate, onNavigateToTitle }) {
var [title, setTitle] = useState(note ? note.title : '');
var [body, setBody] = useState(note ? note.body : '');
var [noteTags, setNoteTags] = useState([]);
var [preview, setPreview] = useState(false);
var [viewMode, setViewMode] = useState(_modeStore.get('editor_mode') || 'rendered');
var [dirty, setDirty] = useState(false);
var [saving, setSaving] = useState(false);
var [saveCount, setSaveCount] = useState(0);
@@ -634,7 +703,7 @@
bodyRef.current = note.body || '';
setNoteTags(note.tags || []);
setDirty(false);
setPreview(false);
setViewMode(_modeStore.get('editor_mode') || 'rendered');
// update CM6 content if editor exists
if (cmEditorRef.current) {
cmEditorRef.current.setValue(note.body || '');
@@ -643,8 +712,9 @@
}, [note ? note.id : null]);
// ── CM6 lifecycle ─────────────────────────
var needsCM = viewMode !== 'rendered';
useEffect(function() {
if (!hasCM || preview || !note) return;
if (!hasCM || !needsCM || !note) return;
var el = cmContainerRef.current;
if (!el) return;
@@ -698,7 +768,7 @@
cmEditorRef.current = null;
}
};
}, [note ? note.id : null, preview]);
}, [note ? note.id : null, needsCM]);
function handleTitleChange(e) {
setTitle(e.target.value);
@@ -807,7 +877,7 @@
var previewRef = useRef(null);
useEffect(function() {
var el = previewRef.current;
if (!el || !preview) return;
if (!el || viewMode === 'edit') return;
function handleClick(e) {
var target = e.target;
if (target.classList && target.classList.contains('wikilink')) {
@@ -818,7 +888,35 @@
}
el.addEventListener('click', handleClick);
return function() { el.removeEventListener('click', handleClick); };
}, [preview, onNavigateToTitle]);
}, [viewMode, onNavigateToTitle]);
// ── Synced scroll in split mode ──
useEffect(function() {
if (viewMode !== 'split' || !cmEditorRef.current) return;
var view = cmEditorRef.current.getView();
if (!view) return;
var scrollDOM = view.scrollDOM;
var previewEl = previewRef.current;
if (!scrollDOM || !previewEl) return;
var syncing = false;
function onEditorScroll() {
if (syncing) return;
syncing = true;
var ratio = scrollDOM.scrollTop / (scrollDOM.scrollHeight - scrollDOM.clientHeight || 1);
previewEl.scrollTop = ratio * (previewEl.scrollHeight - previewEl.clientHeight);
syncing = false;
}
scrollDOM.addEventListener('scroll', onEditorScroll);
return function() { scrollDOM.removeEventListener('scroll', onEditorScroll); };
}, [viewMode, note ? note.id : null]);
// ── Document outline headings ──
var [headings, setHeadings] = useState([]);
useEffect(function() {
debounce('outline', function() {
setHeadings(parseHeadings(body));
}, 300);
}, [body]);
// build indented folder list for the select dropdown
var folderOptions = useMemo(function() {
@@ -860,13 +958,25 @@
// ── Render editor body ─────────────────────
function renderEditorBody() {
if (preview) {
if (viewMode === 'rendered') {
return html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
}
if (viewMode === 'split') {
var editor = hasCM
? html`<div class="notes-editor__cm" ref=${cmContainerRef} />`
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
value=${body} onInput=${handleBodyChange}
onKeyDown=${handleKeyDown}
placeholder="Start writing in Markdown…" />`;
return [
editor,
html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
];
}
// edit mode
if (hasCM) {
return html`<div class="notes-editor__cm" ref=${cmContainerRef} />`;
}
// fallback textarea
return html`<textarea class="notes-editor__textarea" ref=${textareaRef}
value=${body} onInput=${handleBodyChange}
onKeyDown=${handleKeyDown}
@@ -894,9 +1004,12 @@
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
</span>
<button class="notes-toggle ${preview ? 'notes-toggle--active' : ''}"
onClick=${() => setPreview(!preview)}>
${preview ? 'Edit' : 'Preview'}
<button class="notes-toggle" onClick=${function() {
var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered';
_modeStore.set('editor_mode', next);
setViewMode(next);
}}>
${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'}
</button>
<button class="notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
<button class="notes-btn" onClick=${handlePin}
@@ -908,8 +1021,12 @@
</div>
</div>
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
<div class="notes-editor__body">
${renderEditorBody()}
<div class="notes-editor__content">
<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>

View File

@@ -6,9 +6,9 @@
"route": "/s/notes",
"auth": "authenticated",
"layout": "single",
"version": "0.5.0",
"version": "0.6.0",
"icon": "📝",
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, and import/export.",
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, and document outline.",
"author": "switchboard",
"permissions": ["db.write"],
@@ -92,6 +92,12 @@
"label": "Default View",
"description": "Default note list view: recent or pinned",
"default": "recent"
},
"editor_mode": {
"type": "string",
"label": "Default Editor Mode",
"description": "How notes open: rendered (read-only preview), edit (CodeMirror editor), or split (side-by-side)",
"default": "rendered"
}
}
}