1071 lines
37 KiB
JavaScript
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');
|
|
});
|