Changeset 0.25.0 (#160)
This commit is contained in:
450
src/js/note-editor.js
Normal file
450
src/js/note-editor.js
Normal file
@@ -0,0 +1,450 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — NoteEditor Component
|
||||
// ==========================================
|
||||
// v0.25.0: Component wrapper for notes list + editor.
|
||||
// Hydrates the server-rendered note-editor.html partial.
|
||||
// Can run standalone (in editor tabbed pane) or coexist with
|
||||
// the existing notes.js panel system (on chat/notes surfaces).
|
||||
//
|
||||
// Usage:
|
||||
// const notes = NoteEditor.create({
|
||||
// id: 'main', // matches template prefix
|
||||
// onOpenGraph: () => { ... },
|
||||
// });
|
||||
// notes.bind();
|
||||
// await notes.loadNotes();
|
||||
// notes.destroy();
|
||||
//
|
||||
// The existing notes.js + PanelRegistry integration continues to
|
||||
// work on the chat surface. This component is for NEW mount points
|
||||
// (editor pane, future notes-studio layout).
|
||||
|
||||
const NoteEditor = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'main';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
rootEl: document.getElementById(pfx + 'NoteEditor'),
|
||||
listViewEl: document.getElementById(pfx + 'NotesListView'),
|
||||
editorViewEl: document.getElementById(pfx + 'NotesEditorView'),
|
||||
graphViewEl: document.getElementById(pfx + 'NotesGraphView'),
|
||||
listEl: document.getElementById(pfx + 'NotesList'),
|
||||
searchEl: document.getElementById(pfx + 'NotesSearchInput'),
|
||||
folderFilterEl: document.getElementById(pfx + 'NotesFolderFilter'),
|
||||
sortEl: document.getElementById(pfx + 'NotesSortSelect'),
|
||||
titleEl: document.getElementById(pfx + 'NoteEditorTitle'),
|
||||
folderEl: document.getElementById(pfx + 'NoteEditorFolder'),
|
||||
tagsEl: document.getElementById(pfx + 'NoteEditorTags'),
|
||||
contentContainerEl: document.getElementById(pfx + 'NoteEditorContentContainer'),
|
||||
readTitleEl: document.getElementById(pfx + 'NoteReadTitle'),
|
||||
readMetaEl: document.getElementById(pfx + 'NoteReadMeta'),
|
||||
readContentEl: document.getElementById(pfx + 'NoteReadContent'),
|
||||
editModeEl: document.getElementById(pfx + 'NoteEditMode'),
|
||||
readModeEl: document.getElementById(pfx + 'NoteReadMode'),
|
||||
backlinksEl: document.getElementById(pfx + 'NoteBacklinks'),
|
||||
backlinksListEl: document.getElementById(pfx + 'NoteBacklinksList'),
|
||||
backlinksCountEl: document.getElementById(pfx + 'NoteBacklinksCount'),
|
||||
onOpenGraph: opts.onOpenGraph || null,
|
||||
_editingNoteId: null,
|
||||
_currentNote: null,
|
||||
_sort: 'updated_desc',
|
||||
_cmEditor: null,
|
||||
_listeners: [],
|
||||
|
||||
// ── View Switching ───────────────────
|
||||
|
||||
showList() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = '';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
|
||||
this._destroyCmEditor();
|
||||
},
|
||||
|
||||
showEditor() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = 'none';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = '';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
|
||||
},
|
||||
|
||||
showGraph() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = 'none';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = '';
|
||||
},
|
||||
|
||||
// ── Note List ───────────────────────
|
||||
|
||||
async loadNotes(folder, searchQuery) {
|
||||
if (!this.listEl) return;
|
||||
this.listEl.innerHTML = '<div class="notes-loading">Loading…</div>';
|
||||
|
||||
try {
|
||||
let notes;
|
||||
if (searchQuery) {
|
||||
const resp = await API.searchNotes(searchQuery);
|
||||
notes = resp.data || [];
|
||||
} else {
|
||||
const folderVal = folder || this.folderFilterEl?.value || '';
|
||||
const resp = await API.listNotes(100, 0, folderVal, '', this._sort);
|
||||
notes = resp.data || [];
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
this.listEl.innerHTML = '<div class="notes-empty">' +
|
||||
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = '';
|
||||
const self = this;
|
||||
notes.forEach(note => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'note-item';
|
||||
item.dataset.noteId = note.id;
|
||||
const title = note.title || 'Untitled';
|
||||
const preview = (note.content || '').slice(0, 80).replace(/\n/g, ' ');
|
||||
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
|
||||
item.innerHTML =
|
||||
'<div class="note-content-col">' +
|
||||
'<div class="note-title">' + _neEsc(title) + '</div>' +
|
||||
'<div class="note-preview">' + _neEsc(preview) + '</div>' +
|
||||
'<div class="note-date">' + _neEsc(date) + '</div>' +
|
||||
'</div>';
|
||||
item.addEventListener('click', () => self.openNote(note.id));
|
||||
self.listEl.appendChild(item);
|
||||
});
|
||||
} catch (e) {
|
||||
this.listEl.innerHTML = '<div class="notes-empty">Failed to load: ' + _neEsc(e.message) + '</div>';
|
||||
}
|
||||
},
|
||||
|
||||
async loadFolders() {
|
||||
if (!this.folderFilterEl) return;
|
||||
try {
|
||||
const resp = await API.listNoteFolders();
|
||||
const folders = resp.data || resp || [];
|
||||
// Keep "All folders" option, rebuild the rest
|
||||
this.folderFilterEl.innerHTML = '<option value="">All folders</option>';
|
||||
folders.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f;
|
||||
opt.textContent = f;
|
||||
this.folderFilterEl.appendChild(opt);
|
||||
});
|
||||
} catch (_) { /* ignore */ }
|
||||
},
|
||||
|
||||
// ── Note CRUD ───────────────────────
|
||||
|
||||
async openNote(noteId) {
|
||||
this.showEditor();
|
||||
if (noteId) {
|
||||
this._editingNoteId = noteId;
|
||||
try {
|
||||
this._currentNote = await API.getNote(noteId);
|
||||
this._populateFields(this._currentNote);
|
||||
this._showReadMode();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to load note: ' + e.message, 'error');
|
||||
this.showList();
|
||||
}
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
this._showEditMode();
|
||||
if (this.titleEl) this.titleEl.focus();
|
||||
}
|
||||
},
|
||||
|
||||
async saveNote() {
|
||||
const title = this.titleEl?.value?.trim() || '';
|
||||
const content = this._getContent();
|
||||
const folder = this.folderEl?.value?.trim() || '';
|
||||
const tagsStr = this.tagsEl?.value || '';
|
||||
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
if (!title && !content) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Note is empty', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this._editingNoteId) {
|
||||
await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags });
|
||||
} else {
|
||||
const resp = await API.createNote(title, content, folder, tags);
|
||||
this._editingNoteId = resp.id || resp.data?.id;
|
||||
}
|
||||
this._currentNote = { ...this._currentNote, title, content, folder_path: folder, tags };
|
||||
this._showReadMode();
|
||||
if (typeof UI !== 'undefined') UI.toast('Note saved', 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Save failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteNote() {
|
||||
if (!this._editingNoteId) return;
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete this note?')
|
||||
: window.confirm('Delete this note?');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await API.deleteNote(this._editingNoteId);
|
||||
if (typeof UI !== 'undefined') UI.toast('Note deleted', 'success');
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this.showList();
|
||||
this.loadNotes();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Read / Edit / Preview Modes ─────
|
||||
|
||||
_showReadMode() {
|
||||
if (this.editModeEl) this.editModeEl.style.display = 'none';
|
||||
if (this.readModeEl) this.readModeEl.style.display = '';
|
||||
|
||||
const n = this._currentNote;
|
||||
if (!n) return;
|
||||
|
||||
if (this.readTitleEl) this.readTitleEl.textContent = n.title || 'Untitled';
|
||||
if (this.readMetaEl) {
|
||||
const parts = [];
|
||||
if (n.folder_path) parts.push('📁 ' + n.folder_path);
|
||||
if (n.tags?.length) parts.push('🏷 ' + n.tags.join(', '));
|
||||
if (n.updated_at) parts.push(new Date(n.updated_at).toLocaleString());
|
||||
this.readMetaEl.textContent = parts.join(' · ');
|
||||
}
|
||||
if (this.readContentEl) {
|
||||
if (typeof marked !== 'undefined') {
|
||||
const raw = typeof marked.parse === 'function' ? marked.parse(n.content || '') : marked(n.content || '');
|
||||
this.readContentEl.innerHTML = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw) : raw;
|
||||
} else {
|
||||
this.readContentEl.textContent = n.content || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Show edit/delete buttons for read mode
|
||||
this._setBtn('NoteEditBtn', true);
|
||||
this._setBtn('NotePreviewBtn', false);
|
||||
this._setBtn('NoteCancelEditBtn', false);
|
||||
this._setBtn('NoteDeleteBtn', true);
|
||||
},
|
||||
|
||||
_showEditMode() {
|
||||
if (this.editModeEl) this.editModeEl.style.display = '';
|
||||
if (this.readModeEl) this.readModeEl.style.display = 'none';
|
||||
|
||||
this._setBtn('NoteEditBtn', false);
|
||||
this._setBtn('NotePreviewBtn', true);
|
||||
this._setBtn('NoteCancelEditBtn', !!this._currentNote);
|
||||
this._setBtn('NoteDeleteBtn', !!this._editingNoteId);
|
||||
},
|
||||
|
||||
_showPreview() {
|
||||
// Toggle between edit and read-mode preview
|
||||
if (this.readModeEl?.style.display === 'none') {
|
||||
// Show preview of current edits
|
||||
const tempNote = {
|
||||
title: this.titleEl?.value || '',
|
||||
content: this._getContent(),
|
||||
folder_path: this.folderEl?.value || '',
|
||||
tags: (this.tagsEl?.value || '').split(',').map(t => t.trim()).filter(Boolean),
|
||||
};
|
||||
const saved = this._currentNote;
|
||||
this._currentNote = tempNote;
|
||||
this._showReadMode();
|
||||
this._currentNote = saved;
|
||||
// Swap buttons for preview-of-edits state
|
||||
this._setBtn('NoteEditBtn', true);
|
||||
this._setBtn('NotePreviewBtn', false);
|
||||
} else {
|
||||
this._showEditMode();
|
||||
}
|
||||
},
|
||||
|
||||
// ── CM6 Editor Management ───────────
|
||||
|
||||
_getContent() {
|
||||
if (this._cmEditor) return this._cmEditor.getValue();
|
||||
const ta = this.contentContainerEl?.querySelector('textarea');
|
||||
return ta ? ta.value : '';
|
||||
},
|
||||
|
||||
_setContent(text) {
|
||||
if (this._cmEditor) { this._cmEditor.setValue(text); return; }
|
||||
|
||||
// Lazy-init CM6
|
||||
if (this.contentContainerEl && window.CM?.noteEditor) {
|
||||
this.contentContainerEl.innerHTML = '';
|
||||
this._cmEditor = CM.noteEditor(this.contentContainerEl, {
|
||||
value: text,
|
||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
onLink: (title) => this._navigateToLink(title),
|
||||
linkCompleter: async (query) => {
|
||||
if (!query || query.length < 1) return [];
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(query, 8);
|
||||
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
|
||||
} catch { return []; }
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Textarea fallback
|
||||
const ta = this.contentContainerEl?.querySelector('textarea');
|
||||
if (ta) ta.value = text;
|
||||
},
|
||||
|
||||
_destroyCmEditor() {
|
||||
if (this._cmEditor?.destroy) this._cmEditor.destroy();
|
||||
this._cmEditor = null;
|
||||
},
|
||||
|
||||
async _navigateToLink(title) {
|
||||
try {
|
||||
const resp = await API.searchNotes(title);
|
||||
const notes = resp.data || [];
|
||||
const match = notes.find(n => n.title?.toLowerCase() === title.toLowerCase());
|
||||
if (match) {
|
||||
this.openNote(match.id);
|
||||
} else {
|
||||
// Create new note with this title
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
if (this.titleEl) this.titleEl.value = title;
|
||||
this._showEditMode();
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
// ── Field Helpers ───────────────────
|
||||
|
||||
_populateFields(note) {
|
||||
if (this.titleEl) this.titleEl.value = note.title || '';
|
||||
if (this.folderEl) this.folderEl.value = note.folder_path || '';
|
||||
if (this.tagsEl) this.tagsEl.value = (note.tags || []).join(', ');
|
||||
this._setContent(note.content || '');
|
||||
},
|
||||
|
||||
_clearFields() {
|
||||
if (this.titleEl) this.titleEl.value = '';
|
||||
if (this.folderEl) this.folderEl.value = '';
|
||||
if (this.tagsEl) this.tagsEl.value = '';
|
||||
this._setContent('');
|
||||
},
|
||||
|
||||
_setBtn(suffix, show) {
|
||||
const el = document.getElementById(pfx + suffix);
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Event Wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
const $ = (id) => document.getElementById(pfx + id);
|
||||
const self = this;
|
||||
|
||||
// Toolbar
|
||||
this._on($('NotesNewBtn'), 'click', () => self.openNote(null));
|
||||
this._on($('NotesTodayBtn'), 'click', () => self._openDailyNote());
|
||||
this._on($('NotesGraphBtn'), 'click', () => {
|
||||
if (self.onOpenGraph) self.onOpenGraph();
|
||||
});
|
||||
|
||||
// Editor header
|
||||
this._on($('NotesBackBtn'), 'click', () => { self.showList(); self.loadNotes(); self.loadFolders(); });
|
||||
this._on($('NoteSaveBtn'), 'click', () => self.saveNote());
|
||||
this._on($('NoteDeleteBtn'), 'click', () => self.deleteNote());
|
||||
this._on($('NoteEditBtn'), 'click', () => self._showEditMode());
|
||||
this._on($('NotePreviewBtn'), 'click', () => self._showPreview());
|
||||
this._on($('NoteCancelEditBtn'), 'click', () => {
|
||||
if (self._currentNote) { self._populateFields(self._currentNote); self._showReadMode(); }
|
||||
else self.showList();
|
||||
});
|
||||
|
||||
// Filters
|
||||
this._on(this.folderFilterEl, 'change', () => {
|
||||
if (self.searchEl) self.searchEl.value = '';
|
||||
self.loadNotes(self.folderFilterEl.value);
|
||||
});
|
||||
this._on(this.sortEl, 'change', () => {
|
||||
self._sort = self.sortEl.value;
|
||||
self.loadNotes();
|
||||
});
|
||||
|
||||
// Search with debounce
|
||||
let timer;
|
||||
this._on(this.searchEl, 'input', () => {
|
||||
clearTimeout(timer);
|
||||
const q = self.searchEl.value.trim();
|
||||
timer = setTimeout(() => {
|
||||
if (q.length >= 2) {
|
||||
if (self.folderFilterEl) self.folderFilterEl.value = '';
|
||||
self.loadNotes(null, q);
|
||||
} else if (q.length === 0) {
|
||||
self.loadNotes();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Daily Note ──────────────────────
|
||||
|
||||
async _openDailyNote() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const resp = await API.searchNotes(today);
|
||||
const notes = resp.data || [];
|
||||
const daily = notes.find(n => n.title === today || n.title?.startsWith(today));
|
||||
if (daily) {
|
||||
this.openNote(daily.id);
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
if (this.titleEl) this.titleEl.value = today;
|
||||
this._setContent('# ' + today + '\n\n');
|
||||
this.showEditor();
|
||||
this._showEditMode();
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to open daily note: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._destroyCmEditor();
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
NoteEditor._instances.delete(this.id);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
NoteEditor._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
function _neEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
Reference in New Issue
Block a user