445 lines
20 KiB
JavaScript
445 lines
20 KiB
JavaScript
// ==========================================
|
|
// 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).
|
|
//
|
|
// Exports: window.NoteEditor
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
const NoteEditor = {
|
|
...createComponentRegistry('NoteEditor'),
|
|
|
|
create(opts) {
|
|
const pfx = opts.id || 'main';
|
|
const instance = componentMixin({
|
|
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,
|
|
|
|
// ── 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="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="empty-hint">' +
|
|
(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">' + esc(title) + '</div>' +
|
|
'<div class="note-preview">' + esc(preview) + '</div>' +
|
|
'<div class="note-date">' + esc(date) + '</div>' +
|
|
'</div>';
|
|
item.addEventListener('click', () => self.openNote(note.id));
|
|
self.listEl.appendChild(item);
|
|
});
|
|
} catch (e) {
|
|
this.listEl.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(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');
|
|
}
|
|
},
|
|
|
|
_cleanup() {
|
|
this._destroyCmEditor();
|
|
this._editingNoteId = null;
|
|
this._currentNote = null;
|
|
},
|
|
}, NoteEditor);
|
|
|
|
NoteEditor._register(pfx, instance);
|
|
return instance;
|
|
},
|
|
};
|
|
|
|
|
|
// ── Exports ─────────────────────────────────
|
|
sb.ns('NoteEditor', NoteEditor);
|
|
})();
|