Feat v0.4.4 rich editor import export
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Notes — Surface Entry Point (v0.4.0)
|
||||
* Notes — Surface Entry Point (v0.5.0)
|
||||
*
|
||||
* Markdown notes surface using the SDK:
|
||||
* sw.api.ext('notes') — scoped API client
|
||||
* sw.ui.* — primitive components
|
||||
* sw.shell.Topbar — navigation bar
|
||||
* window.CM — CodeMirror 6 bundle (optional)
|
||||
*/
|
||||
(async function () {
|
||||
'use strict';
|
||||
@@ -15,6 +16,22 @@
|
||||
var base = window.__BASE__ || '';
|
||||
var ver = window.__VERSION__ || '0';
|
||||
|
||||
// ── Dynamic Script Loader ────────────────────
|
||||
function _loadScript(src) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
var s = document.createElement('script');
|
||||
s.src = base + src;
|
||||
s.type = 'module';
|
||||
s.onload = resolve;
|
||||
s.onerror = function() { resolve(); }; // Non-fatal
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Boot SDK ───────────────────────────────
|
||||
try {
|
||||
if (!window.preact) {
|
||||
@@ -32,6 +49,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Load CM6 bundle (optional — falls back to textarea) ──
|
||||
await _loadScript('/vendor/codemirror/codemirror.bundle.js?v=' + ver);
|
||||
var hasCM = !!(window.CM && window.CM.noteEditor);
|
||||
|
||||
var { html } = window;
|
||||
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
|
||||
var { render } = preact;
|
||||
@@ -537,6 +558,56 @@
|
||||
// EditorPane — note editor + preview + tags
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Export / Import helpers
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function exportNoteAsMarkdown(note, tags) {
|
||||
var parts = ['---'];
|
||||
parts.push('title: ' + (note.title || 'Untitled'));
|
||||
if (tags && tags.length) parts.push('tags: [' + tags.join(', ') + ']');
|
||||
if (note.created_at) parts.push('created: ' + note.created_at);
|
||||
parts.push('---');
|
||||
parts.push('');
|
||||
parts.push(note.body || '');
|
||||
var text = parts.join('\n');
|
||||
var blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (note.title || 'Untitled').replace(/[^a-zA-Z0-9_\- ]/g, '').trim() + '.md';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function parseFrontmatter(text) {
|
||||
var result = { title: '', tags: [], body: text };
|
||||
if (!text.startsWith('---')) return result;
|
||||
var end = text.indexOf('\n---', 3);
|
||||
if (end === -1) return result;
|
||||
var fm = text.substring(3, end).trim();
|
||||
var bodyStart = end + 4;
|
||||
// skip leading newline after frontmatter
|
||||
if (text[bodyStart] === '\n') bodyStart++;
|
||||
result.body = text.substring(bodyStart);
|
||||
var lines = fm.split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (line.startsWith('title:')) {
|
||||
result.title = line.substring(6).trim().replace(/^["']|["']$/g, '');
|
||||
} else if (line.startsWith('tags:')) {
|
||||
var tagStr = line.substring(5).trim();
|
||||
// parse [tag1, tag2] or tag1, tag2
|
||||
tagStr = tagStr.replace(/^\[|\]$/g, '');
|
||||
result.tags = tagStr.split(',').map(function(t) { return t.trim().toLowerCase(); }).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
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 : '');
|
||||
@@ -546,28 +617,102 @@
|
||||
var [saving, setSaving] = useState(false);
|
||||
var [saveCount, setSaveCount] = useState(0);
|
||||
var textareaRef = useRef(null);
|
||||
var cmContainerRef = useRef(null);
|
||||
var cmEditorRef = useRef(null);
|
||||
|
||||
// keep mutable refs for the latest title/body so CM6 callbacks see current values
|
||||
var titleRef = useRef(title);
|
||||
var bodyRef = useRef(body);
|
||||
titleRef.current = title;
|
||||
bodyRef.current = body;
|
||||
|
||||
// sync when note changes
|
||||
useEffect(function() {
|
||||
if (note) {
|
||||
setTitle(note.title || '');
|
||||
setBody(note.body || '');
|
||||
titleRef.current = note.title || '';
|
||||
bodyRef.current = note.body || '';
|
||||
setNoteTags(note.tags || []);
|
||||
setDirty(false);
|
||||
setPreview(false);
|
||||
// update CM6 content if editor exists
|
||||
if (cmEditorRef.current) {
|
||||
cmEditorRef.current.setValue(note.body || '');
|
||||
}
|
||||
}
|
||||
}, [note ? note.id : null]);
|
||||
|
||||
// ── CM6 lifecycle ─────────────────────────
|
||||
useEffect(function() {
|
||||
if (!hasCM || preview || !note) return;
|
||||
var el = cmContainerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// destroy previous
|
||||
if (cmEditorRef.current) {
|
||||
cmEditorRef.current.destroy();
|
||||
cmEditorRef.current = null;
|
||||
}
|
||||
|
||||
var editor = CM.noteEditor(el, {
|
||||
value: bodyRef.current,
|
||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
onChange: function(text) {
|
||||
bodyRef.current = text;
|
||||
setBody(text);
|
||||
setDirty(true);
|
||||
debounce('save', function() { doSave(titleRef.current, text); }, 1000);
|
||||
},
|
||||
onLink: function(linkTitle) {
|
||||
if (onNavigateToTitle) onNavigateToTitle(linkTitle);
|
||||
},
|
||||
linkCompleter: async function(query) {
|
||||
try {
|
||||
var res = await api.get('/search?q=' + encodeURIComponent(query));
|
||||
var items = (res && res.data) || res || [];
|
||||
if (!Array.isArray(items)) items = [];
|
||||
return items.map(function(n) { return { label: n.title, id: n.id }; });
|
||||
} catch (e) { return []; }
|
||||
},
|
||||
});
|
||||
|
||||
cmEditorRef.current = editor;
|
||||
|
||||
// Ctrl/Cmd+S via CM6 keymap injection
|
||||
var view = editor.getView();
|
||||
if (view) {
|
||||
var handleSaveKey = function(e) {
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
doSave(titleRef.current, bodyRef.current);
|
||||
}
|
||||
};
|
||||
view.dom.addEventListener('keydown', handleSaveKey);
|
||||
}
|
||||
|
||||
editor.focus();
|
||||
|
||||
return function() {
|
||||
if (cmEditorRef.current) {
|
||||
cmEditorRef.current.destroy();
|
||||
cmEditorRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [note ? note.id : null, preview]);
|
||||
|
||||
function handleTitleChange(e) {
|
||||
setTitle(e.target.value);
|
||||
titleRef.current = e.target.value;
|
||||
setDirty(true);
|
||||
debounce('save', function() { doSave(e.target.value, body); }, 1000);
|
||||
debounce('save', function() { doSave(e.target.value, bodyRef.current); }, 1000);
|
||||
}
|
||||
|
||||
function handleBodyChange(e) {
|
||||
setBody(e.target.value);
|
||||
bodyRef.current = e.target.value;
|
||||
setDirty(true);
|
||||
debounce('save', function() { doSave(title, e.target.value); }, 1000);
|
||||
debounce('save', function() { doSave(titleRef.current, e.target.value); }, 1000);
|
||||
}
|
||||
|
||||
async function doSave(t, b) {
|
||||
@@ -608,6 +753,11 @@
|
||||
onDelete();
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!note) return;
|
||||
exportNoteAsMarkdown(note, noteTags);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
// Tab inserts two spaces
|
||||
if (e.key === 'Tab') {
|
||||
@@ -619,6 +769,7 @@
|
||||
ta.value = val.substring(0, start) + ' ' + val.substring(end);
|
||||
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||
setBody(ta.value);
|
||||
bodyRef.current = ta.value;
|
||||
setDirty(true);
|
||||
debounce('save', function() { doSave(title, ta.value); }, 1000);
|
||||
}
|
||||
@@ -708,6 +859,21 @@
|
||||
return html`<div class="notes-editor__empty">Select a note or create a new one</div>`;
|
||||
}
|
||||
|
||||
// ── Render editor body ─────────────────────
|
||||
function renderEditorBody() {
|
||||
if (preview) {
|
||||
return html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
|
||||
}
|
||||
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}
|
||||
placeholder="Start writing in Markdown…" />`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="notes-editor">
|
||||
<div class="notes-editor__header">
|
||||
@@ -733,6 +899,7 @@
|
||||
onClick=${() => setPreview(!preview)}>
|
||||
${preview ? 'Edit' : 'Preview'}
|
||||
</button>
|
||||
<button class="notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
|
||||
<button class="notes-btn" onClick=${handlePin}
|
||||
title=${note.pinned ? 'Unpin' : 'Pin'}>
|
||||
${note.pinned ? '📌' : '📍'}
|
||||
@@ -743,13 +910,7 @@
|
||||
</div>
|
||||
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
||||
<div class="notes-editor__body">
|
||||
${preview
|
||||
? html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
||||
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
||||
value=${body} onInput=${handleBodyChange}
|
||||
onKeyDown=${handleKeyDown}
|
||||
placeholder="Start writing in Markdown…" />`
|
||||
}
|
||||
${renderEditorBody()}
|
||||
</div>
|
||||
<${BacklinksPanel} noteId=${note.id} onNavigate=${onNavigate} />
|
||||
</div>
|
||||
@@ -986,6 +1147,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Import .md file ─────────────────────
|
||||
function handleImport() {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.md,.markdown,.txt';
|
||||
input.style.display = 'none';
|
||||
input.onchange = async function() {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
var text = await new Promise(function(resolve, reject) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function() { resolve(reader.result); };
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
var parsed = parseFrontmatter(text);
|
||||
var noteTitle = parsed.title || file.name.replace(/\.(md|markdown|txt)$/i, '') || 'Imported';
|
||||
var payload = { title: noteTitle, body: parsed.body };
|
||||
if (activeFolderId) payload.folder_id = activeFolderId;
|
||||
var newNote = await api.post('/notes', payload);
|
||||
if (newNote && newNote.id) {
|
||||
if (parsed.tags.length) {
|
||||
await api.put('/tags/' + newNote.id, { tags: parsed.tags });
|
||||
}
|
||||
setActiveId(newNote.id);
|
||||
loadNote(newNote.id);
|
||||
loadNotes();
|
||||
loadStats();
|
||||
loadTags();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Import failed:', e);
|
||||
}
|
||||
document.body.removeChild(input);
|
||||
};
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────
|
||||
function handleSearch(e) {
|
||||
var val = e.target.value;
|
||||
@@ -996,6 +1197,7 @@
|
||||
return html`
|
||||
<${Topbar} title="Notes">
|
||||
<${Button} variant="primary" size="sm" onClick=${handleNew}>+ New Note<//>
|
||||
<${Button} variant="secondary" size="sm" onClick=${handleImport}>Import .md<//>
|
||||
<//>
|
||||
<div class="notes-app">
|
||||
<div class="notes-sidebar">
|
||||
|
||||
Reference in New Issue
Block a user