Feat v0.4.5 editor modes + document outline
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 3s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m37s
CI/CD / test-sqlite (pull_request) Successful in 2m45s
CI/CD / build-and-deploy (pull_request) Successful in 1m32s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 3s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m37s
CI/CD / test-sqlite (pull_request) Successful in 2m45s
CI/CD / build-and-deploy (pull_request) Successful in 1m32s
Tri-state viewMode (rendered/edit/split) replaces binary preview toggle. Split view uses CSS grid with synced scroll. Document outline panel parses headings and supports click-to-scroll in both CM6 and preview. Mode preference persisted to localStorage. Notes package bumped to v0.6.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.5 — Editor Modes + Document Outline
|
||||
|
||||
### Added
|
||||
|
||||
- **Tri-state view mode**: Notes open in rendered (read-only) mode by default.
|
||||
Toolbar button cycles through Edit (CM6) and Split (side-by-side) modes.
|
||||
Replaces the old binary preview toggle.
|
||||
- **Split view**: Side-by-side CM6 editor + rendered preview using CSS grid.
|
||||
CM6 instance preserved when toggling between edit and split modes.
|
||||
- **Synced scroll**: In split mode, scrolling the CM6 editor proportionally
|
||||
scrolls the preview pane via linear ratio mapping on `scrollDOM`.
|
||||
- **View mode persistence**: User's preferred mode saved to localStorage
|
||||
(`sw.storage.local('notes')`). New `editor_mode` manifest setting with
|
||||
admin-level default (rendered / edit / split).
|
||||
- **Document outline**: Collapsible TOC panel adjacent to the editor body.
|
||||
`parseHeadings()` extracts heading level, text, and line number (skips
|
||||
fenced code blocks). Click heading to scroll — uses CM6
|
||||
`EditorView.scrollIntoView()` in edit/split mode, DOM `scrollIntoView()`
|
||||
in rendered mode. Updates on body change with 300ms debounce.
|
||||
|
||||
### Changed
|
||||
|
||||
- Notes package version bumped from 0.5.0 to 0.6.0.
|
||||
- Editor body wrapped in `.notes-editor__content` flex container to
|
||||
accommodate the outline panel.
|
||||
- Wikilink click handling active in both rendered and split modes.
|
||||
- Mobile: split view collapses to single column, outline panel hidden.
|
||||
|
||||
---
|
||||
|
||||
## v0.4.4 — Rich Editor + Import/Export
|
||||
|
||||
### Added
|
||||
|
||||
14
ROADMAP.md
14
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Switchboard Core — Roadmap
|
||||
|
||||
## Current: v0.4.4 — Rich Editor + Import/Export
|
||||
## Current: v0.4.5 — Editor Modes + Document Outline
|
||||
|
||||
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||
features removed from the kernel. What remains is the minimum viable
|
||||
@@ -291,15 +291,15 @@ Zero platform special-casing. Proves the full extension stack E2E.
|
||||
| Import .md | ✅ | Import button in topbar. File picker for `.md`/`.markdown`/`.txt`. Parses YAML frontmatter for title and tags. Creates note via API with tags. |
|
||||
| Notes package v0.5.0 | ✅ | Manifest version bumped from 0.4.0 → 0.5.0. |
|
||||
|
||||
### v0.4.5 — Editor Modes + Document Outline (planned)
|
||||
### v0.4.5 — Editor Modes + Document Outline (complete)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Default-rendered mode | | Preview is the initial state on note open. "Edit" button enters CM6. Save exits back to rendered view. |
|
||||
| Split view | | Side-by-side layout: CM6 left, rendered preview right. New `viewMode` state: `rendered` / `edit` / `split`. CSS grid two-column in `.notes-editor__body`. |
|
||||
| Synced scroll | | Split mode scroll sync. CM6 `scrollDOM` scroll listener maps position ratio to preview `scrollTop`. Linear mapping. |
|
||||
| View mode setting | | Add `editor_mode` to manifest `settings` (rendered / split / edit). Persist preference. Toolbar cycles modes. |
|
||||
| Document outline | | Parse headings from body. Render as collapsible TOC panel adjacent to editor. Click heading → scroll CM6 or preview to target. Updates on body change via debounced parse. |
|
||||
| Default-rendered mode | ✅ | Preview is the initial state on note open. "Edit" button enters CM6. Tri-state `viewMode`: `rendered` / `edit` / `split`. |
|
||||
| Split view | ✅ | Side-by-side layout: CM6 left, rendered preview right. CSS grid two-column in `.notes-editor__body--split`. |
|
||||
| Synced scroll | ✅ | Split mode scroll sync. CM6 `scrollDOM` scroll listener maps position ratio to preview `scrollTop`. Linear mapping. |
|
||||
| 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user