This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/note-editor.js
2026-03-14 14:43:55 +00:00

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);
})();