diff --git a/public/js/notes.js b/public/js/notes.js
index e044ebe..7997e12 100644
--- a/public/js/notes.js
+++ b/public/js/notes.js
@@ -1,4 +1,8 @@
import * as NotesApi from './notes/api.js';
+import { createNotesEditor } from './notes/editor.js';
+import { renderNoteList, updateTrashCount as renderTrashCount } from './notes/list.js';
+import { createNotesRecorder } from './notes/recorder.js';
+import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
// ============================================================
// NOTES — per-user personal scratchpad under Clinical Tools.
@@ -22,7 +26,6 @@ import * as NotesApi from './notes/api.js';
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
@@ -32,13 +35,21 @@ import * as NotesApi from './notes/api.js';
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;
+ var _editor = createNotesEditor({
+ onDirty: function() { _dirty = true; },
+ onStatus: updateStatus,
+ onAutosave: scheduleAutosave
+ });
+ var _recorder = createNotesRecorder({
+ getEl: $,
+ updateStatus: updateStatus,
+ ensureEditor: function() { if ($('notes-editor').classList.contains('hidden')) startCreate(); },
+ noteFromVoice: NotesApi.noteFromVoice,
+ applyGeneratedNote: applyGeneratedNote,
+ markDirty: function() { _dirty = true; },
+ scheduleAutosave: scheduleAutosave
+ });
// Wait for the Notes tab to be activated — the component HTML is
// lazy-loaded, so elements don't exist until the user clicks the tab.
@@ -51,7 +62,7 @@ import * as NotesApi from './notes/api.js';
}
// 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);
+ if (_recorder.isActive()) _recorder.stop(true);
flushAutosave();
});
@@ -135,9 +146,9 @@ import * as NotesApi from './notes/api.js';
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); });
+ if (recStart) recStart.addEventListener('click', function() { _recorder.start(); });
+ if (recPause) recPause.addEventListener('click', function() { _recorder.togglePause(); });
+ if (recStop) recStop.addEventListener('click', function() { _recorder.stop(false); });
// Ctrl/Cmd+S still saves manually from the editor
document.addEventListener('keydown', function(e) {
@@ -192,8 +203,8 @@ import * as NotesApi from './notes/api.js';
flushAutosave();
_activeId = null;
_dirty = false;
- if (_editor) { _editor.destroy(); _editor = null; }
- stopRecording(true); // silent cancel if in flight
+ _editor.destroy();
+ _recorder.stop(true); // silent cancel if in flight
setView('list');
renderList();
}
@@ -243,53 +254,17 @@ import * as NotesApi from './notes/api.js';
}
function updateTrashCount() {
- var el = $('notes-trash-count');
- if (!el) return;
- el.textContent = _trash.length ? '(' + _trash.length + ')' : '';
+ renderTrashCount($('notes-trash-count'), _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('');
+ renderNoteList($('notes-list'), {
+ pane: _pane,
+ notes: _notes,
+ trash: _trash,
+ search: _search,
+ activeId: _activeId
+ });
}
function restoreNote(id) {
@@ -333,14 +308,14 @@ import * as NotesApi from './notes/api.js';
// ── Reader mode (default after opening or saving) ─────────
function openReader(id) {
flushAutosave();
- if (_recActive) stopRecording(true);
+ if (_recorder.isActive()) _recorder.stop(true);
var note = _notes.find(function(n) { return n.id === id; });
if (!note) return;
_activeId = id;
_dirty = false;
// Tear down the editor if it was open
- if (_editor) { _editor.destroy(); _editor = null; }
+ _editor.destroy();
$('notes-reader-title').textContent = note.title || 'Untitled';
$('notes-reader-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
@@ -355,15 +330,15 @@ import * as NotesApi from './notes/api.js';
// ── Editor mode (create new / edit existing) ──────────────
function startCreate() {
flushAutosave();
- if (_recActive) stopRecording(true);
+ if (_recorder.isActive()) _recorder.stop(true);
_activeId = null;
_dirty = false;
$('note-title').value = '';
$('note-meta').textContent = '';
$('btn-note-delete').style.display = 'none';
- mountTiptap($('note-body-editor'), '');
+ _editor.mount($('note-body-editor'), '');
updateStatus('', '');
- setRecUI('idle');
+ _recorder.setUI('idle');
setView('editor');
setTimeout(function() { $('note-title').focus(); }, 20);
renderList();
@@ -378,22 +353,16 @@ import * as NotesApi from './notes/api.js';
$('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 || '');
+ _editor.mount($('note-body-editor'), note.body || '');
updateStatus('', '');
- setRecUI('idle');
+ _recorder.setUI('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;
+ return _editor.getBody();
}
// ── Save + autosave ──────────────────────────────────────
@@ -494,7 +463,7 @@ import * as NotesApi from './notes/api.js';
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
_activeId = null;
_dirty = false;
- if (_editor) { _editor.destroy(); _editor = null; }
+ _editor.destroy();
closeToList();
if (typeof showToast === 'function') showToast('Moved to trash', 'success');
refreshList();
@@ -507,88 +476,6 @@ import * as NotesApi from './notes/api.js';
}
}
- // ── 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 NotesApi.noteFromVoice({ transcript: resp.text, model: selectedModel || undefined })
- .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');
@@ -597,207 +484,7 @@ import * as NotesApi from './notes/api.js';
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);
- });
+ _editor.applyGenerated(container, body);
}
// ── Helpers ──────────────────────────────────────────────
@@ -812,44 +499,4 @@ import * as NotesApi from './notes/api.js';
}
}
- 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' });
- }
})();
diff --git a/public/js/notes/editor.js b/public/js/notes/editor.js
new file mode 100644
index 0000000..b85c912
--- /dev/null
+++ b/public/js/notes/editor.js
@@ -0,0 +1,181 @@
+import { esc, stripTags } from './utils.js';
+
+export function createNotesEditor(options) {
+ options = options || {};
+ var editor = null;
+
+ function mount(container, initialHtml) {
+ var T = window.Tiptap || {};
+ if (!T.Editor) {
+ container.innerHTML = '';
+ editor = null;
+ var fb = container.querySelector('.notes-body-fallback');
+ if (fb) fb.addEventListener('input', function() { markDirty(); });
+ 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() {
+ markDirty();
+ updateToolbarState(container);
+ },
+ onSelectionUpdate: function() { updateToolbarState(container); }
+ });
+ wireToolbar(container, editor);
+ }
+
+ function destroy() {
+ if (!editor) return;
+ editor.destroy();
+ editor = null;
+ }
+
+ function getBody() {
+ if (!editor) {
+ var fb = document.querySelector('.notes-body-fallback');
+ return fb ? fb.value : '';
+ }
+ var html = editor.getHTML();
+ return (html === '' || html === '') ? '' : html;
+ }
+
+ function applyGenerated(container, body) {
+ if (editor && typeof editor.commands === 'object') {
+ try {
+ editor.commands.setContent(body || '', { emitUpdate: true });
+ if (typeof options.onDirty === 'function') options.onDirty();
+ return;
+ } catch (err) {
+ console.warn('[notes] Tiptap setContent failed, rebuilding:', err && err.message);
+ }
+ }
+ destroy();
+ mount(container, body || '');
+ if (typeof options.onDirty === 'function') options.onDirty();
+ }
+
+ function markDirty() {
+ if (typeof options.onDirty === 'function') options.onDirty();
+ if (typeof options.onStatus === 'function') options.onStatus('Saving...', 'saving');
+ if (typeof options.onAutosave === 'function') options.onAutosave();
+ }
+
+ 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);
+ });
+ }
+
+ return {
+ mount: mount,
+ destroy: destroy,
+ getBody: getBody,
+ applyGenerated: applyGenerated
+ };
+}
diff --git a/public/js/notes/list.js b/public/js/notes/list.js
new file mode 100644
index 0000000..b94897b
--- /dev/null
+++ b/public/js/notes/list.js
@@ -0,0 +1,55 @@
+import { esc, formatWhen, stripTags } from './utils.js';
+
+export function renderNoteList(listEl, options) {
+ if (!listEl) return;
+ options = options || {};
+ var pane = options.pane || 'active';
+ var search = options.search || '';
+ var source = pane === 'trash' ? (options.trash || []) : (options.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 === options.activeId && pane === 'active') ? ' active' : '';
+ var trashActions = pane === 'trash'
+ ? ''
+ + ''
+ + ''
+ + '
'
+ : '';
+ return ''
+ + '
'
+ + trashActions
+ + '
';
+ }).join('');
+}
+
+export function updateTrashCount(el, trashCount) {
+ if (!el) return;
+ el.textContent = trashCount ? '(' + trashCount + ')' : '';
+}
diff --git a/public/js/notes/recorder.js b/public/js/notes/recorder.js
new file mode 100644
index 0000000..438b8a5
--- /dev/null
+++ b/public/js/notes/recorder.js
@@ -0,0 +1,155 @@
+export function createNotesRecorder(options) {
+ options = options || {};
+ var recorder = null;
+ var recTimer = null;
+ var recPaused = false;
+ var recActive = false;
+
+ function start() {
+ if (recActive) return;
+ if (typeof options.ensureEditor === 'function') options.ensureEditor();
+ if (typeof AudioRecorder === 'undefined') { updateStatus('Recorder unavailable', 'err'); return; }
+ recorder = new AudioRecorder();
+ recorder.start().then(function() {
+ recActive = true; recPaused = false;
+ setUI('recording');
+ startTimer();
+ }).catch(function(err) {
+ updateStatus('Mic denied: ' + (err && err.message || ''), 'err');
+ });
+ }
+
+ function togglePause() {
+ if (!recActive || !recorder || !recorder.mediaRecorder) return;
+ if (!recPaused) {
+ try { recorder.mediaRecorder.pause(); } catch (e) {}
+ recPaused = true; stopTimer(); setUI('paused');
+ } else {
+ try { recorder.mediaRecorder.resume(); } catch (e) {
+ 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; startTimer(); setUI('recording');
+ }
+ }
+
+ function stop(silent) {
+ if (!recActive || !recorder) {
+ if (!silent) setUI('idle');
+ return;
+ }
+ recActive = false; recPaused = false;
+ stopTimer(true);
+ if (silent) {
+ 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) {}
+ setUI('idle');
+ return;
+ }
+ setUI('processing');
+ updateStatus('Transcribing...', 'saving');
+
+ recorder.stop().then(function(blob) {
+ if (!blob || blob.size === 0) { updateStatus('Nothing recorded', 'err'); setUI('idle'); return; }
+ if (typeof transcribeAudio !== 'function') { updateStatus('Transcription unavailable', 'err'); setUI('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'); setUI('idle'); return;
+ }
+ updateStatus('Generating note...', 'saving');
+ var modelEl = document.getElementById('notes-model-select');
+ var selectedModel = modelEl ? modelEl.value : '';
+ return options.noteFromVoice({ transcript: resp.text, model: selectedModel || undefined })
+ .then(function(data) {
+ setUI('idle');
+ if (!data.success) { updateStatus(data.error || 'Generation failed', 'err'); return; }
+ if (typeof options.applyGeneratedNote === 'function') options.applyGeneratedNote(data.title || 'Voice note', data.body || '');
+ updateStatus('Generated - autosaving...', 'ok');
+ if (typeof options.markDirty === 'function') options.markDirty();
+ if (typeof options.scheduleAutosave === 'function') options.scheduleAutosave();
+ });
+ });
+ }).catch(function(err) {
+ setUI('idle');
+ updateStatus(err.message || 'Recording failed', 'err');
+ });
+ }
+
+ function isActive() {
+ return recActive;
+ }
+
+ function startTimer() {
+ var el = getEl('notes-rec-timer');
+ if (recTimer || !el) return;
+ recTimer = createTimerLite(el);
+ recTimer.start();
+ }
+
+ function stopTimer(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 setUI(state) {
+ var startBtn = getEl('btn-note-rec-start');
+ var pause = getEl('btn-note-rec-pause');
+ var stopBtn = getEl('btn-note-rec-stop');
+ var ind = getEl('notes-rec-indicator');
+ var stateEl = getEl('notes-rec-state');
+ var dot = ind ? ind.querySelector('.pulse-dot') : null;
+ if (!startBtn) return;
+
+ var idle = (state === 'idle');
+ var recording = (state === 'recording');
+ var paused = (state === 'paused');
+ var processing = (state === 'processing');
+
+ startBtn.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);
+
+ startBtn.disabled = !idle;
+ pause.disabled = processing;
+ stopBtn.disabled = processing;
+ }
+
+ function getEl(id) {
+ if (typeof options.getEl === 'function') return options.getEl(id);
+ return document.getElementById(id);
+ }
+
+ function updateStatus(text, kind) {
+ if (typeof options.updateStatus === 'function') options.updateStatus(text, kind);
+ }
+
+ return {
+ start: start,
+ togglePause: togglePause,
+ stop: stop,
+ isActive: isActive,
+ setUI: setUI
+ };
+}
diff --git a/public/js/notes/utils.js b/public/js/notes/utils.js
new file mode 100644
index 0000000..c734d79
--- /dev/null
+++ b/public/js/notes/utils.js
@@ -0,0 +1,41 @@
+export function esc(s) {
+ return (s == null ? '' : String(s))
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+export function stripTags(html) {
+ if (!html) return '';
+ var d = document.createElement('div');
+ d.innerHTML = html;
+ return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
+}
+
+export 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' });
+}
+
+// Sanitize via DOMPurify (loaded from cdnjs in index.html, also used by
+// learningHub.js). If DOMPurify fails to load, render as plain text.
+export 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
+ });
+}