// ============================================================ // 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 = '
Loading…
'; 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 = '
' + esc(err.message || 'Load failed') + '
'; }); } 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 = '
' + (_search ? 'No matches' : (_pane === 'trash' ? 'Trash is empty' : '')) + '
'; 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' ? '
' + '' + '' + '
' : ''; return '
' + '' + trashActions + '
'; }).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 === '

' || 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 || '

', { 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 || '

'); _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 ? ' Resume' : ' 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 = ''; _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() + '
'; _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 = '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; return '' + '
' + btns + '
' + ''; } 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, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } 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 {}; } })();