498 lines
19 KiB
JavaScript
498 lines
19 KiB
JavaScript
import * as NotesApi from './notes/api.js';
|
||
import { createNotesEditor } from './notes/editor.js';
|
||
import { renderNoteList, updateTrashCount as renderTrashCount } from './notes/list.js';
|
||
import { createNotesRecorder } from './notes/recorder.js';
|
||
import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
|
||
|
||
// ============================================================
|
||
// NOTES — per-user personal scratchpad under Clinical Tools.
|
||
// Rich-text via the app-wide Tiptap bundle (window.Tiptap).
|
||
// CRUD against /api/notes (auth + server-side encryption).
|
||
//
|
||
// Three panes — list / reader / editor. After opening or saving
|
||
// a note, the reader shows a clean, read-only rendering; tap
|
||
// Edit to switch to the Tiptap editor. Autosaves during edit
|
||
// (1.2s quiet period) so you never lose progress if you close
|
||
// the tab or the browser crashes.
|
||
//
|
||
// On mobile (<900px) only one pane is visible at a time — the
|
||
// layout's data-view attribute controls which one. Desktop
|
||
// always shows the sidebar + the right pane together.
|
||
// ============================================================
|
||
var _inited = false;
|
||
var _notes = []; // active notes (deleted_at IS NULL)
|
||
var _trash = []; // trashed notes (deleted_at IS NOT NULL)
|
||
var _pane = 'active'; // 'active' | 'trash' — drives the list view
|
||
var _activeId = null; // currently open note id (reader OR editor)
|
||
var _search = '';
|
||
|
||
// Autosave state
|
||
var _dirty = false;
|
||
var _autosaveTimer = null;
|
||
var _autosaveInFlight = false;
|
||
var _pendingAfterInflight = false;
|
||
var AUTOSAVE_DEBOUNCE = 1200;
|
||
|
||
var _statusTimer = null;
|
||
var _editor = createNotesEditor({
|
||
onDirty: function() { _dirty = true; },
|
||
onStatus: updateStatus,
|
||
onAutosave: scheduleAutosave
|
||
});
|
||
var _recorder = createNotesRecorder({
|
||
getEl: $,
|
||
updateStatus: updateStatus,
|
||
ensureEditor: function() { if ($('notes-editor').classList.contains('hidden')) startCreate(); },
|
||
noteFromVoice: NotesApi.noteFromVoice,
|
||
applyGeneratedNote: applyGeneratedNote,
|
||
markDirty: function() { _dirty = true; },
|
||
scheduleAutosave: scheduleAutosave
|
||
});
|
||
|
||
// Wait for the Notes tab to be activated — the component HTML is
|
||
// lazy-loaded, so elements don't exist until the user clicks the tab.
|
||
document.addEventListener('tabChanged', function(e) {
|
||
if (e.detail.tab === 'notes') {
|
||
if (_inited) { refreshList(); return; }
|
||
_inited = true;
|
||
init();
|
||
return;
|
||
}
|
||
// Leaving the Notes tab — cancel any in-flight recording so the
|
||
// mic doesn't stay live + the recorder doesn't keep buffering.
|
||
if (_recorder.isActive()) _recorder.stop(true);
|
||
flushAutosave();
|
||
});
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
function init() {
|
||
var newBtn = $('btn-notes-new');
|
||
var searchEl = $('notes-search');
|
||
var titleEl = $('note-title');
|
||
var listEl = $('notes-list');
|
||
if (!newBtn || !listEl) return;
|
||
|
||
// List + sidebar
|
||
newBtn.addEventListener('click', function() { startCreate(); });
|
||
searchEl.addEventListener('input', function() {
|
||
_search = searchEl.value.trim().toLowerCase();
|
||
renderList();
|
||
});
|
||
listEl.addEventListener('click', function(e) {
|
||
// Trash-view actions get handled by their own classes; route them
|
||
// first so a click on Restore or × doesn't also open the note.
|
||
var restoreBtn = e.target.closest('.notes-restore-btn');
|
||
if (restoreBtn) {
|
||
e.stopPropagation();
|
||
restoreNote(parseInt(restoreBtn.dataset.id));
|
||
return;
|
||
}
|
||
var hardBtn = e.target.closest('.notes-hard-delete-btn');
|
||
if (hardBtn) {
|
||
e.stopPropagation();
|
||
hardDeleteNote(parseInt(hardBtn.dataset.id));
|
||
return;
|
||
}
|
||
var item = e.target.closest('.notes-list-item');
|
||
if (!item) return;
|
||
var id = parseInt(item.dataset.id);
|
||
if (!id) return;
|
||
if (_pane === 'active') openReader(id);
|
||
// In trash, clicking the row body is a no-op — explicit Restore /
|
||
// delete-forever buttons are the only actions, so a stray click
|
||
// can't accidentally open a stale draft.
|
||
});
|
||
|
||
// Active <-> Trash tab toggle
|
||
var tabActive = $('btn-notes-tab-active');
|
||
var tabTrash = $('btn-notes-tab-trash');
|
||
if (tabActive) tabActive.addEventListener('click', function() { switchPane('active'); });
|
||
if (tabTrash) tabTrash.addEventListener('click', function() { switchPane('trash'); });
|
||
|
||
var emptyTrashBtn = $('btn-notes-trash-empty');
|
||
if (emptyTrashBtn) emptyTrashBtn.addEventListener('click', emptyTrash);
|
||
|
||
// Empty-state CTA
|
||
var newFromEmpty = $('btn-notes-new-empty');
|
||
if (newFromEmpty) newFromEmpty.addEventListener('click', function() { startCreate(); });
|
||
|
||
// Reader pane
|
||
var editBtn = $('btn-note-edit');
|
||
var delFromRead = $('btn-note-delete-read');
|
||
var backFromRead = $('btn-notes-reader-back');
|
||
if (editBtn) editBtn.addEventListener('click', function() { if (_activeId != null) openEditor(_activeId); });
|
||
if (delFromRead) delFromRead.addEventListener('click', deleteActive);
|
||
if (backFromRead) backFromRead.addEventListener('click', closeToList);
|
||
|
||
// Editor pane
|
||
var saveBtn = $('btn-note-save');
|
||
var delBtn = $('btn-note-delete');
|
||
var backFromEdit = $('btn-notes-editor-back');
|
||
if (saveBtn) saveBtn.addEventListener('click', function() { saveNote({ source: 'manual', returnToReader: true }); });
|
||
if (delBtn) delBtn.addEventListener('click', deleteActive);
|
||
if (backFromEdit) backFromEdit.addEventListener('click', backFromEditor);
|
||
|
||
titleEl.addEventListener('input', function() {
|
||
_dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave();
|
||
});
|
||
|
||
// Voice recording controls. addEventListener passes the click Event
|
||
// as the first argument — wrap so the truthy Event doesn't get passed
|
||
// to stopRecording(silent) and trigger the silent-cancel branch
|
||
// (which shuts the mic without transcribing → "Stop does nothing").
|
||
var recStart = $('btn-note-rec-start');
|
||
var recPause = $('btn-note-rec-pause');
|
||
var recStop = $('btn-note-rec-stop');
|
||
if (recStart) recStart.addEventListener('click', function() { _recorder.start(); });
|
||
if (recPause) recPause.addEventListener('click', function() { _recorder.togglePause(); });
|
||
if (recStop) recStop.addEventListener('click', function() { _recorder.stop(false); });
|
||
|
||
// Ctrl/Cmd+S still saves manually from the editor
|
||
document.addEventListener('keydown', function(e) {
|
||
var tab = $('notes-tab');
|
||
if (!tab || !tab.classList.contains('active')) return;
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
if (!$('notes-editor').classList.contains('hidden')) {
|
||
e.preventDefault();
|
||
saveNote({ source: 'shortcut', returnToReader: true });
|
||
}
|
||
}
|
||
});
|
||
|
||
// Persist dirty state on tab close / page unload — best-effort final flush.
|
||
// navigator.sendBeacon is POST-only so it can't drive a PUT update; fetch
|
||
// with keepalive:true is the right primitive here (works for both POST
|
||
// for new notes and PUT for existing).
|
||
window.addEventListener('beforeunload', function() {
|
||
if (!_dirty) return;
|
||
var title = getTitle();
|
||
if (!title) return;
|
||
var body = getBody();
|
||
var isNew = _activeId == null;
|
||
try {
|
||
NotesApi.saveNoteKeepalive(isNew ? null : _activeId, { title: title, body: body });
|
||
} catch (e) {}
|
||
});
|
||
|
||
refreshList();
|
||
setView('list');
|
||
}
|
||
|
||
// ── View management ──────────────────────────────────────
|
||
// On mobile (<900px), only one pane visible at a time, driven
|
||
// by the layout's data-view attribute.
|
||
function setView(view) {
|
||
var layout = $('notes-layout');
|
||
if (layout) layout.setAttribute('data-view', view);
|
||
|
||
var reader = $('notes-reader');
|
||
var editor = $('notes-editor');
|
||
var empty = $('notes-empty-state');
|
||
|
||
if (reader) reader.classList.toggle('hidden', view !== 'reader');
|
||
if (editor) editor.classList.toggle('hidden', view !== 'editor');
|
||
// Empty state is only the "no note picked" desktop placeholder
|
||
if (empty) empty.classList.toggle('hidden', view !== 'empty' && view !== 'list');
|
||
}
|
||
|
||
function closeToList() {
|
||
// Any unsaved edits are already autosaved, so no discard prompt needed.
|
||
flushAutosave();
|
||
_activeId = null;
|
||
_dirty = false;
|
||
_editor.destroy();
|
||
_recorder.stop(true); // silent cancel if in flight
|
||
setView('list');
|
||
renderList();
|
||
}
|
||
|
||
function backFromEditor() {
|
||
flushAutosave();
|
||
if (_activeId != null) openReader(_activeId);
|
||
else closeToList();
|
||
}
|
||
|
||
// ── Data fetching ─────────────────────────────────────────
|
||
// Always pulls both lists in parallel so the trash-count badge stays
|
||
// current regardless of which pane the user has open.
|
||
function refreshList() {
|
||
var listEl = $('notes-list');
|
||
if (!listEl) return;
|
||
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
|
||
Promise.all([
|
||
NotesApi.listActive(),
|
||
NotesApi.listTrash(),
|
||
])
|
||
.then(function(results) {
|
||
var active = results[0], trash = results[1];
|
||
if (active && active.success) _notes = active.notes || [];
|
||
if (trash && trash.success) _trash = trash.notes || [];
|
||
renderList();
|
||
updateTrashCount();
|
||
})
|
||
.catch(function(err) {
|
||
listEl.innerHTML = '<div class="notes-empty">' + esc(err.message || 'Load failed') + '</div>';
|
||
});
|
||
}
|
||
|
||
function switchPane(pane) {
|
||
if (pane === _pane) return;
|
||
_pane = pane;
|
||
var tabA = $('btn-notes-tab-active');
|
||
var tabT = $('btn-notes-tab-trash');
|
||
if (tabA) tabA.classList.toggle('active', pane === 'active');
|
||
if (tabT) tabT.classList.toggle('active', pane === 'trash');
|
||
var foot = $('notes-trash-foot');
|
||
if (foot) foot.classList.toggle('hidden', pane !== 'trash');
|
||
// Switching to trash clears any open editor — trashed notes are
|
||
// read-only previews, opening them is intentionally disabled.
|
||
if (pane === 'trash') closeToList();
|
||
renderList();
|
||
}
|
||
|
||
function updateTrashCount() {
|
||
renderTrashCount($('notes-trash-count'), _trash.length);
|
||
}
|
||
|
||
function renderList() {
|
||
renderNoteList($('notes-list'), {
|
||
pane: _pane,
|
||
notes: _notes,
|
||
trash: _trash,
|
||
search: _search,
|
||
activeId: _activeId
|
||
});
|
||
}
|
||
|
||
function restoreNote(id) {
|
||
NotesApi.restore(id)
|
||
.then(function(data) {
|
||
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Restore failed', 'error'); return; }
|
||
if (typeof showToast === 'function') showToast('Note restored', 'success');
|
||
refreshList();
|
||
})
|
||
.catch(function(err) {
|
||
if (typeof showToast === 'function') showToast(err.message || 'Restore failed', 'error');
|
||
});
|
||
}
|
||
|
||
function hardDeleteNote(id) {
|
||
if (typeof showConfirm !== 'function') return;
|
||
showConfirm('Delete forever? This cannot be undone.', function() {
|
||
NotesApi.hardDelete(id)
|
||
.then(function(data) {
|
||
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Delete failed', 'error'); return; }
|
||
refreshList();
|
||
});
|
||
}, { danger: true, confirmText: 'Delete forever' });
|
||
}
|
||
|
||
function emptyTrash() {
|
||
if (typeof showConfirm !== 'function') return;
|
||
if (_trash.length === 0) return;
|
||
showConfirm('Empty the trash? This will permanently delete ' + _trash.length + ' note' + (_trash.length === 1 ? '' : 's') + '.',
|
||
function() {
|
||
NotesApi.emptyTrash()
|
||
.then(function(data) {
|
||
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Empty trash failed', 'error'); return; }
|
||
if (typeof showToast === 'function') showToast('Trash emptied', 'success');
|
||
refreshList();
|
||
});
|
||
},
|
||
{ danger: true, confirmText: 'Empty trash' });
|
||
}
|
||
|
||
// ── Reader mode (default after opening or saving) ─────────
|
||
function openReader(id) {
|
||
flushAutosave();
|
||
if (_recorder.isActive()) _recorder.stop(true);
|
||
var note = _notes.find(function(n) { return n.id === id; });
|
||
if (!note) return;
|
||
_activeId = id;
|
||
_dirty = false;
|
||
|
||
// Tear down the editor if it was open
|
||
_editor.destroy();
|
||
|
||
$('notes-reader-title').textContent = note.title || 'Untitled';
|
||
$('notes-reader-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||
|
||
var body = $('notes-reader-body');
|
||
body.innerHTML = note.body ? sanitizeHtml(note.body) : '';
|
||
|
||
setView('reader');
|
||
renderList();
|
||
}
|
||
|
||
// ── Editor mode (create new / edit existing) ──────────────
|
||
function startCreate() {
|
||
flushAutosave();
|
||
if (_recorder.isActive()) _recorder.stop(true);
|
||
_activeId = null;
|
||
_dirty = false;
|
||
$('note-title').value = '';
|
||
$('note-meta').textContent = '';
|
||
$('btn-note-delete').style.display = 'none';
|
||
_editor.mount($('note-body-editor'), '');
|
||
updateStatus('', '');
|
||
_recorder.setUI('idle');
|
||
setView('editor');
|
||
setTimeout(function() { $('note-title').focus(); }, 20);
|
||
renderList();
|
||
}
|
||
|
||
function openEditor(id) {
|
||
var note = _notes.find(function(n) { return n.id === id; });
|
||
if (!note) return;
|
||
_activeId = id;
|
||
_dirty = false;
|
||
|
||
$('note-title').value = note.title || '';
|
||
$('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||
$('btn-note-delete').style.display = '';
|
||
_editor.mount($('note-body-editor'), note.body || '');
|
||
updateStatus('', '');
|
||
_recorder.setUI('idle');
|
||
setView('editor');
|
||
renderList();
|
||
}
|
||
|
||
function getTitle() { return ($('note-title').value || '').trim(); }
|
||
function getBody() {
|
||
return _editor.getBody();
|
||
}
|
||
|
||
// ── Save + autosave ──────────────────────────────────────
|
||
function scheduleAutosave() {
|
||
if (_autosaveTimer) clearTimeout(_autosaveTimer);
|
||
_autosaveTimer = setTimeout(function() {
|
||
_autosaveTimer = null;
|
||
saveNote({ source: 'auto' });
|
||
}, AUTOSAVE_DEBOUNCE);
|
||
}
|
||
|
||
function flushAutosave() {
|
||
if (_autosaveTimer) { clearTimeout(_autosaveTimer); _autosaveTimer = null; }
|
||
// Flush dirty state regardless of whether the note is new or existing.
|
||
// saveNote() handles both paths (POST for new, PUT for existing), so
|
||
// gating on _activeId here silently dropped the very first flush for
|
||
// a brand-new note — e.g. tap New note → type → tap Back.
|
||
if (_dirty) saveNote({ source: 'flush' });
|
||
}
|
||
|
||
function saveNote(opts) {
|
||
opts = opts || {};
|
||
var title = getTitle();
|
||
var body = getBody();
|
||
|
||
// Title is required — but for autosave, don't nag every keystroke.
|
||
if (!title) {
|
||
if (opts.source === 'manual' || opts.source === 'shortcut') {
|
||
updateStatus('Title required', 'err');
|
||
$('note-title').focus();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (_autosaveInFlight) {
|
||
// Another save is already pending the network — mark so we
|
||
// fire one more right after it completes to capture the newest
|
||
// state. Prevents "the last keystroke didn't save" races.
|
||
_pendingAfterInflight = true;
|
||
return;
|
||
}
|
||
|
||
var isNew = _activeId == null;
|
||
|
||
_autosaveInFlight = true;
|
||
updateStatus('Saving…', 'saving');
|
||
|
||
NotesApi.saveNote(isNew ? null : _activeId, { title: title, body: body })
|
||
.then(function(data) {
|
||
_autosaveInFlight = false;
|
||
if (!data.success) { updateStatus(data.error || 'Save failed', 'err'); return; }
|
||
if (isNew && data.id) _activeId = data.id;
|
||
_dirty = false;
|
||
updateStatus('Saved', 'ok');
|
||
|
||
// Refresh the list so the new/updated note appears with correct
|
||
// timestamps + ordering.
|
||
return NotesApi.listActive()
|
||
.then(function(d) {
|
||
if (d.success) _notes = d.notes || [];
|
||
var note = _notes.find(function(n) { return n.id === _activeId; });
|
||
if (note) {
|
||
$('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||
}
|
||
renderList();
|
||
})
|
||
.then(function() {
|
||
// If the user typed more while the save was in flight, fire once more.
|
||
if (_pendingAfterInflight) {
|
||
_pendingAfterInflight = false;
|
||
_dirty = true;
|
||
scheduleAutosave();
|
||
} else if (opts.returnToReader) {
|
||
// Manual save → show the reader view with the saved content.
|
||
openReader(_activeId);
|
||
}
|
||
});
|
||
})
|
||
.catch(function(err) {
|
||
_autosaveInFlight = false;
|
||
updateStatus(err.message || 'Save failed', 'err');
|
||
});
|
||
}
|
||
|
||
// ── Delete ──────────────────────────────────────────────
|
||
function deleteActive() {
|
||
if (_activeId == null) return;
|
||
var note = _notes.find(function(n) { return n.id === _activeId; });
|
||
var label = (note && note.title) ? note.title : 'this note';
|
||
|
||
// Soft-delete: row gets deleted_at timestamp and moves to Trash.
|
||
// The user can Restore from there or Empty trash to actually erase.
|
||
var doMove = function() {
|
||
flushAutosave();
|
||
NotesApi.moveToTrash(_activeId)
|
||
.then(function(data) {
|
||
if (!data.success) { updateStatus(data.error || 'Move to trash failed', 'err'); return; }
|
||
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
|
||
_activeId = null;
|
||
_dirty = false;
|
||
_editor.destroy();
|
||
closeToList();
|
||
if (typeof showToast === 'function') showToast('Moved to trash', 'success');
|
||
refreshList();
|
||
})
|
||
.catch(function(err) { updateStatus(err.message || 'Move to trash failed', 'err'); });
|
||
};
|
||
|
||
if (typeof showConfirm === 'function') {
|
||
showConfirm('Move "' + label + '" to trash?', doMove, { confirmText: 'Move to trash' });
|
||
}
|
||
}
|
||
|
||
function applyGeneratedNote(title, body) {
|
||
var titleEl = $('note-title');
|
||
var container = $('note-body-editor');
|
||
if (!titleEl || !container) {
|
||
console.error('[notes] applyGeneratedNote: editor not mounted; title/container missing');
|
||
return;
|
||
}
|
||
titleEl.value = title || 'Voice note';
|
||
_editor.applyGenerated(container, body);
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────
|
||
function updateStatus(text, kind) {
|
||
var el = $('notes-status');
|
||
if (!el) return;
|
||
el.textContent = text || '';
|
||
el.className = 'notes-status' + (kind ? ' notes-status-' + kind : '');
|
||
if (_statusTimer) { clearTimeout(_statusTimer); _statusTimer = null; }
|
||
if (kind === 'ok') {
|
||
_statusTimer = setTimeout(function() { el.textContent = ''; el.className = 'notes-status'; }, 2000);
|
||
}
|
||
}
|