From db9d630399d47df5c9f1b5496d07f6586600f8e9 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sun, 29 Mar 2026 20:40:22 +0000 Subject: [PATCH] Feat v0.4.5 editor modes + document outline 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) --- CHANGELOG.md | 30 ++++++++ ROADMAP.md | 14 ++-- VERSION | 2 +- packages/notes/README.md | 1 + packages/notes/css/main.css | 70 +++++++++++++++++ packages/notes/js/main.js | 145 +++++++++++++++++++++++++++++++---- packages/notes/manifest.json | 10 ++- 7 files changed, 248 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c97bd1..e86a6b5 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/ROADMAP.md b/ROADMAP.md index 58ae6f7..e101f4f 100644 --- a/ROADMAP.md +++ b/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) diff --git a/VERSION b/VERSION index 6f2743d..0bfccb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.4 +0.4.5 diff --git a/packages/notes/README.md b/packages/notes/README.md index f67bb1d..55c0fca 100644 --- a/packages/notes/README.md +++ b/packages/notes/README.md @@ -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 diff --git a/packages/notes/css/main.css b/packages/notes/css/main.css index 3db4610..10d3ef8 100644 --- a/packages/notes/css/main.css +++ b/packages/notes/css/main.css @@ -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; } } diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js index e59cf23..8741c83 100644 --- a/packages/notes/js/main.js +++ b/packages/notes/js/main.js @@ -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` +
+
+ ${collapsed ? '\u25B6' : '\u25BC'} + Outline + ${headings.length} +
+ ${!collapsed && headings.map(function(h, i) { + return html` +
+ ${h.text} +
+ `; + })} +
+ `; + } + + 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`
`; } + if (viewMode === 'split') { + var editor = hasCM + ? html`
` + : html`