271 lines
7.8 KiB
JavaScript
271 lines
7.8 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|