pediatric-ai-scribe-v3/public/js/notes.js
Daniel 67b7667e04 feat: ED encounters + notes model selector; remove AI corrections; fix notes framing
Four changes batched:

1. ED Encounters tab (new) — multi-stage emergency note with don't-miss
   tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters
   (generate per-stage + finalize MDM), new ed-encounters.js owning all
   client logic, new ed-encounter.html component, new template_ed memory
   category. Persists draft to localStorage every keystroke and to
   saved_encounters on stage advance. encounters.js touched only to
   register the new tab in sessionStorage restore + tabMap (save and
   idempotency code untouched).

2. Notes model selector — /notes/from-voice now accepts a client-supplied
   model (validated by the existing callAI allow-list); falls back to the
   admin default. Added <select class="tab-model-select"> to notes.html
   so the existing app.js populator handles options + default.

3. Remove AI-learning-from-corrections — deleted correctionTracker.js,
   POST /memories/correction, the corrections branch in
   /memories/context, the settings UI section, the FAQ entry, and all
   dead trackAIOutput/saveCorrection guards in callers. Legacy
   correction_* DB rows are filtered (NOT LIKE) rather than dropped, so
   no destructive migration.

4. Fix notes AI framing — /notes/from-voice prompt no longer assumes
   "physician dictation". Plain notes (shopping lists, reminders,
   ideas) now match the dictation tone instead of being forced into
   clinical structure.

All 46 tests pass.
2026-04-28 03:09:38 +02:00

890 lines
37 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.

// ============================================================
// 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.
// ============================================================
(function() {
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 _editor = null; // Tiptap instance
var _search = '';
// Autosave state
var _dirty = false;
var _autosaveTimer = null;
var _autosaveInFlight = false;
var _pendingAfterInflight = false;
var AUTOSAVE_DEBOUNCE = 1200;
// Voice recording state
var _recorder = null;
var _recTimer = null;
var _recPaused = false;
var _recActive = false;
var _statusTimer = null;
// 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 (_recActive) stopRecording(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() { startRecording(); });
if (recPause) recPause.addEventListener('click', function() { togglePauseRecording(); });
if (recStop) recStop.addEventListener('click', function() { stopRecording(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;
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
var method = isNew ? 'POST' : 'PUT';
try {
fetch(url, {
method: method,
keepalive: true,
credentials: 'include',
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
body: JSON.stringify({ 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;
if (_editor) { _editor.destroy(); _editor = null; }
stopRecording(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([
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
fetch('/api/notes/trash', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
])
.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() {
var el = $('notes-trash-count');
if (!el) return;
el.textContent = _trash.length ? '(' + _trash.length + ')' : '';
}
function renderList() {
var listEl = $('notes-list');
if (!listEl) return;
var source = _pane === 'trash' ? _trash : _notes;
var filtered = source;
if (_search) {
filtered = source.filter(function(n) {
var hay = (n.title + ' ' + stripTags(n.body || '')).toLowerCase();
return hay.indexOf(_search) !== -1;
});
}
if (filtered.length === 0) {
listEl.innerHTML = '<div class="notes-empty">'
+ (_search ? 'No matches' : (_pane === 'trash' ? 'Trash is empty' : ''))
+ '</div>';
return;
}
listEl.innerHTML = filtered.map(function(n) {
var snippet = stripTags(n.body || '').substring(0, 110);
var when = formatWhen(_pane === 'trash' ? n.deleted_at : n.updated_at);
var active = (n.id === _activeId && _pane === 'active') ? ' active' : '';
var trashActions = _pane === 'trash'
? '<div class="notes-trash-actions">'
+ '<button type="button" class="notes-restore-btn" data-id="' + n.id + '" title="Restore">'
+ '<i class="fas fa-rotate-left"></i> Restore'
+ '</button>'
+ '<button type="button" class="notes-hard-delete-btn" data-id="' + n.id + '" title="Delete forever">'
+ '<i class="fas fa-times"></i>'
+ '</button>'
+ '</div>'
: '';
return '<div class="notes-list-row">'
+ '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
+ '<div class="notes-list-when">'
+ (_pane === 'trash' ? 'Deleted ' : '') + esc(when)
+ '</div>'
+ '</button>'
+ trashActions
+ '</div>';
}).join('');
}
function restoreNote(id) {
fetch('/api/notes/' + id + '/restore', {
method: 'POST', credentials: 'include', headers: getAuthHeaders(),
})
.then(function(r) { return r.json(); })
.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() {
fetch('/api/notes/' + id + '?hard=1', {
method: 'DELETE', credentials: 'include', headers: getAuthHeaders(),
})
.then(function(r) { return r.json(); })
.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() {
fetch('/api/notes/trash/empty', {
method: 'POST', credentials: 'include', headers: getAuthHeaders(),
})
.then(function(r) { return r.json(); })
.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 (_recActive) stopRecording(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
if (_editor) { _editor.destroy(); _editor = null; }
$('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 (_recActive) stopRecording(true);
_activeId = null;
_dirty = false;
$('note-title').value = '';
$('note-meta').textContent = '';
$('btn-note-delete').style.display = 'none';
mountTiptap($('note-body-editor'), '');
updateStatus('', '');
setRecUI('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 = '';
mountTiptap($('note-body-editor'), note.body || '');
updateStatus('', '');
setRecUI('idle');
setView('editor');
renderList();
}
function getTitle() { return ($('note-title').value || '').trim(); }
function getBody() {
if (!_editor) {
// Fallback textarea if Tiptap didn't load
var fb = document.querySelector('.notes-body-fallback');
return fb ? fb.value : '';
}
var html = _editor.getHTML();
return (html === '<p></p>' || html === '') ? '' : html;
}
// ── 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;
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
var method = isNew ? 'POST' : 'PUT';
_autosaveInFlight = true;
updateStatus('Saving…', 'saving');
fetch(url, {
method: method,
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
credentials: 'include',
body: JSON.stringify({ title: title, body: body }),
})
.then(function(r) { return r.json(); })
.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 fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
.then(function(r) { return r.json(); })
.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();
fetch('/api/notes/' + _activeId, { method: 'DELETE', headers: getAuthHeaders(), credentials: 'include' })
.then(function(r) { return r.json(); })
.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;
if (_editor) { _editor.destroy(); _editor = null; }
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' });
}
}
// ── Voice recording → AI note ──────────────────────────────
function startRecording() {
if (_recActive) return;
// Ensure the editor is open — voice always targets the editor
if ($('notes-editor').classList.contains('hidden')) startCreate();
if (typeof AudioRecorder === 'undefined') { updateStatus('Recorder unavailable', 'err'); return; }
_recorder = new AudioRecorder();
_recorder.start().then(function() {
_recActive = true; _recPaused = false;
setRecUI('recording');
startRecTimer();
}).catch(function(err) {
updateStatus('Mic denied: ' + (err && err.message || ''), 'err');
});
}
function togglePauseRecording() {
if (!_recActive || !_recorder || !_recorder.mediaRecorder) return;
if (!_recPaused) {
try { _recorder.mediaRecorder.pause(); } catch (e) {}
_recPaused = true; stopRecTimer(); setRecUI('paused');
} else {
try { _recorder.mediaRecorder.resume(); } catch (e) {
// Browser doesn't support resume — start a fresh segment.
try {
if (_recorder.stream && _recorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
_recorder.mediaRecorder = new MediaRecorder(_recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
_recorder.mediaRecorder.ondataavailable = function(ev) { if (ev.data.size > 0) _recorder.chunks.push(ev.data); };
_recorder.mediaRecorder.start(1000);
}
} catch (e2) {}
}
_recPaused = false; startRecTimer(); setRecUI('recording');
}
}
function stopRecording(silent) {
if (!_recActive || !_recorder) {
if (!silent) setRecUI('idle');
return;
}
_recActive = false; _recPaused = false;
stopRecTimer(true);
if (silent) {
// Cancel cleanly — close the stream, don't transcribe.
try { _recorder.mediaRecorder && _recorder.mediaRecorder.state !== 'inactive' && _recorder.mediaRecorder.stop(); } catch (e) {}
try { _recorder.stream && _recorder.stream.getTracks().forEach(function(t) { t.stop(); }); } catch (e) {}
setRecUI('idle');
return;
}
setRecUI('processing');
updateStatus('Transcribing…', 'saving');
_recorder.stop().then(function(blob) {
if (!blob || blob.size === 0) { updateStatus('Nothing recorded', 'err'); setRecUI('idle'); return; }
if (typeof transcribeAudio !== 'function') { updateStatus('Transcription unavailable', 'err'); setRecUI('idle'); return; }
return transcribeAudio(blob).then(function(resp) {
if (!resp || !resp.success || !resp.text) {
var msg = (resp && (resp.error || (resp.noProvider ? 'No STT provider configured' : null))) || 'Transcription failed';
updateStatus(msg, 'err'); setRecUI('idle'); return;
}
updateStatus('Generating note…', 'saving');
var modelEl = document.getElementById('notes-model-select');
var selectedModel = modelEl ? modelEl.value : '';
return fetch('/api/notes/from-voice', {
method: 'POST',
credentials: 'include',
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
body: JSON.stringify({ transcript: resp.text, model: selectedModel || undefined }),
})
.then(function(r) { return r.json(); })
.then(function(data) {
setRecUI('idle');
if (!data.success) { updateStatus(data.error || 'Generation failed', 'err'); return; }
applyGeneratedNote(data.title || 'Voice note', data.body || '');
updateStatus('Generated — autosaving…', 'ok');
// Trigger the autosave so the AI-generated note is persisted
// even if the user walks away.
_dirty = true; scheduleAutosave();
});
});
}).catch(function(err) {
setRecUI('idle');
updateStatus(err.message || 'Recording failed', 'err');
});
}
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';
// Prefer Tiptap's native commands.setContent over a remount when an
// editor already exists — remounting on every voice take left the
// toolbar reattaching, which has caused flicker + a brief window
// where the body looked empty.
if (_editor && typeof _editor.commands === 'object') {
try {
_editor.commands.setContent(body || '<p></p>', { emitUpdate: true });
_dirty = true;
return;
} catch (err) {
console.warn('[notes] Tiptap setContent failed, rebuilding:', err && err.message);
}
}
if (_editor) { try { _editor.destroy(); } catch (e) {} _editor = null; }
mountTiptap(container, body || '<p></p>');
_dirty = true;
}
function startRecTimer() {
var el = $('notes-rec-timer');
if (_recTimer || !el) return;
_recTimer = createTimerLite(el);
_recTimer.start();
}
function stopRecTimer(reset) {
if (_recTimer) _recTimer.stop();
if (reset && _recTimer) { _recTimer.reset(); _recTimer = null; }
}
function createTimerLite(el) {
var s = 0, iv = null;
function paint() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); }
return {
start: function() { paint(); if (!iv) iv = setInterval(function() { s++; paint(); }, 1000); },
stop: function() { if (iv) { clearInterval(iv); iv = null; } },
reset: function() { s = 0; paint(); },
};
}
function setRecUI(state) {
var start = $('btn-note-rec-start');
var pause = $('btn-note-rec-pause');
var stopBtn = $('btn-note-rec-stop');
var ind = $('notes-rec-indicator');
var stateEl = $('notes-rec-state');
var dot = ind ? ind.querySelector('.pulse-dot') : null;
if (!start) return;
var idle = (state === 'idle');
var recording = (state === 'recording');
var paused = (state === 'paused');
var processing = (state === 'processing');
start.classList.toggle('hidden', !idle);
pause.classList.toggle('hidden', !(recording || paused));
stopBtn.classList.toggle('hidden', !(recording || paused));
ind.classList.toggle('hidden', idle);
if (pause) pause.innerHTML = paused ? '<i class="fas fa-play"></i> Resume' : '<i class="fas fa-pause"></i> Pause';
if (stateEl) stateEl.textContent = processing ? 'Processing…' : (paused ? 'Paused' : 'Recording');
if (dot) dot.classList.toggle('paused', paused || processing);
start.disabled = !idle;
pause.disabled = processing;
stopBtn.disabled = processing;
}
// ── Tiptap ─────────────────────────────────────────────
function mountTiptap(container, initialHtml) {
var T = window.Tiptap || {};
if (!T.Editor) {
// Fallback when the Tiptap bundle didn't load — plain textarea.
container.innerHTML = '<textarea class="notes-body-fallback" placeholder="Write your note…">' + esc(stripTags(initialHtml)) + '</textarea>';
_editor = null;
var fb = container.querySelector('.notes-body-fallback');
if (fb) fb.addEventListener('input', function() { _dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave(); });
return;
}
container.innerHTML = toolbarHTML() + '<div class="tp-content"></div>';
_editor = new T.Editor({
element: container.querySelector('.tp-content'),
extensions: [
T.StarterKit,
T.Link.configure({ openOnClick: false, autolink: true }),
T.Underline
],
content: initialHtml || '',
autofocus: false,
onUpdate: function() {
_dirty = true;
updateStatus('Saving…', 'saving');
updateToolbarState(container);
scheduleAutosave();
},
onSelectionUpdate: function() { updateToolbarState(container); }
});
wireToolbar(container, _editor);
}
function toolbarHTML() {
var btns = ''
+ '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>'
+ '<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>'
+ '<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>'
+ '<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>'
+ '<span class="tp-sep"></span>'
+ '<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>'
+ '<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>'
+ '<span class="tp-sep"></span>'
+ '<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet list"><i class="fas fa-list-ul"></i></button>'
+ '<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered list"><i class="fas fa-list-ol"></i></button>'
+ '<button type="button" class="tp-btn" data-cmd="blockquote" title="Quote"><i class="fas fa-quote-left"></i></button>'
+ '<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>'
+ '<span class="tp-sep"></span>'
+ '<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>'
+ '<span class="tp-sep"></span>'
+ '<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
return ''
+ '<div class="tp-toolbar">' + btns + '</div>'
+ '<div class="tp-link-bar" style="display:none;">'
+ '<input type="url" class="tp-link-input" placeholder="https://">'
+ '<button type="button" class="tp-link-apply">Apply</button>'
+ '<button type="button" class="tp-link-remove">Remove</button>'
+ '<button type="button" class="tp-link-cancel">✕</button>'
+ '</div>';
}
function wireToolbar(wrap, ed) {
var toolbar = wrap.querySelector('.tp-toolbar');
var linkBar = wrap.querySelector('.tp-link-bar');
var linkInput = wrap.querySelector('.tp-link-input');
toolbar.addEventListener('mousedown', function(e) {
var btn = e.target.closest('.tp-btn[data-cmd]');
if (!btn) return;
e.preventDefault();
var cmd = btn.dataset.cmd;
switch (cmd) {
case 'bold': ed.chain().focus().toggleBold().run(); break;
case 'italic': ed.chain().focus().toggleItalic().run(); break;
case 'underline': ed.chain().focus().toggleUnderline().run(); break;
case 'strike': ed.chain().focus().toggleStrike().run(); break;
case 'h2': ed.chain().focus().toggleHeading({ level: 2 }).run(); break;
case 'h3': ed.chain().focus().toggleHeading({ level: 3 }).run(); break;
case 'bulletList': ed.chain().focus().toggleBulletList().run(); break;
case 'orderedList': ed.chain().focus().toggleOrderedList().run(); break;
case 'blockquote': ed.chain().focus().toggleBlockquote().run(); break;
case 'codeBlock': ed.chain().focus().toggleCodeBlock().run(); break;
case 'clear': ed.chain().focus().unsetAllMarks().clearNodes().run(); break;
case 'link':
if (linkBar.style.display === 'none') {
linkInput.value = ed.getAttributes('link').href || '';
linkBar.style.display = 'flex';
setTimeout(function() { linkInput.focus(); }, 0);
} else {
linkBar.style.display = 'none';
}
break;
}
updateToolbarState(wrap);
});
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
e.preventDefault();
var url = linkInput.value.trim();
if (url) ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
linkBar.style.display = 'none';
});
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
e.preventDefault();
ed.chain().focus().unsetLink().run();
linkBar.style.display = 'none';
});
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
e.preventDefault();
linkBar.style.display = 'none';
});
linkInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
if (e.key === 'Escape') { linkBar.style.display = 'none'; }
});
}
function updateToolbarState(wrap) {
if (!_editor) return;
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
var cmd = btn.dataset.cmd;
var active = false;
if (cmd === 'bold') active = _editor.isActive('bold');
else if (cmd === 'italic') active = _editor.isActive('italic');
else if (cmd === 'underline') active = _editor.isActive('underline');
else if (cmd === 'strike') active = _editor.isActive('strike');
else if (cmd === 'h2') active = _editor.isActive('heading', { level: 2 });
else if (cmd === 'h3') active = _editor.isActive('heading', { level: 3 });
else if (cmd === 'bulletList') active = _editor.isActive('bulletList');
else if (cmd === 'orderedList') active = _editor.isActive('orderedList');
else if (cmd === 'blockquote') active = _editor.isActive('blockquote');
else if (cmd === 'codeBlock') active = _editor.isActive('codeBlock');
else if (cmd === 'link') active = _editor.isActive('link');
btn.classList.toggle('active', active);
});
}
// ── 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);
}
}
function esc(s) {
return (s == null ? '' : String(s))
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function stripTags(html) {
if (!html) return '';
var d = document.createElement('div'); d.innerHTML = html;
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
}
// Sanitize via DOMPurify (loaded from cdnjs in index.html, also used by
// learningHub.js). DOMPurify's allowlist + attribute filter is the right
// primitive — homegrown regex/walker sanitizers historically have bypasses.
// If DOMPurify somehow fails to load, refuse to render HTML rather than
// falling back to a hand-rolled walker.
function sanitizeHtml(html) {
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
console.warn('[notes] DOMPurify unavailable — rendering as plain text.');
var d = document.createElement('div');
d.textContent = String(html == null ? '' : html);
return d.innerHTML;
}
return window.DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','strong','em','b','i','u','s','h2','h3','h4',
'ul','ol','li','a','blockquote','code','pre','hr'],
ALLOWED_ATTR: ['href','target','rel'],
ADD_ATTR: ['target'],
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
ALLOW_DATA_ATTR: false
});
}
function formatWhen(iso) {
if (!iso) return '';
var d = new Date(iso);
if (isNaN(d.getTime())) return '';
var now = new Date();
var sameDay = d.toDateString() === now.toDateString();
if (sameDay) return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
}
function getAuthHeaders() {
if (typeof window.getAuthHeaders === 'function') return window.getAuthHeaders();
return {};
}
})();