pediatric-ai-scribe-v3/public/js/notes.js
2026-05-08 05:30:32 +02:00

498 lines
19 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.

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