Files
audiobook-studio-pro-v3/static/js/editor.js
Ashim Kumar 11d715eb85 first commit
2026-01-09 21:06:30 +06:00

1071 lines
37 KiB
JavaScript

/**
* Editor Module
* Handles bulk editor, markers, text formatting, image uploads, and content management
*/
// ==========================================
// VOICE OPTIONS CONFIGURATION
// ==========================================
const VOICES = [
{val: 'af_alloy', label: 'Alloy (US Fem)'},
{val: 'af_aoede', label: 'Aoede (US Fem)'},
{val: 'af_bella', label: 'Bella (US Fem)'},
{val: 'af_heart', label: 'Heart (US Fem)'},
{val: 'af_jessica', label: 'Jessica (US Fem)'},
{val: 'af_nicole', label: 'Nicole (US Fem)'},
{val: 'af_nova', label: 'Nova (US Fem)'},
{val: 'af_river', label: 'River (US Fem)'},
{val: 'af_sarah', label: 'Sarah (US Fem)'},
{val: 'af_sky', label: 'Sky (US Fem)'},
{val: 'am_adam', label: 'Adam (US Masc)'},
{val: 'am_echo', label: 'Echo (US Masc)'},
{val: 'am_eric', label: 'Eric (US Masc)'},
{val: 'am_michael', label: 'Michael (US Masc)'},
{val: 'bf_emma', label: 'Emma (UK Fem)'},
{val: 'bf_isabella', label: 'Isabella (UK Fem)'},
{val: 'bm_daniel', label: 'Daniel (UK Masc)'},
{val: 'bm_george', label: 'George (UK Masc)'},
];
// ==========================================
// MARKER STATE MANAGEMENT
// ==========================================
let ttsModal = null;
const markerState = {};
let chapterCounter = 1;
let sectionCounter = 1;
// ==========================================
// TOGGLE FLOATING CONTROLS
// ==========================================
/**
* Toggle floating controls visibility
* @param {boolean} show - Whether to show or hide controls
*/
function toggleFloatingControls(show) {
const floatingControls = document.getElementById('floatingControls');
const plNav = document.getElementById('playlistNavigator');
const singleExportGroup = document.getElementById('singleExportGroup');
if (floatingControls) {
if (show) {
floatingControls.classList.add('visible');
floatingControls.style.display = 'flex';
} else {
floatingControls.classList.remove('visible');
floatingControls.style.display = 'none';
}
}
if (show) {
if (singleExportGroup) singleExportGroup.style.display = 'none';
if (plNav) plNav.style.display = 'block';
} else {
if (singleExportGroup) singleExportGroup.style.display = 'flex';
if (plNav) plNav.style.display = 'none';
}
}
// Make it globally available
window.toggleFloatingControls = toggleFloatingControls;
// ==========================================
// VOICE SELECT POPULATION
// ==========================================
/**
* Populate voice dropdown selects
*/
function populateVoiceSelects() {
const opts = VOICES.map(v => `<option value="${v.val}">${v.label}</option>`).join('');
const voiceSelect = document.getElementById('voiceSelect');
if (voiceSelect) {
voiceSelect.innerHTML = opts;
}
}
// ==========================================
// MARKER CREATION FUNCTIONS
// ==========================================
/**
* Insert chapter marker at cursor position
*/
function insertChapterMarker() {
sectionCounter = 1;
const marker = createMarkerHTML('chapter', chapterCounter++);
insertHtmlAtCursor(marker);
// Focus back on editor
const editor = document.getElementById('bulk-editor');
if (editor) editor.focus();
}
/**
* Insert section marker at cursor position
*/
function insertSectionMarker() {
const marker = createMarkerHTML('section', sectionCounter++);
insertHtmlAtCursor(marker);
// Initialize image handlers after DOM update
setTimeout(() => {
initializeImageHandlers();
}, 100);
// Focus back on editor
const editor = document.getElementById('bulk-editor');
if (editor) editor.focus();
}
// Make functions globally available
window.insertChapterMarker = insertChapterMarker;
window.insertSectionMarker = insertSectionMarker;
/**
* Generate marker HTML
* @param {string} type - 'chapter' or 'section'
* @param {number} num - Marker number
* @param {string} voice - Voice ID
* @param {string} markerId - Optional marker ID
* @returns {string} HTML string
*/
function createMarkerHTML(type, num, voice = 'af_alloy', markerId = null) {
const id = markerId || (Date.now() + '_' + Math.random().toString(36).substr(2, 9));
const title = type.toUpperCase();
const btnClass = type === 'chapter' ? 'btn-danger' : 'btn-primary';
const voiceOpts = VOICES.map(v =>
`<option value="${v.val}" ${v.val === voice ? 'selected' : ''}>${v.label}</option>`
).join('');
// Section-specific controls including image upload
let extraControls = '';
let imageSection = '';
if (type === 'section') {
extraControls = `
<div class="vr bg-secondary mx-2"></div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-dark dropdown-toggle" type="button" data-bs-toggle="dropdown" onmousedown="event.preventDefault()">Style</button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); formatBlock('p')">Normal</button></li>
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); formatBlock('h1')">Heading 1</button></li>
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); formatBlock('h2')">Heading 2</button></li>
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); formatBlock('h3')">Heading 3</button></li>
</ul>
<button class="btn btn-outline-dark" title="Bold" onmousedown="event.preventDefault(); applyFormat('bold')"><i class="bi bi-type-bold"></i></button>
<button class="btn btn-outline-dark" title="Italic" onmousedown="event.preventDefault(); applyFormat('italic')"><i class="bi bi-type-italic"></i></button>
<button class="btn btn-outline-dark" title="Normalize" onmousedown="event.preventDefault(); normalizeSection('${id}')"><i class="bi bi-eraser"></i></button>
<div class="vr bg-secondary mx-2"></div>
<button class="btn btn-outline-success" onclick="triggerImageUpload('${id}')" title="Add Image">
<i class="bi bi-image"></i>
</button>
<button class="btn btn-outline-primary fw-bold" onclick="openTTSEditor('${id}')" title="Edit TTS Text">
<i class="bi bi-pencil-square"></i> TTS
</button>
</div>
`;
imageSection = `
<div class="section-image-container" id="image-container-${id}" data-marker-id="${id}">
<input type="file" class="image-file-input" id="image-input-${id}" accept="image/png,image/jpeg,image/jpg,image/gif,image/webp" onchange="handleImageSelect(event, '${id}')">
<div class="image-drop-zone" id="drop-zone-${id}" onclick="triggerImageUpload('${id}')">
<i class="bi bi-cloud-arrow-up"></i>
<span>Drop image here, click to browse, or paste from clipboard</span>
</div>
<div class="image-preview-wrapper d-none" id="preview-wrapper-${id}">
<img class="section-image-preview" id="image-preview-${id}" src="" alt="Section image">
<div class="image-actions">
<button class="btn-remove" onclick="removeImage('${id}')" title="Remove image">
<i class="bi bi-x"></i>
</button>
</div>
<div class="image-info" id="image-info-${id}"></div>
</div>
</div>
`;
}
return `
<div class="${type}-marker editor-marker" contenteditable="false" id="marker-${id}" data-type="${type}" data-marker-id="${id}">
<div class="marker-header">
<span class="marker-title">${title}</span>
<div class="marker-controls">
<div class="input-group input-group-sm">
<span class="input-group-text">#</span>
<input type="number" class="form-control" value="${num}" style="width: 60px;" onchange="updateMarkerData('${id}', 'num', this.value)">
</div>
<select class="form-select form-select-sm" style="width: 140px;" onchange="updateMarkerData('${id}', 'voice', this.value)">
${voiceOpts}
</select>
${extraControls}
<div class="vr bg-secondary mx-2"></div>
<button class="btn btn-sm ${btnClass} fw-bold" onclick="generateMarkerAudio('${id}')">
<i class="bi bi-play-circle me-1"></i> Generate ${type === 'chapter' ? 'All' : 'Audio'}
</button>
<button class="btn btn-sm btn-outline-danger ms-2" title="Remove Marker" onclick="removeMarker('${id}')">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
${imageSection}
</div>
<p><br></p>`;
}
// Make it globally available
window.createMarkerHTML = createMarkerHTML;
/**
* Remove marker from editor
* @param {string} id - Marker ID
*/
function removeMarker(id) {
if (confirm("Remove this marker?")) {
const el = document.getElementById(`marker-${id}`);
if (el) el.remove();
// Clean up marker state
if (markerState[id]) delete markerState[id];
}
}
// Make it globally available
window.removeMarker = removeMarker;
// ==========================================
// IMAGE HANDLING FUNCTIONS
// ==========================================
/**
* Trigger image file input click
* @param {string} markerId - Marker ID
*/
function triggerImageUpload(markerId) {
const input = document.getElementById(`image-input-${markerId}`);
if (input) input.click();
}
// Make it globally available
window.triggerImageUpload = triggerImageUpload;
/**
* Handle image file selection
* @param {Event} event - File input change event
* @param {string} markerId - Marker ID
*/
function handleImageSelect(event, markerId) {
const file = event.target.files[0];
if (file) {
processImageFile(file, markerId);
}
}
// Make it globally available
window.handleImageSelect = handleImageSelect;
/**
* Process image file and convert to base64
* @param {File} file - Image file
* @param {string} markerId - Marker ID
*/
function processImageFile(file, markerId) {
// Validate file type
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
alert('Please select a valid image file (PNG, JPG, GIF, or WebP)');
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
alert('Image file is too large. Maximum size is 10MB.');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
const base64Data = e.target.result.split(',')[1]; // Remove data:image/xxx;base64, prefix
const format = file.type.split('/')[1]; // Get format from MIME type
// Store in marker state
updateMarkerData(markerId, 'imageData', base64Data);
updateMarkerData(markerId, 'imageFormat', format === 'jpeg' ? 'jpg' : format);
// Update UI
showImagePreview(markerId, e.target.result, file.name, file.size, format);
};
reader.readAsDataURL(file);
}
/**
* Show image preview in the marker
* @param {string} markerId - Marker ID
* @param {string} dataUrl - Image data URL for preview
* @param {string} fileName - Original file name
* @param {number} fileSize - File size in bytes
* @param {string} format - Image format
*/
function showImagePreview(markerId, dataUrl, fileName, fileSize, format) {
const container = document.getElementById(`image-container-${markerId}`);
const dropZone = document.getElementById(`drop-zone-${markerId}`);
const previewWrapper = document.getElementById(`preview-wrapper-${markerId}`);
const preview = document.getElementById(`image-preview-${markerId}`);
const info = document.getElementById(`image-info-${markerId}`);
const marker = document.getElementById(`marker-${markerId}`);
if (container && dropZone && previewWrapper && preview) {
dropZone.classList.add('d-none');
previewWrapper.classList.remove('d-none');
preview.src = dataUrl;
container.classList.add('has-image');
if (info) {
const sizeKB = (fileSize / 1024).toFixed(1);
info.textContent = `${fileName || 'Image'}${sizeKB > 0 ? sizeKB + ' KB' : 'Loaded'}${(format || 'png').toUpperCase()}`;
}
if (marker) {
marker.classList.add('has-image');
}
}
}
// Make it globally available
window.showImagePreview = showImagePreview;
/**
* Remove image from marker
* @param {string} markerId - Marker ID
*/
function removeImage(markerId) {
const container = document.getElementById(`image-container-${markerId}`);
const dropZone = document.getElementById(`drop-zone-${markerId}`);
const previewWrapper = document.getElementById(`preview-wrapper-${markerId}`);
const input = document.getElementById(`image-input-${markerId}`);
const marker = document.getElementById(`marker-${markerId}`);
if (container && dropZone && previewWrapper) {
dropZone.classList.remove('d-none');
previewWrapper.classList.add('d-none');
container.classList.remove('has-image');
if (input) input.value = '';
if (marker) {
marker.classList.remove('has-image');
}
}
// Clear from marker state
updateMarkerData(markerId, 'imageData', '');
updateMarkerData(markerId, 'imageFormat', '');
}
// Make it globally available
window.removeImage = removeImage;
/**
* Initialize image drag-drop and paste handlers for all containers
*/
function initializeImageHandlers() {
const containers = document.querySelectorAll('.section-image-container');
containers.forEach(container => {
const markerId = container.dataset.markerId;
if (!markerId) return;
// Skip if already initialized
if (container.dataset.initialized === 'true') return;
container.dataset.initialized = 'true';
// Drag and drop handlers
container.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.add('drag-over');
});
container.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
});
container.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
processImageFile(files[0], markerId);
}
});
});
}
// Make it globally available
window.initializeImageHandlers = initializeImageHandlers;
/**
* Handle paste event for images
* @param {ClipboardEvent} e - Paste event
*/
function handlePasteImage(e) {
const items = e.clipboardData?.items;
if (!items) return;
// Check if we're in the bulk editor tab
const bulkPanel = document.getElementById('bulk-panel');
if (!bulkPanel || !bulkPanel.classList.contains('show') && !bulkPanel.classList.contains('active')) return;
// Find the focused section marker or the last one
const editor = document.getElementById('bulk-editor');
let sectionMarker = null;
// Try to find marker from current selection
const selection = window.getSelection();
if (selection && selection.anchorNode) {
let currentNode = selection.anchorNode;
// Walk up the DOM to find a section marker
while (currentNode && currentNode !== editor && currentNode !== document.body) {
if (currentNode.nodeType === 1) {
// Check if this is a section marker
if (currentNode.classList && currentNode.classList.contains('section-marker')) {
sectionMarker = currentNode;
break;
}
// Check previous siblings
let prevSibling = currentNode.previousElementSibling;
while (prevSibling) {
if (prevSibling.classList && prevSibling.classList.contains('section-marker')) {
sectionMarker = prevSibling;
break;
}
prevSibling = prevSibling.previousElementSibling;
}
if (sectionMarker) break;
}
currentNode = currentNode.parentNode;
}
}
// If no section marker found from selection, use the last one
if (!sectionMarker) {
const allSectionMarkers = document.querySelectorAll('.section-marker');
if (allSectionMarkers.length > 0) {
sectionMarker = allSectionMarkers[allSectionMarkers.length - 1];
}
}
if (!sectionMarker) return;
const markerId = sectionMarker.dataset.markerId;
if (!markerId) return;
// Check for image in clipboard
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/')) {
e.preventDefault();
e.stopPropagation();
const file = item.getAsFile();
if (file) {
processImageFile(file, markerId);
}
break;
}
}
}
// ==========================================
// MARKER DATA MANAGEMENT
// ==========================================
/**
* Update marker data in state
* @param {string} id - Marker ID
* @param {string} key - Data key
* @param {*} val - Data value
*/
function updateMarkerData(id, key, val) {
if (!markerState[id]) markerState[id] = {};
markerState[id][key] = val;
}
// Make it globally available
window.updateMarkerData = updateMarkerData;
/**
* Get marker data from state
* @param {string} id - Marker ID
* @param {string} key - Data key
* @returns {*} Data value or undefined
*/
function getMarkerData(id, key) {
if (!markerState[id]) return undefined;
return markerState[id][key];
}
/**
* Open TTS text editor modal
* @param {string} markerId - Marker ID
*/
function openTTSEditor(markerId) {
if (!ttsModal) ttsModal = new bootstrap.Modal(document.getElementById('ttsEditModal'));
document.getElementById('currentMarkerId').value = markerId;
const marker = document.getElementById(`marker-${markerId}`);
const allMarkers = Array.from(document.querySelectorAll('.editor-marker'));
if (markerState[markerId] && markerState[markerId].ttsText) {
document.getElementById('ttsTextInput').value = markerState[markerId].ttsText;
} else {
document.getElementById('ttsTextInput').value = extractPlainTextForMarker(marker, allMarkers);
}
ttsModal.show();
}
// Make it globally available
window.openTTSEditor = openTTSEditor;
/**
* Save TTS text from modal
*/
function saveTTSText() {
const markerId = document.getElementById('currentMarkerId').value;
updateMarkerData(markerId, 'ttsText', document.getElementById('ttsTextInput').value);
if (ttsModal) ttsModal.hide();
}
// Make it globally available
window.saveTTSText = saveTTSText;
// ==========================================
// TEXT FORMATTING FUNCTIONS
// ==========================================
/**
* Apply formatting command
* @param {string} command - execCommand name
*/
function applyFormat(command) {
document.execCommand(command, false, null);
}
// Make it globally available
window.applyFormat = applyFormat;
/**
* Format block element
* @param {string} tag - HTML tag name
*/
function formatBlock(tag) {
document.execCommand('formatBlock', false, tag);
}
// Make it globally available
window.formatBlock = formatBlock;
/**
* Normalize section text (clean up formatting)
* @param {string} markerId - Marker ID
*/
function normalizeSection(markerId) {
const marker = document.getElementById(`marker-${markerId}`);
if (!marker) return;
let currentNode = marker.nextSibling;
const nodesToRemove = [];
let collectedText = "";
while (currentNode) {
if (currentNode.nodeType === 1 && currentNode.classList && currentNode.classList.contains('editor-marker')) break;
collectedText += currentNode.textContent + " ";
nodesToRemove.push(currentNode);
currentNode = currentNode.nextSibling;
}
const cleanText = collectedText.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
const p = document.createElement('p');
p.textContent = cleanText || '';
if (!cleanText) p.innerHTML = '<br>';
marker.after(p);
nodesToRemove.forEach(n => n.remove());
}
// Make it globally available
window.normalizeSection = normalizeSection;
/**
* Insert HTML at cursor position
* @param {string} html - HTML string to insert
*/
function insertHtmlAtCursor(html) {
const sel = window.getSelection();
const editor = document.getElementById('bulk-editor');
// Make sure we're focused on the editor
if (!editor.contains(sel.anchorNode)) {
editor.focus();
// Place cursor at the end if not in editor
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
if (sel.getRangeAt && sel.rangeCount) {
let range = sel.getRangeAt(0);
range.deleteContents();
const el = document.createElement("div");
el.innerHTML = html;
let frag = document.createDocumentFragment(), node, lastNode;
while ((node = el.firstChild)) lastNode = frag.appendChild(node);
range.insertNode(frag);
if (lastNode) {
range = range.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}
}
// ==========================================
// TEXT EXTRACTION FUNCTIONS
// ==========================================
/**
* Extract HTML content from marker to next marker
* @param {Element} marker - Marker element
* @param {Element[]} allMarkers - All marker elements
* @returns {string} HTML content
*/
function extractHtmlForMarker(marker, allMarkers = []) {
const editor = document.getElementById('bulk-editor');
if (allMarkers.length === 0) allMarkers = Array.from(document.querySelectorAll('.editor-marker'));
const myIndex = allMarkers.indexOf(marker);
const nextMarker = (myIndex !== -1 && myIndex < allMarkers.length - 1) ? allMarkers[myIndex + 1] : null;
const range = document.createRange();
range.setStartAfter(marker);
if (nextMarker) {
range.setEndBefore(nextMarker);
} else {
range.setEndAfter(editor.lastChild || editor);
}
const frag = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(frag);
return tempDiv.innerHTML;
}
// Make it globally available
window.extractHtmlForMarker = extractHtmlForMarker;
/**
* Extract plain text from marker
* @param {Element} marker - Marker element
* @param {Element[]} allMarkers - All marker elements
* @returns {string} Plain text content
*/
function extractPlainTextForMarker(marker, allMarkers = []) {
const html = extractHtmlForMarker(marker, allMarkers);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
return tempDiv.innerText.replace(/\n{3,}/g, '\n\n').trim();
}
// Make it globally available
window.extractPlainTextForMarker = extractPlainTextForMarker;
/**
* Extract markdown from marker
* @param {Element} marker - Marker element
* @param {Element[]} allMarkers - All marker elements
* @returns {string} Markdown content
*/
function extractMarkdownForMarker(marker, allMarkers = []) {
const html = extractHtmlForMarker(marker, allMarkers);
return htmlToMarkdown(html);
}
// Make it globally available
window.extractMarkdownForMarker = extractMarkdownForMarker;
/**
* Convert HTML to Markdown
* @param {string} html - HTML string
* @returns {string} Markdown string
*/
function htmlToMarkdown(html) {
const temp = document.createElement('div');
temp.innerHTML = html;
temp.querySelectorAll('b, strong').forEach(el => el.replaceWith(`**${el.textContent}**`));
temp.querySelectorAll('i, em').forEach(el => el.replaceWith(`*${el.textContent}*`));
temp.querySelectorAll('h1').forEach(el => el.replaceWith(`# ${el.textContent}\n`));
temp.querySelectorAll('h2').forEach(el => el.replaceWith(`## ${el.textContent}\n`));
temp.querySelectorAll('h3').forEach(el => el.replaceWith(`### ${el.textContent}\n`));
let text = temp.innerHTML;
text = text.replace(/<br\s*\/?>/gi, '\n');
text = text.replace(/<\/p>/gi, '\n\n');
text = text.replace(/<p>/gi, '');
text = text.replace(/<[^>]+>/g, '');
const txt = document.createElement("textarea");
txt.innerHTML = text;
return txt.value.trim();
}
// ==========================================
// SECTION COLLECTION FUNCTION
// ==========================================
/**
* Collect all sections from editor (explicit + implicit)
* @returns {Object[]} Array of section objects
*/
function collectAllSectionsFromEditor() {
const allMarkers = Array.from(document.querySelectorAll('.editor-marker'));
const sections = [];
let currentChapter = 0;
let implicitSectionCounter = {};
for (let idx = 0; idx < allMarkers.length; idx++) {
const marker = allMarkers[idx];
const markerId = marker.dataset.markerId;
// --- Handle Chapter Markers ---
if (marker.classList.contains('chapter-marker')) {
currentChapter = marker.querySelector('input[type="number"]').value;
const chapterVoice = marker.querySelector('select').value;
const nextMarker = (idx + 1 < allMarkers.length) ? allMarkers[idx + 1] : null;
// Check for implicit section (content after chapter without section marker)
if (!nextMarker || nextMarker.classList.contains('chapter-marker')) {
const secHtml = extractHtmlForMarker(marker, allMarkers);
const secDisplay = extractMarkdownForMarker(marker, allMarkers);
const secPlain = extractPlainTextForMarker(marker, allMarkers);
if (secPlain && secPlain.trim().length > 1) {
if (!implicitSectionCounter[currentChapter]) {
implicitSectionCounter[currentChapter] = 1;
}
const secNum = implicitSectionCounter[currentChapter]++;
const playlistTrack = typeof playlist !== 'undefined' ? playlist.find(t => t.chapter == currentChapter && t.section == secNum) : null;
sections.push({
markerId: markerId + '_implicit_' + secNum,
chapter: currentChapter,
section: secNum,
text: secDisplay,
htmlContent: secHtml,
ttsText: secPlain,
voice: chapterVoice,
audioData: playlistTrack?.audioData || '',
audioFormat: playlistTrack?.audioFormat || 'mp3',
transcription: playlistTrack?.transcription || [],
imageData: '',
imageFormat: 'png',
isImplicit: true
});
console.log(`📝 Found implicit section: Ch${currentChapter}.Sec${secNum}`);
}
}
}
// --- Handle Section Markers ---
else if (marker.classList.contains('section-marker')) {
const secId = markerId;
const secNum = marker.querySelector('input[type="number"]').value;
const secVoice = marker.querySelector('select').value;
const secHtml = extractHtmlForMarker(marker, allMarkers);
const secPlain = extractPlainTextForMarker(marker, allMarkers);
const secDisplay = extractMarkdownForMarker(marker, allMarkers);
const secGen = markerState[secId]?.ttsText || secPlain;
// Get image data from marker state
const imageData = markerState[secId]?.imageData || '';
const imageFormat = markerState[secId]?.imageFormat || 'png';
const playlistTrack = typeof playlist !== 'undefined' ? playlist.find(t => t.chapter == currentChapter && t.section == secNum) : null;
sections.push({
markerId: secId,
chapter: currentChapter,
section: secNum,
text: secDisplay,
htmlContent: secHtml,
ttsText: secGen,
voice: secVoice,
audioData: playlistTrack?.audioData || '',
audioFormat: playlistTrack?.audioFormat || 'mp3',
transcription: playlistTrack?.transcription || [],
imageData: imageData,
imageFormat: imageFormat,
isImplicit: false
});
console.log(`📝 Found explicit section: Ch${currentChapter}.Sec${secNum} (image: ${imageData ? 'yes' : 'no'})`);
}
}
return sections;
}
// Make it globally available
window.collectAllSectionsFromEditor = collectAllSectionsFromEditor;
// ==========================================
// SAVE PROJECT FUNCTION
// ==========================================
/**
* Save project without generating audio
*/
async function saveProject() {
const projectName = document.getElementById('bulkProjectName').value.trim() || 'Book-1';
const sections = collectAllSectionsFromEditor();
if (sections.length === 0) {
alert("No sections found. Add chapter and section markers first.");
return;
}
showLoader("Saving Project...", `Saving ${sections.length} sections to "${projectName}"...`);
try {
// Get or create project
const projectId = await getOrCreateProject();
if (!projectId) {
throw new Error('Could not create or get project');
}
// Save all sections
const res = await fetch(`/projects/${projectId}/save_all`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sections: sections })
});
const result = await res.json();
if (result.error) throw new Error(result.error);
await refreshLibraryStats();
hideLoader();
alert(`Project "${projectName}" saved successfully!\n${result.saved_count} sections saved.`);
} catch (e) {
hideLoader();
alert("Error saving project: " + e.message);
console.error('Save project error:', e);
}
}
// Make it globally available
window.saveProject = saveProject;
// ==========================================
// QUILL EDITOR SETUP
// ==========================================
let quill = null;
/**
* Initialize Quill editor
*/
function initQuillEditor() {
const quillContainer = document.getElementById('quill-editor');
if (!quillContainer) return;
quill = new Quill('#quill-editor', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic'],
['clean']
]
},
placeholder: 'Write here...'
});
}
/**
* Generate audio from Quill editor content
*/
async function generateAudio() {
if (!quill) return;
const text = quill.getText().trim();
const voice = document.getElementById('voiceSelect').value;
if (text.length < 5) {
alert("Write some text first.");
return;
}
showLoader("Generating...", "Creating audio and timestamps...");
try {
const res = await fetch('/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ text, voice, save_to_db: true })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
initApp(data);
await refreshLibraryStats();
} catch (e) {
alert(e.message);
} finally {
hideLoader();
}
}
// Make it globally available
window.generateAudio = generateAudio;
// ==========================================
// EXPORT FUNCTIONS
// ==========================================
/**
* Export all sections as zip
*/
async function exportEverything() {
const projectName = document.getElementById('bulkProjectName').value || 'Book-1';
const allSections = collectAllSectionsFromEditor();
if (allSections.length === 0) {
alert("No sections found. Add chapter and section markers first.");
return;
}
showLoader("Exporting...", "Saving all sections and creating zip file...");
try {
const res = await fetch('/export_bulk', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ projectName, files: allSections })
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Export failed');
}
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${projectName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
await refreshLibraryStats();
} catch (e) {
alert("Export error: " + e.message);
} finally {
hideLoader();
}
}
// Make it globally available
window.exportEverything = exportEverything;
/**
* Export single file as zip
*/
async function exportSingle() {
const filename = document.getElementById('exportFilename').value || '1.1_task-1';
if (typeof currentAudioData === 'undefined' || !currentAudioData) {
alert("No project loaded.");
return;
}
showLoader("Exporting...", "Creating zip file...");
try {
const res = await fetch('/export', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
filename,
text: document.getElementById('readerContent').innerText,
transcription: typeof transcriptionData !== 'undefined' ? transcriptionData : [],
audio_data: currentAudioData,
audio_format: typeof currentAudioFormat !== 'undefined' ? currentAudioFormat : 'mp3'
})
});
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${filename}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (e) {
alert(e.message);
} finally {
hideLoader();
}
}
// Make it globally available
window.exportSingle = exportSingle;
// ==========================================
// INITIALIZATION
// ==========================================
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
console.log('📝 Editor module initializing...');
// Populate voice selects
populateVoiceSelects();
// Initialize Quill editor
initQuillEditor();
// Initialize paste handler for images
document.addEventListener('paste', handlePasteImage);
// Initialize image handlers after a short delay
setTimeout(initializeImageHandlers, 500);
// Handle tab switching for floating controls using Bootstrap events
const bulkTab = document.getElementById('bulk-tab');
if (bulkTab) {
bulkTab.addEventListener('shown.bs.tab', function() {
console.log('📝 Bulk tab shown, initializing image handlers...');
setTimeout(initializeImageHandlers, 100);
});
}
console.log('✅ Editor module initialized');
});