pediatric-ai-scribe-v3/public/js/notes.js
Daniel 9bb4879be1 feat(notes): read-mode default + Edit toggle + autosave + mobile layout
Four changes landing together so the UX flows properly.

1. Fix voice-generation model error.
   /api/notes/from-voice was passing `req.body.model` (undefined)
   through to callAI, which resolved to LITELLM_DEFAULT_MODEL (empty
   for LiteLLM deployments with no explicit default) → `model=""` →
   LiteLLM 400 "Invalid model name". Server now reads the admin-
   configured `models.default` setting, falls back to the
   LITELLM_DEFAULT_MODEL env var, and only passes a model if one
   resolved. Empty string never reaches the provider.

2. Read mode as the default post-save.
   Opening an existing note or saving a new one now lands on a
   clean, read-only rendering of the body (sanitized HTML with an
   allowlist — p/h2/h3/strong/em/u/s/a/ul/ol/li/blockquote/code/pre).
   Click the "Edit" button to switch to the Tiptap editor. Matches
   the mental model of "notes are documents I review, not drafts I'm
   always editing."

3. Autosave during edit.
   Title-input + Tiptap onUpdate trigger a 1.2-second-debounced save.
   In-flight saves coalesce: if the user types more while a save is
   in progress, a follow-up fires right after it lands so the last
   keystroke never gets stranded. beforeunload uses navigator.
   sendBeacon to best-effort flush on tab close. The manual Save
   button is kept as a "save + switch to reader" shortcut. Ctrl/
   Cmd+S still works in the editor.

4. Mobile layout.
   The two-pane layout collapses to a single-pane view below 900px,
   driven by a data-view attribute on .notes-layout. Back buttons
   appear in the reader + editor heads on mobile to return to the
   list. Desktop layout unchanged (sidebar + right pane always
   visible).

Also:
  • CSS specificity fix — .hidden{display:none} was losing to
    .notes-rec-indicator{display:inline-flex} on source order, so
    the "Recording" indicator was showing when idle. Added explicit
    .notes-rec-indicator.hidden + .notes-voice-bar .btn-sm.hidden
    overrides at higher specificity.
  • Reader-body sanitizer — allowlist of safe tags + attributes;
    <a> links get target=_blank + rel=noopener.
  • SW cache bumped to pedscribe-v12-notes2 so clients pick up the
    new module / component / CSS.
2026-04-24 12:45:30 +02:00

738 lines
30 KiB
JavaScript

// ============================================================
// 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 = [];
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') return;
if (_inited) { refreshList(); return; }
_inited = true;
init();
});
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) {
var item = e.target.closest('.notes-list-item');
if (!item) return;
var id = parseInt(item.dataset.id);
if (id) openReader(id);
});
// 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
var recStart = $('btn-note-rec-start');
var recPause = $('btn-note-rec-pause');
var recStop = $('btn-note-rec-stop');
if (recStart) recStart.addEventListener('click', startRecording);
if (recPause) recPause.addEventListener('click', togglePauseRecording);
if (recStop) recStop.addEventListener('click', stopRecording);
// 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
window.addEventListener('beforeunload', function() {
if (_dirty && _activeId != null) {
// Use sendBeacon for a guaranteed best-effort send
try {
var payload = JSON.stringify({ title: getTitle(), body: getBody() });
var headers = { type: 'application/json' };
navigator.sendBeacon && navigator.sendBeacon('/api/notes/' + _activeId, new Blob([payload], headers));
} 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 ─────────────────────────────────────────
function refreshList() {
var listEl = $('notes-list');
if (!listEl) return;
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { listEl.innerHTML = '<div class="notes-empty">' + esc(data.error || 'Failed to load') + '</div>'; return; }
_notes = data.notes || [];
renderList();
})
.catch(function(err) {
listEl.innerHTML = '<div class="notes-empty">' + esc(err.message || 'Load failed') + '</div>';
});
}
function renderList() {
var listEl = $('notes-list');
if (!listEl) return;
var filtered = _notes;
if (_search) {
filtered = _notes.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 notes match "' + esc(_search) + '".'
: 'No notes yet. Tap <strong>New note</strong> to create one.')
+ '</div>';
return;
}
listEl.innerHTML = filtered.map(function(n) {
var snippet = stripTags(n.body || '').substring(0, 110);
var when = formatWhen(n.updated_at);
var active = (n.id === _activeId) ? ' active' : '';
return '<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">' + esc(when) + '</div>'
+ '</button>';
}).join('');
}
// ── Reader mode (default after opening or saving) ─────────
function openReader(id) {
flushAutosave();
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) : '<p style="color:var(--g400);font-style:italic;">This note is empty. Tap Edit to add content.</p>';
setView('reader');
renderList();
}
// ── Editor mode (create new / edit existing) ──────────────
function startCreate() {
flushAutosave();
_activeId = null;
_dirty = false;
$('note-title').value = '';
$('note-meta').textContent = 'New note — will save automatically once you type.';
$('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; }
if (_dirty && _activeId != null) {
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(opts.source === 'auto' ? 'Saving…' : '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';
var doDelete = 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 || 'Delete 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('Note deleted', 'success');
})
.catch(function(err) { updateStatus(err.message || 'Delete failed', 'err'); });
};
if (typeof showConfirm === 'function') {
showConfirm('Delete "' + label + '"? This cannot be undone.', doDelete);
} else if (window.confirm('Delete "' + label + '"? This cannot be undone.')) {
doDelete();
}
}
// ── 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');
return fetch('/api/notes/from-voice', {
method: 'POST',
credentials: 'include',
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
body: JSON.stringify({ transcript: resp.text }),
})
.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) {
$('note-title').value = title;
var container = $('note-body-editor');
if (!container) return;
if (_editor) { _editor.destroy(); _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 ? '<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();
}
// Allowlist-based sanitizer for reader view. The body is also
// encrypted in the DB and only rendered back to the same user
// who wrote it, so the threat model here is "clean up your own
// pasted HTML" rather than hostile content — but we still drop
// script/event-handler/iframe/object to be safe.
var ALLOWED_TAGS = /^(p|br|strong|em|b|i|u|s|a|ul|ol|li|blockquote|h2|h3|h4|code|pre|hr)$/i;
function sanitizeHtml(html) {
var doc = document.createElement('div'); doc.innerHTML = html;
(function walk(node) {
var children = Array.from(node.children);
children.forEach(function(el) {
if (!ALLOWED_TAGS.test(el.tagName)) {
// Replace with its text content
var text = document.createTextNode(el.textContent || '');
el.parentNode.replaceChild(text, el);
return;
}
// Strip every attribute except href on <a>
Array.from(el.attributes || []).forEach(function(attr) {
if (el.tagName.toLowerCase() === 'a' && attr.name === 'href') {
if (!/^(https?:|mailto:|#)/i.test(attr.value)) el.removeAttribute('href');
} else {
el.removeAttribute(attr.name);
}
});
if (el.tagName.toLowerCase() === 'a') { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); }
walk(el);
});
})(doc);
return doc.innerHTML;
}
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 {};
}
})();