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