split notes frontend modules
This commit is contained in:
parent
302add00da
commit
d419fc3a63
5 changed files with 474 additions and 395 deletions
|
|
@ -1,4 +1,8 @@
|
||||||
import * as NotesApi from './notes/api.js';
|
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.
|
// 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 _trash = []; // trashed notes (deleted_at IS NOT NULL)
|
||||||
var _pane = 'active'; // 'active' | 'trash' — drives the list view
|
var _pane = 'active'; // 'active' | 'trash' — drives the list view
|
||||||
var _activeId = null; // currently open note id (reader OR editor)
|
var _activeId = null; // currently open note id (reader OR editor)
|
||||||
var _editor = null; // Tiptap instance
|
|
||||||
var _search = '';
|
var _search = '';
|
||||||
|
|
||||||
// Autosave state
|
// Autosave state
|
||||||
|
|
@ -32,13 +35,21 @@ import * as NotesApi from './notes/api.js';
|
||||||
var _pendingAfterInflight = false;
|
var _pendingAfterInflight = false;
|
||||||
var AUTOSAVE_DEBOUNCE = 1200;
|
var AUTOSAVE_DEBOUNCE = 1200;
|
||||||
|
|
||||||
// Voice recording state
|
|
||||||
var _recorder = null;
|
|
||||||
var _recTimer = null;
|
|
||||||
var _recPaused = false;
|
|
||||||
var _recActive = false;
|
|
||||||
|
|
||||||
var _statusTimer = null;
|
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
|
// 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.
|
// 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
|
// Leaving the Notes tab — cancel any in-flight recording so the
|
||||||
// mic doesn't stay live + the recorder doesn't keep buffering.
|
// mic doesn't stay live + the recorder doesn't keep buffering.
|
||||||
if (_recActive) stopRecording(true);
|
if (_recorder.isActive()) _recorder.stop(true);
|
||||||
flushAutosave();
|
flushAutosave();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -135,9 +146,9 @@ import * as NotesApi from './notes/api.js';
|
||||||
var recStart = $('btn-note-rec-start');
|
var recStart = $('btn-note-rec-start');
|
||||||
var recPause = $('btn-note-rec-pause');
|
var recPause = $('btn-note-rec-pause');
|
||||||
var recStop = $('btn-note-rec-stop');
|
var recStop = $('btn-note-rec-stop');
|
||||||
if (recStart) recStart.addEventListener('click', function() { startRecording(); });
|
if (recStart) recStart.addEventListener('click', function() { _recorder.start(); });
|
||||||
if (recPause) recPause.addEventListener('click', function() { togglePauseRecording(); });
|
if (recPause) recPause.addEventListener('click', function() { _recorder.togglePause(); });
|
||||||
if (recStop) recStop.addEventListener('click', function() { stopRecording(false); });
|
if (recStop) recStop.addEventListener('click', function() { _recorder.stop(false); });
|
||||||
|
|
||||||
// Ctrl/Cmd+S still saves manually from the editor
|
// Ctrl/Cmd+S still saves manually from the editor
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
|
|
@ -192,8 +203,8 @@ import * as NotesApi from './notes/api.js';
|
||||||
flushAutosave();
|
flushAutosave();
|
||||||
_activeId = null;
|
_activeId = null;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
if (_editor) { _editor.destroy(); _editor = null; }
|
_editor.destroy();
|
||||||
stopRecording(true); // silent cancel if in flight
|
_recorder.stop(true); // silent cancel if in flight
|
||||||
setView('list');
|
setView('list');
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
@ -243,53 +254,17 @@ import * as NotesApi from './notes/api.js';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTrashCount() {
|
function updateTrashCount() {
|
||||||
var el = $('notes-trash-count');
|
renderTrashCount($('notes-trash-count'), _trash.length);
|
||||||
if (!el) return;
|
|
||||||
el.textContent = _trash.length ? '(' + _trash.length + ')' : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
var listEl = $('notes-list');
|
renderNoteList($('notes-list'), {
|
||||||
if (!listEl) return;
|
pane: _pane,
|
||||||
var source = _pane === 'trash' ? _trash : _notes;
|
notes: _notes,
|
||||||
var filtered = source;
|
trash: _trash,
|
||||||
if (_search) {
|
search: _search,
|
||||||
filtered = source.filter(function(n) {
|
activeId: _activeId
|
||||||
var hay = (n.title + ' ' + stripTags(n.body || '')).toLowerCase();
|
});
|
||||||
return hay.indexOf(_search) !== -1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
listEl.innerHTML = '<div class="notes-empty">'
|
|
||||||
+ (_search ? 'No matches' : (_pane === 'trash' ? 'Trash is empty' : ''))
|
|
||||||
+ '</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
listEl.innerHTML = filtered.map(function(n) {
|
|
||||||
var snippet = stripTags(n.body || '').substring(0, 110);
|
|
||||||
var when = formatWhen(_pane === 'trash' ? n.deleted_at : n.updated_at);
|
|
||||||
var active = (n.id === _activeId && _pane === 'active') ? ' active' : '';
|
|
||||||
var trashActions = _pane === 'trash'
|
|
||||||
? '<div class="notes-trash-actions">'
|
|
||||||
+ '<button type="button" class="notes-restore-btn" data-id="' + n.id + '" title="Restore">'
|
|
||||||
+ '<i class="fas fa-rotate-left"></i> Restore'
|
|
||||||
+ '</button>'
|
|
||||||
+ '<button type="button" class="notes-hard-delete-btn" data-id="' + n.id + '" title="Delete forever">'
|
|
||||||
+ '<i class="fas fa-times"></i>'
|
|
||||||
+ '</button>'
|
|
||||||
+ '</div>'
|
|
||||||
: '';
|
|
||||||
return '<div class="notes-list-row">'
|
|
||||||
+ '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
|
|
||||||
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
|
|
||||||
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
|
|
||||||
+ '<div class="notes-list-when">'
|
|
||||||
+ (_pane === 'trash' ? 'Deleted ' : '') + esc(when)
|
|
||||||
+ '</div>'
|
|
||||||
+ '</button>'
|
|
||||||
+ trashActions
|
|
||||||
+ '</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreNote(id) {
|
function restoreNote(id) {
|
||||||
|
|
@ -333,14 +308,14 @@ import * as NotesApi from './notes/api.js';
|
||||||
// ── Reader mode (default after opening or saving) ─────────
|
// ── Reader mode (default after opening or saving) ─────────
|
||||||
function openReader(id) {
|
function openReader(id) {
|
||||||
flushAutosave();
|
flushAutosave();
|
||||||
if (_recActive) stopRecording(true);
|
if (_recorder.isActive()) _recorder.stop(true);
|
||||||
var note = _notes.find(function(n) { return n.id === id; });
|
var note = _notes.find(function(n) { return n.id === id; });
|
||||||
if (!note) return;
|
if (!note) return;
|
||||||
_activeId = id;
|
_activeId = id;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
|
|
||||||
// Tear down the editor if it was open
|
// 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-title').textContent = note.title || 'Untitled';
|
||||||
$('notes-reader-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
$('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) ──────────────
|
// ── Editor mode (create new / edit existing) ──────────────
|
||||||
function startCreate() {
|
function startCreate() {
|
||||||
flushAutosave();
|
flushAutosave();
|
||||||
if (_recActive) stopRecording(true);
|
if (_recorder.isActive()) _recorder.stop(true);
|
||||||
_activeId = null;
|
_activeId = null;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
$('note-title').value = '';
|
$('note-title').value = '';
|
||||||
$('note-meta').textContent = '';
|
$('note-meta').textContent = '';
|
||||||
$('btn-note-delete').style.display = 'none';
|
$('btn-note-delete').style.display = 'none';
|
||||||
mountTiptap($('note-body-editor'), '');
|
_editor.mount($('note-body-editor'), '');
|
||||||
updateStatus('', '');
|
updateStatus('', '');
|
||||||
setRecUI('idle');
|
_recorder.setUI('idle');
|
||||||
setView('editor');
|
setView('editor');
|
||||||
setTimeout(function() { $('note-title').focus(); }, 20);
|
setTimeout(function() { $('note-title').focus(); }, 20);
|
||||||
renderList();
|
renderList();
|
||||||
|
|
@ -378,22 +353,16 @@ import * as NotesApi from './notes/api.js';
|
||||||
$('note-title').value = note.title || '';
|
$('note-title').value = note.title || '';
|
||||||
$('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
$('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||||||
$('btn-note-delete').style.display = '';
|
$('btn-note-delete').style.display = '';
|
||||||
mountTiptap($('note-body-editor'), note.body || '');
|
_editor.mount($('note-body-editor'), note.body || '');
|
||||||
updateStatus('', '');
|
updateStatus('', '');
|
||||||
setRecUI('idle');
|
_recorder.setUI('idle');
|
||||||
setView('editor');
|
setView('editor');
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTitle() { return ($('note-title').value || '').trim(); }
|
function getTitle() { return ($('note-title').value || '').trim(); }
|
||||||
function getBody() {
|
function getBody() {
|
||||||
if (!_editor) {
|
return _editor.getBody();
|
||||||
// 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 ──────────────────────────────────────
|
// ── Save + autosave ──────────────────────────────────────
|
||||||
|
|
@ -494,7 +463,7 @@ import * as NotesApi from './notes/api.js';
|
||||||
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
|
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
|
||||||
_activeId = null;
|
_activeId = null;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
if (_editor) { _editor.destroy(); _editor = null; }
|
_editor.destroy();
|
||||||
closeToList();
|
closeToList();
|
||||||
if (typeof showToast === 'function') showToast('Moved to trash', 'success');
|
if (typeof showToast === 'function') showToast('Moved to trash', 'success');
|
||||||
refreshList();
|
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) {
|
function applyGeneratedNote(title, body) {
|
||||||
var titleEl = $('note-title');
|
var titleEl = $('note-title');
|
||||||
var container = $('note-body-editor');
|
var container = $('note-body-editor');
|
||||||
|
|
@ -597,207 +484,7 @@ import * as NotesApi from './notes/api.js';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
titleEl.value = title || 'Voice note';
|
titleEl.value = title || 'Voice note';
|
||||||
|
_editor.applyGenerated(container, body);
|
||||||
// Prefer Tiptap's native commands.setContent over a remount when an
|
|
||||||
// editor already exists — remounting on every voice take left the
|
|
||||||
// toolbar reattaching, which has caused flicker + a brief window
|
|
||||||
// where the body looked empty.
|
|
||||||
if (_editor && typeof _editor.commands === 'object') {
|
|
||||||
try {
|
|
||||||
_editor.commands.setContent(body || '<p></p>', { emitUpdate: true });
|
|
||||||
_dirty = true;
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[notes] Tiptap setContent failed, rebuilding:', err && err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (_editor) { try { _editor.destroy(); } catch (e) {} _editor = null; }
|
|
||||||
mountTiptap(container, body || '<p></p>');
|
|
||||||
_dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRecTimer() {
|
|
||||||
var el = $('notes-rec-timer');
|
|
||||||
if (_recTimer || !el) return;
|
|
||||||
_recTimer = createTimerLite(el);
|
|
||||||
_recTimer.start();
|
|
||||||
}
|
|
||||||
function stopRecTimer(reset) {
|
|
||||||
if (_recTimer) _recTimer.stop();
|
|
||||||
if (reset && _recTimer) { _recTimer.reset(); _recTimer = null; }
|
|
||||||
}
|
|
||||||
function createTimerLite(el) {
|
|
||||||
var s = 0, iv = null;
|
|
||||||
function paint() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); }
|
|
||||||
return {
|
|
||||||
start: function() { paint(); if (!iv) iv = setInterval(function() { s++; paint(); }, 1000); },
|
|
||||||
stop: function() { if (iv) { clearInterval(iv); iv = null; } },
|
|
||||||
reset: function() { s = 0; paint(); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRecUI(state) {
|
|
||||||
var start = $('btn-note-rec-start');
|
|
||||||
var pause = $('btn-note-rec-pause');
|
|
||||||
var stopBtn = $('btn-note-rec-stop');
|
|
||||||
var ind = $('notes-rec-indicator');
|
|
||||||
var stateEl = $('notes-rec-state');
|
|
||||||
var dot = ind ? ind.querySelector('.pulse-dot') : null;
|
|
||||||
if (!start) return;
|
|
||||||
|
|
||||||
var idle = (state === 'idle');
|
|
||||||
var recording = (state === 'recording');
|
|
||||||
var paused = (state === 'paused');
|
|
||||||
var processing = (state === 'processing');
|
|
||||||
|
|
||||||
start.classList.toggle('hidden', !idle);
|
|
||||||
pause.classList.toggle('hidden', !(recording || paused));
|
|
||||||
stopBtn.classList.toggle('hidden', !(recording || paused));
|
|
||||||
ind.classList.toggle('hidden', idle);
|
|
||||||
|
|
||||||
if (pause) pause.innerHTML = paused ? '<i class="fas fa-play"></i> Resume' : '<i class="fas fa-pause"></i> Pause';
|
|
||||||
if (stateEl) stateEl.textContent = processing ? 'Processing…' : (paused ? 'Paused' : 'Recording');
|
|
||||||
if (dot) dot.classList.toggle('paused', paused || processing);
|
|
||||||
|
|
||||||
start.disabled = !idle;
|
|
||||||
pause.disabled = processing;
|
|
||||||
stopBtn.disabled = processing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tiptap ─────────────────────────────────────────────
|
|
||||||
function mountTiptap(container, initialHtml) {
|
|
||||||
var T = window.Tiptap || {};
|
|
||||||
if (!T.Editor) {
|
|
||||||
// Fallback when the Tiptap bundle didn't load — plain textarea.
|
|
||||||
container.innerHTML = '<textarea class="notes-body-fallback" placeholder="Write your note…">' + esc(stripTags(initialHtml)) + '</textarea>';
|
|
||||||
_editor = null;
|
|
||||||
var fb = container.querySelector('.notes-body-fallback');
|
|
||||||
if (fb) fb.addEventListener('input', function() { _dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave(); });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = toolbarHTML() + '<div class="tp-content"></div>';
|
|
||||||
_editor = new T.Editor({
|
|
||||||
element: container.querySelector('.tp-content'),
|
|
||||||
extensions: [
|
|
||||||
T.StarterKit,
|
|
||||||
T.Link.configure({ openOnClick: false, autolink: true }),
|
|
||||||
T.Underline
|
|
||||||
],
|
|
||||||
content: initialHtml || '',
|
|
||||||
autofocus: false,
|
|
||||||
onUpdate: function() {
|
|
||||||
_dirty = true;
|
|
||||||
updateStatus('Saving…', 'saving');
|
|
||||||
updateToolbarState(container);
|
|
||||||
scheduleAutosave();
|
|
||||||
},
|
|
||||||
onSelectionUpdate: function() { updateToolbarState(container); }
|
|
||||||
});
|
|
||||||
wireToolbar(container, _editor);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolbarHTML() {
|
|
||||||
var btns = ''
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>'
|
|
||||||
+ '<span class="tp-sep"></span>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>'
|
|
||||||
+ '<span class="tp-sep"></span>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet list"><i class="fas fa-list-ul"></i></button>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered list"><i class="fas fa-list-ol"></i></button>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="blockquote" title="Quote"><i class="fas fa-quote-left"></i></button>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>'
|
|
||||||
+ '<span class="tp-sep"></span>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>'
|
|
||||||
+ '<span class="tp-sep"></span>'
|
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
|
|
||||||
return ''
|
|
||||||
+ '<div class="tp-toolbar">' + btns + '</div>'
|
|
||||||
+ '<div class="tp-link-bar" style="display:none;">'
|
|
||||||
+ '<input type="url" class="tp-link-input" placeholder="https://">'
|
|
||||||
+ '<button type="button" class="tp-link-apply">Apply</button>'
|
|
||||||
+ '<button type="button" class="tp-link-remove">Remove</button>'
|
|
||||||
+ '<button type="button" class="tp-link-cancel">✕</button>'
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireToolbar(wrap, ed) {
|
|
||||||
var toolbar = wrap.querySelector('.tp-toolbar');
|
|
||||||
var linkBar = wrap.querySelector('.tp-link-bar');
|
|
||||||
var linkInput = wrap.querySelector('.tp-link-input');
|
|
||||||
|
|
||||||
toolbar.addEventListener('mousedown', function(e) {
|
|
||||||
var btn = e.target.closest('.tp-btn[data-cmd]');
|
|
||||||
if (!btn) return;
|
|
||||||
e.preventDefault();
|
|
||||||
var cmd = btn.dataset.cmd;
|
|
||||||
switch (cmd) {
|
|
||||||
case 'bold': ed.chain().focus().toggleBold().run(); break;
|
|
||||||
case 'italic': ed.chain().focus().toggleItalic().run(); break;
|
|
||||||
case 'underline': ed.chain().focus().toggleUnderline().run(); break;
|
|
||||||
case 'strike': ed.chain().focus().toggleStrike().run(); break;
|
|
||||||
case 'h2': ed.chain().focus().toggleHeading({ level: 2 }).run(); break;
|
|
||||||
case 'h3': ed.chain().focus().toggleHeading({ level: 3 }).run(); break;
|
|
||||||
case 'bulletList': ed.chain().focus().toggleBulletList().run(); break;
|
|
||||||
case 'orderedList': ed.chain().focus().toggleOrderedList().run(); break;
|
|
||||||
case 'blockquote': ed.chain().focus().toggleBlockquote().run(); break;
|
|
||||||
case 'codeBlock': ed.chain().focus().toggleCodeBlock().run(); break;
|
|
||||||
case 'clear': ed.chain().focus().unsetAllMarks().clearNodes().run(); break;
|
|
||||||
case 'link':
|
|
||||||
if (linkBar.style.display === 'none') {
|
|
||||||
linkInput.value = ed.getAttributes('link').href || '';
|
|
||||||
linkBar.style.display = 'flex';
|
|
||||||
setTimeout(function() { linkInput.focus(); }, 0);
|
|
||||||
} else {
|
|
||||||
linkBar.style.display = 'none';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
updateToolbarState(wrap);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var url = linkInput.value.trim();
|
|
||||||
if (url) ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
||||||
linkBar.style.display = 'none';
|
|
||||||
});
|
|
||||||
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
ed.chain().focus().unsetLink().run();
|
|
||||||
linkBar.style.display = 'none';
|
|
||||||
});
|
|
||||||
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
linkBar.style.display = 'none';
|
|
||||||
});
|
|
||||||
linkInput.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
|
|
||||||
if (e.key === 'Escape') { linkBar.style.display = 'none'; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateToolbarState(wrap) {
|
|
||||||
if (!_editor) return;
|
|
||||||
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
|
|
||||||
var cmd = btn.dataset.cmd;
|
|
||||||
var active = false;
|
|
||||||
if (cmd === 'bold') active = _editor.isActive('bold');
|
|
||||||
else if (cmd === 'italic') active = _editor.isActive('italic');
|
|
||||||
else if (cmd === 'underline') active = _editor.isActive('underline');
|
|
||||||
else if (cmd === 'strike') active = _editor.isActive('strike');
|
|
||||||
else if (cmd === 'h2') active = _editor.isActive('heading', { level: 2 });
|
|
||||||
else if (cmd === 'h3') active = _editor.isActive('heading', { level: 3 });
|
|
||||||
else if (cmd === 'bulletList') active = _editor.isActive('bulletList');
|
|
||||||
else if (cmd === 'orderedList') active = _editor.isActive('orderedList');
|
|
||||||
else if (cmd === 'blockquote') active = _editor.isActive('blockquote');
|
|
||||||
else if (cmd === 'codeBlock') active = _editor.isActive('codeBlock');
|
|
||||||
else if (cmd === 'link') active = _editor.isActive('link');
|
|
||||||
btn.classList.toggle('active', active);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────
|
// ── 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, '"').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' });
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
181
public/js/notes/editor.js
Normal file
181
public/js/notes/editor.js
Normal file
|
|
@ -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 = '<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() { markDirty(); });
|
||||||
|
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() {
|
||||||
|
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 === '<p></p>' || html === '') ? '' : html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGenerated(container, body) {
|
||||||
|
if (editor && typeof editor.commands === 'object') {
|
||||||
|
try {
|
||||||
|
editor.commands.setContent(body || '<p></p>', { 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 || '<p></p>');
|
||||||
|
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 = ''
|
||||||
|
+ '<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">x</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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mount: mount,
|
||||||
|
destroy: destroy,
|
||||||
|
getBody: getBody,
|
||||||
|
applyGenerated: applyGenerated
|
||||||
|
};
|
||||||
|
}
|
||||||
55
public/js/notes/list.js
Normal file
55
public/js/notes/list.js
Normal file
|
|
@ -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 = '<div class="notes-empty">'
|
||||||
|
+ (search ? 'No matches' : (pane === 'trash' ? 'Trash is empty' : ''))
|
||||||
|
+ '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = filtered.map(function(n) {
|
||||||
|
var snippet = stripTags(n.body || '').substring(0, 110);
|
||||||
|
var when = formatWhen(pane === 'trash' ? n.deleted_at : n.updated_at);
|
||||||
|
var active = (n.id === options.activeId && pane === 'active') ? ' active' : '';
|
||||||
|
var trashActions = pane === 'trash'
|
||||||
|
? '<div class="notes-trash-actions">'
|
||||||
|
+ '<button type="button" class="notes-restore-btn" data-id="' + n.id + '" title="Restore">'
|
||||||
|
+ '<i class="fas fa-rotate-left"></i> Restore'
|
||||||
|
+ '</button>'
|
||||||
|
+ '<button type="button" class="notes-hard-delete-btn" data-id="' + n.id + '" title="Delete forever">'
|
||||||
|
+ '<i class="fas fa-times"></i>'
|
||||||
|
+ '</button>'
|
||||||
|
+ '</div>'
|
||||||
|
: '';
|
||||||
|
return '<div class="notes-list-row">'
|
||||||
|
+ '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
|
||||||
|
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
|
||||||
|
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
|
||||||
|
+ '<div class="notes-list-when">'
|
||||||
|
+ (pane === 'trash' ? 'Deleted ' : '') + esc(when)
|
||||||
|
+ '</div>'
|
||||||
|
+ '</button>'
|
||||||
|
+ trashActions
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTrashCount(el, trashCount) {
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = trashCount ? '(' + trashCount + ')' : '';
|
||||||
|
}
|
||||||
155
public/js/notes/recorder.js
Normal file
155
public/js/notes/recorder.js
Normal file
|
|
@ -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 ? '<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);
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
41
public/js/notes/utils.js
Normal file
41
public/js/notes/utils.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export function esc(s) {
|
||||||
|
return (s == null ? '' : String(s))
|
||||||
|
.replace(/&/g, '&').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
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue