Changeset 0.30.1 (#200)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
788
src/js/note-panel.js
Normal file
788
src/js/note-panel.js
Normal file
@@ -0,0 +1,788 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – NotePanel Component
|
||||
// ==========================================
|
||||
// v0.30.1: Mountable, self-contained notes panel instances.
|
||||
// Follows the ChatPane pattern: createComponentRegistry + componentMixin.
|
||||
// NotePanel.primary is the backward-compat bridge (singleton used by notes.js shim).
|
||||
//
|
||||
// Exports: window.NotePanel
|
||||
|
||||
|
||||
const NotePanel = {
|
||||
...createComponentRegistry('NotePanel'),
|
||||
|
||||
create(opts) {
|
||||
const id = opts.id || 'notes-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
||||
const container = opts.container;
|
||||
if (!container) {
|
||||
console.error('[NotePanel] container is required');
|
||||
return null;
|
||||
}
|
||||
|
||||
const instance = componentMixin({
|
||||
id,
|
||||
container,
|
||||
projectId: opts.projectId || null,
|
||||
|
||||
// ── State (was module-level in notes.js) ──
|
||||
_editingNoteId: null,
|
||||
_notesSelectMode: false,
|
||||
_selectedNoteIds: new Set(),
|
||||
_notesSort: 'updated_desc',
|
||||
_noteEditor: null,
|
||||
_currentNote: null,
|
||||
_searchTimer: null,
|
||||
|
||||
// ── DOM query helper ──
|
||||
$(sel) { return this.container.querySelector(sel); },
|
||||
$$(sel) { return this.container.querySelectorAll(sel); },
|
||||
|
||||
// ── Notes List ──
|
||||
|
||||
async loadNotesList(folder, searchQuery) {
|
||||
const list = this.$('#notesList');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="loading">Loading\u2026</div>';
|
||||
|
||||
try {
|
||||
let data;
|
||||
if (searchQuery) {
|
||||
data = await API.searchNotes(searchQuery);
|
||||
const results = data.data || [];
|
||||
if (results.length === 0) {
|
||||
list.innerHTML = '<div class="empty-hint">No results found</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = results.map(n => this._noteListItem(n, true)).join('');
|
||||
} else {
|
||||
const folderVal = folder || this.$('#notesFolderFilter')?.value || '';
|
||||
data = await API.listNotes(100, 0, folderVal, '', this._notesSort);
|
||||
const notes = data.data || [];
|
||||
if (notes.length === 0) {
|
||||
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = notes.map(n => this._noteListItem(n, false)).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
_noteListItem(note, isSearch) {
|
||||
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
|
||||
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
|
||||
const preview = isSearch && note.headline
|
||||
? `<div class="note-headline">${this._highlightHeadline(note.headline)}</div>`
|
||||
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
|
||||
const time = _relativeTime(note.updated_at);
|
||||
const checked = this._selectedNoteIds.has(note.id) ? 'checked' : '';
|
||||
|
||||
return `
|
||||
<div class="note-item ${this._selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
|
||||
<div class="note-select-col" style="display:${this._notesSelectMode ? '' : 'none'}">
|
||||
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
|
||||
data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
|
||||
</div>
|
||||
<div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
|
||||
<div class="note-item-header">
|
||||
<span class="note-item-title">${esc(note.title)}</span>
|
||||
<span class="note-item-time">${time}</span>
|
||||
</div>
|
||||
${preview}
|
||||
<div class="note-item-meta">${folder}${tags}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
_highlightHeadline(headline) {
|
||||
const escaped = esc(headline);
|
||||
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
|
||||
},
|
||||
|
||||
// ── Multi-select ──
|
||||
|
||||
_enterSelectMode() {
|
||||
this._notesSelectMode = true;
|
||||
this._selectedNoteIds.clear();
|
||||
const bar = this.$('#notesSelectionBar');
|
||||
if (bar) bar.style.display = '';
|
||||
this.$$('.note-select-col').forEach(el => el.style.display = '');
|
||||
this._updateSelectedCount();
|
||||
},
|
||||
|
||||
_exitSelectMode() {
|
||||
this._notesSelectMode = false;
|
||||
this._selectedNoteIds.clear();
|
||||
const bar = this.$('#notesSelectionBar');
|
||||
if (bar) bar.style.display = 'none';
|
||||
this.$$('.note-select-col').forEach(el => el.style.display = 'none');
|
||||
this.$$('.note-item.selected').forEach(el => el.classList.remove('selected'));
|
||||
this.$$('.note-checkbox').forEach(cb => cb.checked = false);
|
||||
const sa = this.$('#notesSelectAll');
|
||||
if (sa) sa.checked = false;
|
||||
},
|
||||
|
||||
_toggleNoteSelect(noteId, forceState) {
|
||||
if (!this._notesSelectMode) {
|
||||
this._enterSelectMode();
|
||||
}
|
||||
const has = this._selectedNoteIds.has(noteId);
|
||||
const newState = forceState !== undefined ? forceState : !has;
|
||||
|
||||
if (newState) this._selectedNoteIds.add(noteId);
|
||||
else this._selectedNoteIds.delete(noteId);
|
||||
|
||||
const item = this.container.querySelector(`.note-item[data-note-id="${noteId}"]`);
|
||||
if (item) {
|
||||
item.classList.toggle('selected', newState);
|
||||
const cb = item.querySelector('.note-checkbox');
|
||||
if (cb) cb.checked = newState;
|
||||
}
|
||||
this._updateSelectedCount();
|
||||
},
|
||||
|
||||
_toggleSelectAll(checked) {
|
||||
this.$$('.note-checkbox').forEach(cb => {
|
||||
const cbId = cb.dataset.id;
|
||||
if (checked) this._selectedNoteIds.add(cbId);
|
||||
else this._selectedNoteIds.delete(cbId);
|
||||
cb.checked = checked;
|
||||
cb.closest('.note-item')?.classList.toggle('selected', checked);
|
||||
});
|
||||
this._updateSelectedCount();
|
||||
},
|
||||
|
||||
_updateSelectedCount() {
|
||||
const el = this.$('#notesSelectedCount');
|
||||
if (el) el.textContent = this._selectedNoteIds.size;
|
||||
},
|
||||
|
||||
async _bulkDeleteSelected() {
|
||||
if (this._selectedNoteIds.size === 0) return;
|
||||
const count = this._selectedNoteIds.size;
|
||||
if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
|
||||
|
||||
try {
|
||||
const resp = await API.bulkDeleteNotes([...this._selectedNoteIds]);
|
||||
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
|
||||
this._exitSelectMode();
|
||||
await this.loadNotesList();
|
||||
await this.loadNoteFolders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
// ── Folders ──
|
||||
|
||||
async loadNoteFolders() {
|
||||
const sel = this.$('#notesFolderFilter');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const data = await API.listNoteFolders();
|
||||
const folders = data.folders || [];
|
||||
sel.innerHTML = '<option value="">All folders</option>';
|
||||
folders.forEach(f => {
|
||||
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
|
||||
});
|
||||
} catch (e) { /* non-critical */ }
|
||||
},
|
||||
|
||||
// ── View switching ──
|
||||
|
||||
showNotesList() {
|
||||
const lv = this.$('#notesListView');
|
||||
const ev = this.$('#notesEditorView');
|
||||
const gv = this.$('#notesGraphView');
|
||||
if (lv) lv.style.display = '';
|
||||
if (ev) ev.style.display = 'none';
|
||||
if (gv) gv.style.display = 'none';
|
||||
this._editingNoteId = null;
|
||||
this._destroyNoteEditor();
|
||||
const list = this.$('#notesList');
|
||||
if (list) list.innerHTML = '<div class="loading">Loading\u2026</div>';
|
||||
},
|
||||
|
||||
// ── Note Editor ──
|
||||
|
||||
copyNoteContent() {
|
||||
if (!this._currentNote) return;
|
||||
const text = `# ${this._currentNote.title || ''}\n\n${this._currentNote.content || ''}`;
|
||||
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
|
||||
},
|
||||
|
||||
async openNoteEditor(noteId) {
|
||||
const lv = this.$('#notesListView');
|
||||
const ev = this.$('#notesEditorView');
|
||||
if (lv) lv.style.display = 'none';
|
||||
if (ev) ev.style.display = '';
|
||||
|
||||
if (noteId) {
|
||||
this._editingNoteId = noteId;
|
||||
try {
|
||||
this._currentNote = await API.getNote(noteId);
|
||||
this._populateEditFields(this._currentNote);
|
||||
this._showNoteReadMode();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to load note: ' + e.message, 'error');
|
||||
this.showNotesList();
|
||||
}
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearEditFields();
|
||||
this._showNoteEditMode();
|
||||
this.$('#noteEditorTitle')?.focus();
|
||||
}
|
||||
},
|
||||
|
||||
_populateEditFields(note) {
|
||||
const t = this.$('#noteEditorTitle');
|
||||
const f = this.$('#noteEditorFolder');
|
||||
const g = this.$('#noteEditorTags');
|
||||
if (t) t.value = note.title || '';
|
||||
if (f) f.value = note.folder_path || '';
|
||||
if (g) g.value = (note.tags || []).join(', ');
|
||||
this._setNoteEditorContent(note.content || '');
|
||||
},
|
||||
|
||||
_clearEditFields() {
|
||||
const t = this.$('#noteEditorTitle');
|
||||
const f = this.$('#noteEditorFolder');
|
||||
const g = this.$('#noteEditorTags');
|
||||
if (t) t.value = '';
|
||||
if (f) f.value = '';
|
||||
if (g) g.value = '';
|
||||
this._setNoteEditorContent('');
|
||||
},
|
||||
|
||||
_getNoteEditorContent() {
|
||||
if (this._noteEditor) return this._noteEditor.getValue();
|
||||
const ta = this.container.querySelector('#noteEditorContentContainer textarea');
|
||||
return ta ? ta.value : '';
|
||||
},
|
||||
|
||||
_setNoteEditorContent(text) {
|
||||
if (this._noteEditor) {
|
||||
this._noteEditor.setValue(text);
|
||||
return;
|
||||
}
|
||||
const container = this.$('#noteEditorContentContainer');
|
||||
if (!container) return;
|
||||
|
||||
if (window.CM?.noteEditor) {
|
||||
container.innerHTML = '';
|
||||
this._noteEditor = CM.noteEditor(container, {
|
||||
value: text,
|
||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
onChange: null,
|
||||
onLink: (title) => {
|
||||
this._navigateToLinkedNote(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 []; }
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (!container.querySelector('textarea')) {
|
||||
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>';
|
||||
}
|
||||
const ta = container.querySelector('textarea');
|
||||
if (ta) ta.value = text;
|
||||
}
|
||||
},
|
||||
|
||||
async _navigateToLinkedNote(title) {
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(title, 1);
|
||||
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
|
||||
if (match) {
|
||||
await this.openNoteEditor(match.id);
|
||||
} else {
|
||||
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
|
||||
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
|
||||
await this.openNoteEditor(created.id);
|
||||
}
|
||||
}
|
||||
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
_destroyNoteEditor() {
|
||||
if (this._noteEditor) {
|
||||
this._noteEditor.destroy();
|
||||
this._noteEditor = null;
|
||||
}
|
||||
},
|
||||
|
||||
_showNoteReadMode() {
|
||||
if (!this._currentNote) return;
|
||||
const n = this._currentNote;
|
||||
|
||||
const titleEl = this.$('#noteReadTitle');
|
||||
if (titleEl) titleEl.textContent = n.title || '';
|
||||
|
||||
const parts = [];
|
||||
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
|
||||
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||
const metaEl = this.$('#noteReadMeta');
|
||||
if (metaEl) metaEl.innerHTML = parts.join(' ');
|
||||
|
||||
const noteReadEl = this.$('#noteReadContent');
|
||||
if (noteReadEl) {
|
||||
noteReadEl.innerHTML = formatMessage(n.content || '');
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
|
||||
this._renderWikilinksInReadMode(noteReadEl);
|
||||
}
|
||||
|
||||
const delBtn = this.$('#noteDeleteBtn2');
|
||||
if (delBtn) delBtn.style.display = this._editingNoteId ? '' : 'none';
|
||||
|
||||
const rm = this.$('#noteReadMode');
|
||||
const em = this.$('#noteEditMode');
|
||||
const eb = this.$('#noteEditBtn');
|
||||
const pb = this.$('#notePreviewBtn');
|
||||
if (rm) rm.style.display = '';
|
||||
if (em) em.style.display = 'none';
|
||||
if (eb) eb.style.display = '';
|
||||
if (pb) pb.style.display = 'none';
|
||||
|
||||
if (this._editingNoteId) this._loadBacklinks(this._editingNoteId);
|
||||
this._updateDailyNav(this._currentNote);
|
||||
},
|
||||
|
||||
_showNoteEditMode() {
|
||||
const rm = this.$('#noteReadMode');
|
||||
const em = this.$('#noteEditMode');
|
||||
const db = this.$('#noteDeleteBtn');
|
||||
const cb = this.$('#noteCancelEditBtn');
|
||||
const eb = this.$('#noteEditBtn');
|
||||
const pb = this.$('#notePreviewBtn');
|
||||
if (rm) rm.style.display = 'none';
|
||||
if (em) em.style.display = '';
|
||||
if (db) db.style.display = this._editingNoteId ? '' : 'none';
|
||||
if (cb) cb.style.display = this._editingNoteId ? '' : 'none';
|
||||
if (eb) eb.style.display = 'none';
|
||||
if (pb) pb.style.display = '';
|
||||
},
|
||||
|
||||
_showNotePreview() {
|
||||
const title = this.$('#noteEditorTitle')?.value.trim() || '';
|
||||
const content = this._getNoteEditorContent();
|
||||
const folderVal = this.$('#noteEditorFolder')?.value.trim() || '';
|
||||
const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
|
||||
const titleEl = this.$('#noteReadTitle');
|
||||
if (titleEl) titleEl.textContent = title || 'Untitled';
|
||||
const parts = [];
|
||||
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
|
||||
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||
const metaEl = this.$('#noteReadMeta');
|
||||
if (metaEl) metaEl.innerHTML = parts.join(' ');
|
||||
const readEl = this.$('#noteReadContent');
|
||||
if (readEl) {
|
||||
readEl.innerHTML = formatMessage(content || '');
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(readEl);
|
||||
this._renderWikilinksInReadMode(readEl);
|
||||
}
|
||||
|
||||
const rm = this.$('#noteReadMode');
|
||||
const em = this.$('#noteEditMode');
|
||||
const eb = this.$('#noteEditBtn');
|
||||
const pb = this.$('#notePreviewBtn');
|
||||
if (rm) rm.style.display = '';
|
||||
if (em) em.style.display = 'none';
|
||||
if (eb) eb.style.display = '';
|
||||
if (pb) pb.style.display = 'none';
|
||||
const delBtn = this.$('#noteDeleteBtn2');
|
||||
if (delBtn) delBtn.style.display = 'none';
|
||||
},
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
async saveNote() {
|
||||
const title = this.$('#noteEditorTitle')?.value.trim() || '';
|
||||
const content = this._getNoteEditorContent();
|
||||
const folder = this.$('#noteEditorFolder')?.value.trim() || '';
|
||||
const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
|
||||
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
||||
if (!content) { UI.toast('Content is required', 'warning'); return; }
|
||||
|
||||
try {
|
||||
if (this._editingNoteId) {
|
||||
const updated = await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
|
||||
this._currentNote = updated;
|
||||
UI.toast('Note updated', 'success');
|
||||
this._showNoteReadMode();
|
||||
} else {
|
||||
const created = await API.createNote(title, content, folder, tags);
|
||||
this._editingNoteId = created.id;
|
||||
this._currentNote = created;
|
||||
UI.toast('Note created', 'success');
|
||||
this._showNoteReadMode();
|
||||
}
|
||||
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async deleteNote() {
|
||||
if (!this._editingNoteId) return;
|
||||
if (!await showConfirm('Delete this note?')) return;
|
||||
try {
|
||||
await API.deleteNote(this._editingNoteId);
|
||||
UI.toast('Note deleted', 'success');
|
||||
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||
this.showNotesList();
|
||||
await this.loadNotesList();
|
||||
await this.loadNoteFolders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
// ── Backlinks ──
|
||||
|
||||
async _loadBacklinks(noteId) {
|
||||
const container = this.$('#noteBacklinks');
|
||||
const listEl = this.$('#noteBacklinksList');
|
||||
const countEl = this.$('#noteBacklinksCount');
|
||||
if (!container || !listEl || !countEl) return;
|
||||
|
||||
try {
|
||||
const resp = await API.getNoteBacklinks(noteId);
|
||||
const links = resp.data || [];
|
||||
countEl.textContent = links.length;
|
||||
container.style.display = links.length > 0 ? '' : 'none';
|
||||
listEl.innerHTML = links.map(n => `
|
||||
<div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
|
||||
<span class="note-backlink-title">${esc(n.title)}</span>
|
||||
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
toggleBacklinks() {
|
||||
const list = this.$('#noteBacklinksList');
|
||||
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Wikilink Read-Mode Rendering ──
|
||||
|
||||
_renderWikilinksInReadMode(containerEl) {
|
||||
if (!containerEl) return;
|
||||
const html = containerEl.innerHTML;
|
||||
let transclusionId = 0;
|
||||
const rendered = html.replace(
|
||||
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
|
||||
(match, bang, title, display) => {
|
||||
const titleTrimmed = title.trim();
|
||||
const displayText = display ? display.trim() : titleTrimmed;
|
||||
if (bang === '!') {
|
||||
const tid = `transclusion-${++transclusionId}`;
|
||||
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
|
||||
<div class="transclusion-header">
|
||||
<span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
|
||||
</div>
|
||||
<div class="transclusion-content"><span class="text-muted">Loading\u2026</span></div>
|
||||
</div>`;
|
||||
}
|
||||
return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
|
||||
}
|
||||
);
|
||||
containerEl.innerHTML = rendered;
|
||||
this._resolveTransclusions(containerEl);
|
||||
},
|
||||
|
||||
async _resolveTransclusions(containerEl) {
|
||||
const embeds = containerEl.querySelectorAll('.transclusion-embed');
|
||||
if (!embeds.length) return;
|
||||
|
||||
const cache = {};
|
||||
for (const el of embeds) {
|
||||
const title = el.dataset.title;
|
||||
const contentEl = el.querySelector('.transclusion-content');
|
||||
if (!title || !contentEl) continue;
|
||||
|
||||
try {
|
||||
let note = cache[title.toLowerCase()];
|
||||
if (!note) {
|
||||
const resp = await API.searchNoteTitles(title, 1);
|
||||
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
|
||||
if (!match) {
|
||||
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
|
||||
continue;
|
||||
}
|
||||
note = await API.getNote(match.id);
|
||||
cache[title.toLowerCase()] = note;
|
||||
}
|
||||
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
|
||||
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
|
||||
return `[Embedded: ${inner}]`;
|
||||
});
|
||||
contentEl.innerHTML = formatMessage(safeContent);
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
|
||||
} catch {
|
||||
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Daily Notes ──
|
||||
|
||||
async openDailyNote() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const title = `Daily \u2014 ${today}`;
|
||||
const folder = '/daily/';
|
||||
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(title, 1);
|
||||
const existing = (resp.data || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
await this.openNoteEditor(existing.id);
|
||||
} else {
|
||||
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||
const created = await API.createNote(title, content, folder, ['daily']);
|
||||
await this.openNoteEditor(created.id);
|
||||
}
|
||||
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
_isDailyNote(note) {
|
||||
return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
|
||||
},
|
||||
|
||||
_updateDailyNav(note) {
|
||||
let nav = this.$('#noteDailyNav');
|
||||
if (!this._isDailyNote(note)) {
|
||||
if (nav) nav.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nav) {
|
||||
const toolbar = this.container.querySelector('.notes-editor-toolbar');
|
||||
if (!toolbar) return;
|
||||
nav = document.createElement('div');
|
||||
nav.id = 'noteDailyNav';
|
||||
nav.className = 'note-daily-nav';
|
||||
nav.innerHTML = `
|
||||
<button class="btn-small" id="dailyPrevBtn" title="Previous day">\u2039 Prev</button>
|
||||
<span class="note-daily-date" id="dailyDateLabel"></span>
|
||||
<button class="btn-small" id="dailyNextBtn" title="Next day">Next \u203a</button>
|
||||
`;
|
||||
toolbar.after(nav);
|
||||
nav.querySelector('#dailyPrevBtn')?.addEventListener('click', () => this._navigateDaily(-1));
|
||||
nav.querySelector('#dailyNextBtn')?.addEventListener('click', () => this._navigateDaily(1));
|
||||
}
|
||||
|
||||
nav.style.display = '';
|
||||
const dateStr = note.title.replace('Daily \u2014 ', '');
|
||||
const label = nav.querySelector('#dailyDateLabel');
|
||||
if (label) label.textContent = dateStr;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const nextBtn = nav.querySelector('#dailyNextBtn');
|
||||
if (nextBtn) nextBtn.disabled = dateStr >= today;
|
||||
},
|
||||
|
||||
async _navigateDaily(offset) {
|
||||
if (!this._currentNote || !this._isDailyNote(this._currentNote)) return;
|
||||
const dateStr = this._currentNote.title.replace('Daily \u2014 ', '');
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
d.setDate(d.getDate() + offset);
|
||||
const newDateStr = d.toISOString().slice(0, 10);
|
||||
const title = `Daily \u2014 ${newDateStr}`;
|
||||
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(title, 1);
|
||||
const existing = (resp.data || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
await this.openNoteEditor(existing.id);
|
||||
} else {
|
||||
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||
const created = await API.createNote(title, content, '/daily/', ['daily']);
|
||||
await this.openNoteEditor(created.id);
|
||||
}
|
||||
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
// ── Save-to-Note (from chat messages) ──
|
||||
|
||||
async saveMessageToNote(msgIndex) {
|
||||
const chat = App.chats?.find(c => c.id === App.activeId);
|
||||
const msg = chat?.messages?.[msgIndex];
|
||||
if (!msg) return;
|
||||
|
||||
const sel = window.getSelection();
|
||||
let content = msg.content;
|
||||
|
||||
content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim();
|
||||
|
||||
const msgId = msg.id || '';
|
||||
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
|
||||
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
|
||||
const selected = sel.toString().trim();
|
||||
if (selected) content = selected;
|
||||
}
|
||||
|
||||
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
|
||||
|
||||
this._showSaveToNoteModal({
|
||||
content,
|
||||
sourceChannelId: App.activeId || '',
|
||||
sourceMessageId: msg.id || '',
|
||||
defaultTitle,
|
||||
});
|
||||
},
|
||||
|
||||
_showSaveToNoteModal(opts) {
|
||||
document.getElementById('saveToNoteModal')?.remove();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'saveToNoteModal';
|
||||
modal.className = 'modal-overlay active';
|
||||
modal.innerHTML = `
|
||||
<div class="modal save-to-note-modal">
|
||||
<h3>Save to Note</h3>
|
||||
<div class="save-note-mode-toggle">
|
||||
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
|
||||
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
|
||||
</div>
|
||||
<div id="saveNoteCreateFields">
|
||||
<div class="form-group">
|
||||
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
|
||||
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="saveNoteAppendFields" style="display:none">
|
||||
<div class="form-group">
|
||||
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
|
||||
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
|
||||
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '\u2026' : ''}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
|
||||
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
let selectedAppendNote = null;
|
||||
|
||||
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
|
||||
radio.addEventListener('change', (e) => {
|
||||
const isAppend = e.target.value === 'append';
|
||||
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
|
||||
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
|
||||
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
|
||||
selectedAppendNote = null;
|
||||
document.getElementById('saveNoteSearchResults').innerHTML = '';
|
||||
});
|
||||
});
|
||||
|
||||
let _searchTimer;
|
||||
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
|
||||
clearTimeout(_searchTimer);
|
||||
const q = e.target.value.trim();
|
||||
selectedAppendNote = null;
|
||||
_searchTimer = setTimeout(async () => {
|
||||
const resultsEl = document.getElementById('saveNoteSearchResults');
|
||||
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(q, 8);
|
||||
const notes = resp.data || [];
|
||||
resultsEl.innerHTML = notes.map(n =>
|
||||
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
|
||||
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
|
||||
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
|
||||
document.getElementById('saveNoteSearch').value = el.dataset.title;
|
||||
});
|
||||
});
|
||||
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
|
||||
}, 250);
|
||||
});
|
||||
|
||||
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||
|
||||
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
|
||||
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
|
||||
|
||||
try {
|
||||
if (isAppend) {
|
||||
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
|
||||
await API.updateNote(selectedAppendNote.id, {
|
||||
content: '\n\n---\n\n' + opts.content,
|
||||
mode: 'append',
|
||||
});
|
||||
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
|
||||
} else {
|
||||
const title = document.getElementById('saveNoteTitle').value.trim();
|
||||
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
||||
const folder = document.getElementById('saveNoteFolder').value.trim();
|
||||
const tagsStr = document.getElementById('saveNoteTags').value.trim();
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
|
||||
UI.toast('Note created', 'success');
|
||||
}
|
||||
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||
modal.remove();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
document.getElementById('saveNoteTitle')?.focus();
|
||||
document.getElementById('saveNoteTitle')?.select();
|
||||
},
|
||||
|
||||
// ── Compound onclick wrappers (for data-action dispatch) ──
|
||||
|
||||
_toggleNoteSelectCb(el, noteId) {
|
||||
this._toggleNoteSelect(noteId, el.checked);
|
||||
},
|
||||
|
||||
_noteItemClick(noteId) {
|
||||
if (this._notesSelectMode) {
|
||||
this._toggleNoteSelect(noteId);
|
||||
} else {
|
||||
this.openNoteEditor(noteId);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
_cleanup() {
|
||||
this._destroyNoteEditor();
|
||||
clearTimeout(this._searchTimer);
|
||||
},
|
||||
}, NotePanel);
|
||||
|
||||
NotePanel._register(id, instance);
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('NotePanel', NotePanel);
|
||||
Reference in New Issue
Block a user