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-panel.js
Jeffrey Smith 071dea8904 Changeset 0.31.0 (#203)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-19 00:06:16 +00:00

977 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// 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,
_page: 1,
_pageSize: 50,
_hasMore: false,
// ── DOM query helper ──
$(sel) { return this.container.querySelector(sel); },
$$(sel) { return this.container.querySelectorAll(sel); },
// ── Notes List ──
async loadNotesList(folder, searchQuery, append) {
const list = this.$('#notesList');
if (!list) return;
if (!append) {
list.innerHTML = '<div class="loading">Loading\u2026</div>';
this._page = 1;
}
try {
let notes, isSearch = false;
if (searchQuery) {
isSearch = true;
const data = await API.searchNotes(searchQuery);
notes = data.data || [];
this._hasMore = false;
} else {
const folderVal = folder || this.$('#notesFolderFilter')?.value || '';
const offset = (this._page - 1) * this._pageSize;
const data = await API.listNotes(this._pageSize, offset, folderVal, '', this._notesSort);
notes = data.data || [];
this._hasMore = notes.length >= this._pageSize;
}
if (!append) list.innerHTML = '';
if (notes.length === 0 && !append) {
const folderVal = this.$('#notesFolderFilter')?.value || '';
list.innerHTML = `<div class="empty-hint">${isSearch ? 'No results found' : (folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.')}</div>`;
return;
}
// Remove old "Load more" button if present
list.querySelector('.notes-load-more')?.remove();
list.insertAdjacentHTML('beforeend', notes.map(n => this._noteListItem(n, isSearch)).join(''));
// Pagination: "Load more" button
if (this._hasMore) {
const btn = document.createElement('button');
btn.className = 'btn-small notes-load-more';
btn.textContent = 'Load more\u2026';
btn.style.cssText = 'display:block;margin:8px auto;';
btn.addEventListener('click', () => {
this._page++;
this.loadNotesList(null, null, true);
});
list.appendChild(btn);
}
} catch (e) {
if (!append) 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;
},
};
// ── Self-mount: build DOM + create + wire in one call ──
// Canonical entry point for all consumers (notes.js sidebar, editor package, sw.notes()).
// Uses the same IDs that NotePanel.create() expects — $() is container-scoped so no collisions.
NotePanel.mount = function (container, opts) {
const _opts = opts || {};
const el = document.createElement('div');
el.className = 'note-panel-root';
el.innerHTML =
'<!-- List view -->' +
'<div id="notesListView">' +
'<div class="notes-toolbar">' +
'<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>' +
'<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>' +
'<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>' +
'<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>' +
'</div>' +
'<div class="notes-search-row">' +
'<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">' +
'</div>' +
'<div class="notes-filter-row">' +
'<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>' +
'<select id="notesSortSelect" class="notes-filter-select">' +
'<option value="updated_desc">Last updated</option>' +
'<option value="created_desc">Created</option>' +
'<option value="alpha">Alphabetical</option>' +
'</select>' +
'</div>' +
'<div id="notesList" class="notes-list"></div>' +
'<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">' +
'<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>' +
'<span id="notesSelectedCount">0</span> selected' +
'<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>' +
'<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>' +
'</div>' +
'</div>' +
'<!-- Editor view -->' +
'<div id="notesEditorView" style="display:none;">' +
'<div class="notes-editor-header">' +
'<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
'<div style="flex:1"></div>' +
'<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>' +
'<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>' +
'<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>' +
'<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>' +
'<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>' +
'</div>' +
'<div id="noteEditMode" class="notes-editor">' +
'<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">' +
'<div class="notes-meta-row">' +
'<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">' +
'<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">' +
'</div>' +
'<div id="noteEditorContentContainer" class="notes-content-container">' +
'<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>' +
'</div>' +
'</div>' +
'<div id="noteReadMode" style="display:none;">' +
'<div class="notes-read-view">' +
'<h3 id="noteReadTitle" class="note-read-title"></h3>' +
'<div id="noteReadMeta" class="note-read-meta"></div>' +
'<div id="noteReadContent" class="note-read-content msg-content"></div>' +
'<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>' +
'</div>' +
'</div>' +
'<div id="noteBacklinks" class="note-backlinks" style="display:none;">' +
'<div class="note-backlinks-header" data-action="toggleBacklinks">' +
'Backlinks <span id="noteBacklinksCount" class="badge">0</span>' +
'</div>' +
'<div id="noteBacklinksList" class="note-backlinks-list"></div>' +
'</div>' +
'</div>' +
'<!-- Graph view -->' +
'<div id="notesGraphView" class="notes-graph-view" style="display:none;">' +
'<div class="notes-graph-toolbar">' +
'<button id="notesGraphBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
'<span class="notes-graph-stats">' +
'<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links' +
'</span>' +
'<div style="flex:1"></div>' +
'<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>' +
'<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>' +
'</div>' +
'<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>' +
'</div>';
container.appendChild(el);
// Create NotePanel instance — $() is container-scoped, so the IDs above "just work"
const np = this.create({ container: el, projectId: _opts.projectId || null });
// ── Wire all events (same as notes.js _registerNotesPanel) ──
const q = (sel) => el.querySelector(sel);
q('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
q('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
q('#notesGraphBtn').addEventListener('click', () => {
np.showGraph();
if (typeof openNoteGraph === 'function') openNoteGraph();
if (_opts.onOpenGraph) _opts.onOpenGraph();
});
q('#notesGraphBackBtn').addEventListener('click', () => {
np.showNotesList();
if (typeof closeNoteGraph === 'function') closeNoteGraph();
});
q('#notesSelectModeBtn').addEventListener('click', () => {
if (np._notesSelectMode) np._exitSelectMode();
else np._enterSelectMode();
});
q('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
q('#noteSaveBtn').addEventListener('click', () => np.saveNote());
q('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
q('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
q('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
q('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
q('#noteCancelEditBtn').addEventListener('click', () => {
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
else np.showNotesList();
});
q('#notesFolderFilter').addEventListener('change', (e) => {
np.$('#notesSearchInput').value = '';
np._exitSelectMode();
np.loadNotesList(e.target.value);
});
q('#notesSortSelect').addEventListener('change', (e) => {
np._notesSort = e.target.value;
np._exitSelectMode();
np.loadNotesList();
});
q('#notesSelectAll').addEventListener('change', (e) => np._toggleSelectAll(e.target.checked));
q('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
q('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
let _searchTimer;
q('#notesSearchInput').addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const query = e.target.value.trim();
_searchTimer = setTimeout(() => {
np._exitSelectMode();
if (query.length >= 2) {
np.$('#notesFolderFilter').value = '';
np.loadNotesList(null, query);
} else if (query.length === 0) {
np.loadNotesList();
}
}, 300);
});
// Add showGraph view switcher
np.showGraph = function () {
const lv = np.$('#notesListView');
const ev = np.$('#notesEditorView');
const gv = np.$('#notesGraphView');
if (lv) lv.style.display = 'none';
if (ev) ev.style.display = 'none';
if (gv) gv.style.display = '';
};
return np;
};
// ── Exports ─────────────────────────────────
sb.ns('NotePanel', NotePanel);