first commit
This commit is contained in:
270
static/js/interactive-reader.js
Normal file
270
static/js/interactive-reader.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Interactive Reader Module
|
||||
* Handles text display, word highlighting, and audio sync
|
||||
*/
|
||||
|
||||
// ==========================================
|
||||
// READER STATE VARIABLES
|
||||
// ==========================================
|
||||
|
||||
let allWordSpans = [];
|
||||
let wordMap = [];
|
||||
let sentenceData = [];
|
||||
let lastHighlightedWordSpan = null;
|
||||
let lastHighlightedSentenceSpans = [];
|
||||
|
||||
// ==========================================
|
||||
// READER INITIALIZATION
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Initialize reader with markdown text
|
||||
* @param {string} markdownText - Text content in markdown format
|
||||
*/
|
||||
function initReader(markdownText) {
|
||||
const container = document.getElementById('readerContent');
|
||||
container.innerHTML = '';
|
||||
allWordSpans = [];
|
||||
wordMap = [];
|
||||
|
||||
const html = marked.parse(markdownText || '', { breaks: true });
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// Process nodes to wrap words in spans
|
||||
processNode(tempDiv);
|
||||
|
||||
while (tempDiv.firstChild) {
|
||||
container.appendChild(tempDiv.firstChild);
|
||||
}
|
||||
|
||||
runSmartSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process DOM node to wrap words in clickable spans
|
||||
* @param {Node} node - DOM node to process
|
||||
*/
|
||||
function processNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const words = node.textContent.split(/(\s+|[^\w'])/g);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
words.forEach(part => {
|
||||
if (part.trim().length > 0) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'word';
|
||||
span.textContent = part;
|
||||
span.onclick = handleWordClick;
|
||||
allWordSpans.push(span);
|
||||
fragment.appendChild(span);
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
|
||||
node.parentNode.replaceChild(fragment, node);
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
Array.from(node.childNodes).forEach(processNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on a word span
|
||||
* @param {MouseEvent} e - Click event
|
||||
*/
|
||||
function handleWordClick(e) {
|
||||
e.stopPropagation();
|
||||
const spanIndex = allWordSpans.indexOf(e.target);
|
||||
const aiIdx = wordMap[spanIndex];
|
||||
|
||||
if (aiIdx !== undefined && transcriptionData[aiIdx] && wavesurfer) {
|
||||
wavesurfer.setTime(transcriptionData[aiIdx].start);
|
||||
wavesurfer.play();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TEXT-AUDIO SYNC FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Run smart sync between text words and transcription data
|
||||
*/
|
||||
function runSmartSync() {
|
||||
wordMap = new Array(allWordSpans.length).fill(undefined);
|
||||
let aiIdx = 0;
|
||||
let matchCount = 0;
|
||||
|
||||
allWordSpans.forEach((span, i) => {
|
||||
const clean = span.textContent.toLowerCase().replace(/[^\w]/g, '');
|
||||
|
||||
if (clean.length === 0) {
|
||||
span.classList.add('unmatched');
|
||||
return;
|
||||
}
|
||||
|
||||
// Look ahead up to 5 positions for a match
|
||||
for (let off = 0; off < 5; off++) {
|
||||
if (aiIdx + off >= transcriptionData.length) break;
|
||||
|
||||
const transcriptWord = transcriptionData[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
|
||||
|
||||
if (transcriptWord === clean) {
|
||||
wordMap[i] = aiIdx + off;
|
||||
aiIdx += off + 1;
|
||||
span.classList.remove('unmatched');
|
||||
matchCount++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
span.classList.add('unmatched');
|
||||
});
|
||||
|
||||
mapSentences();
|
||||
updateSyncStatus(matchCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sync status badge
|
||||
* @param {number} matchCount - Number of matched words
|
||||
*/
|
||||
function updateSyncStatus(matchCount) {
|
||||
const badge = document.getElementById('syncStatus');
|
||||
badge.textContent = `Synced (${matchCount}/${allWordSpans.length})`;
|
||||
badge.className = matchCount > allWordSpans.length * 0.8
|
||||
? 'badge bg-success'
|
||||
: 'badge bg-warning text-dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map sentences for sentence-level highlighting
|
||||
*/
|
||||
function mapSentences() {
|
||||
sentenceData = [];
|
||||
let buffer = [];
|
||||
let startIdx = 0;
|
||||
|
||||
allWordSpans.forEach((span, i) => {
|
||||
buffer.push(span);
|
||||
|
||||
// Check for sentence-ending punctuation
|
||||
if (/[.!?]["']?$/.test(span.textContent.trim())) {
|
||||
let startT = 0;
|
||||
let endT = 0;
|
||||
|
||||
// Find start time
|
||||
for (let k = startIdx; k <= i; k++) {
|
||||
if (wordMap[k] !== undefined) {
|
||||
startT = transcriptionData[wordMap[k]].start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find end time
|
||||
for (let k = i; k >= startIdx; k--) {
|
||||
if (wordMap[k] !== undefined) {
|
||||
endT = transcriptionData[wordMap[k]].end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endT > startT) {
|
||||
sentenceData.push({
|
||||
spans: [...buffer],
|
||||
start: startT,
|
||||
end: endT
|
||||
});
|
||||
}
|
||||
|
||||
buffer = [];
|
||||
startIdx = i + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync reader highlighting with audio playback time
|
||||
* @param {number} t - Current playback time in seconds
|
||||
*/
|
||||
function syncReader(t) {
|
||||
// Highlight current word
|
||||
highlightCurrentWord(t);
|
||||
|
||||
// Highlight current sentence
|
||||
highlightCurrentSentence(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the current word based on playback time
|
||||
* @param {number} t - Current playback time
|
||||
*/
|
||||
function highlightCurrentWord(t) {
|
||||
const aiIdx = transcriptionData.findIndex(d => t >= d.start && t < d.end);
|
||||
|
||||
if (aiIdx !== -1) {
|
||||
const txtIdx = wordMap.findIndex(i => i === aiIdx);
|
||||
|
||||
if (txtIdx !== -1 && allWordSpans[txtIdx] !== lastHighlightedWordSpan) {
|
||||
// Remove previous highlight
|
||||
if (lastHighlightedWordSpan) {
|
||||
lastHighlightedWordSpan.classList.remove('current-word');
|
||||
}
|
||||
|
||||
// Add new highlight
|
||||
lastHighlightedWordSpan = allWordSpans[txtIdx];
|
||||
lastHighlightedWordSpan.classList.add('current-word');
|
||||
|
||||
// Scroll into view if needed
|
||||
scrollWordIntoView(lastHighlightedWordSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the current sentence based on playback time
|
||||
* @param {number} t - Current playback time
|
||||
*/
|
||||
function highlightCurrentSentence(t) {
|
||||
const sent = sentenceData.find(s => t >= s.start && t <= s.end);
|
||||
|
||||
if (sent && sent.spans !== lastHighlightedSentenceSpans) {
|
||||
// Remove previous highlight
|
||||
if (lastHighlightedSentenceSpans) {
|
||||
lastHighlightedSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
|
||||
}
|
||||
|
||||
// Add new highlight
|
||||
lastHighlightedSentenceSpans = sent.spans;
|
||||
lastHighlightedSentenceSpans.forEach(s => s.classList.add('current-sentence-bg'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll word into view if outside visible area
|
||||
* @param {Element} wordSpan - Word span element
|
||||
*/
|
||||
function scrollWordIntoView(wordSpan) {
|
||||
const r = wordSpan.getBoundingClientRect();
|
||||
const c = document.querySelector('.reader-section').getBoundingClientRect();
|
||||
|
||||
if (r.top < c.top || r.bottom > c.bottom) {
|
||||
wordSpan.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MISMATCH TOGGLE
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Toggle display of mismatched words
|
||||
*/
|
||||
function toggleMismatches() {
|
||||
const toggle = document.getElementById('mismatchToggle');
|
||||
document.getElementById('readerContent').classList.toggle('show-mismatches', toggle.checked);
|
||||
}
|
||||
Reference in New Issue
Block a user