/** * 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 => ``).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 => `` ).join(''); // Section-specific controls including image upload let extraControls = ''; let imageSection = ''; if (type === 'section') { extraControls = `
`; imageSection = `
Drop image here, click to browse, or paste from clipboard
Section image
`; } return `
${title}
#
${extraControls}
${imageSection}


`; } // 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 = '
'; 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(//gi, '\n'); text = text.replace(/<\/p>/gi, '\n\n'); text = text.replace(/

/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'); });