Feat v0.4.5 editor modes outline (#27)
All checks were successful
All checks were successful
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user