1580 lines
54 KiB
JavaScript
1580 lines
54 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 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="toolbar-divider"></div>
|
|
|
|
<!-- Formatting Group -->
|
|
<div class="toolbar-group">
|
|
<span class="toolbar-group-label">Format</span>
|
|
<div class="dropdown">
|
|
<button class="toolbar-btn dropdown-toggle" type="button" data-bs-toggle="dropdown" onmousedown="event.preventDefault()" title="Text Style">
|
|
Style
|
|
</button>
|
|
<ul class="dropdown-menu">
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); formatBlock('p')"><i class="bi bi-text-paragraph me-2"></i>Normal</button></li>
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); formatBlock('h1')"><i class="bi bi-type-h1 me-2"></i>Heading 1</button></li>
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); formatBlock('h2')"><i class="bi bi-type-h2 me-2"></i>Heading 2</button></li>
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); formatBlock('h3')"><i class="bi bi-type-h3 me-2"></i>Heading 3</button></li>
|
|
</ul>
|
|
</div>
|
|
<button class="toolbar-btn" title="Bold (Ctrl+B)" onmousedown="event.preventDefault(); applyFormat('bold')">
|
|
<i class="bi bi-type-bold"></i>
|
|
</button>
|
|
<button class="toolbar-btn" title="Italic (Ctrl+I)" onmousedown="event.preventDefault(); applyFormat('italic')">
|
|
<i class="bi bi-type-italic"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Text Tools Group -->
|
|
<div class="toolbar-group">
|
|
<span class="toolbar-group-label">Tools</span>
|
|
<button class="toolbar-btn" title="Normalize - Clear formatting" onmousedown="event.preventDefault(); normalizeSection('${id}')">
|
|
<i class="bi bi-eraser"></i>
|
|
</button>
|
|
<div class="dropdown">
|
|
<button class="toolbar-btn dropdown-toggle" type="button" data-bs-toggle="dropdown" onmousedown="event.preventDefault()" title="Change Case">
|
|
Aa
|
|
</button>
|
|
<ul class="dropdown-menu">
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); changeCase('sentence')"><i class="bi bi-type me-2"></i>Sentence case</button></li>
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); changeCase('lower')"><i class="bi bi-alphabet me-2"></i>lower case</button></li>
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); changeCase('upper')"><i class="bi bi-alphabet-uppercase me-2"></i>UPPER CASE</button></li>
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); changeCase('capitalized')"><i class="bi bi-card-text me-2"></i>Capitalized Case</button></li>
|
|
<li><button class="dropdown-item" type="button" onmousedown="event.preventDefault(); changeCase('title')"><i class="bi bi-blockquote-left me-2"></i>Title Case</button></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Media Group -->
|
|
<div class="toolbar-group">
|
|
<span class="toolbar-group-label">Media</span>
|
|
<button class="toolbar-btn image-btn" onclick="triggerImageUpload('${id}')" title="Add Image">
|
|
<i class="bi bi-image"></i>
|
|
</button>
|
|
<button class="toolbar-btn tts-btn" onclick="openTTSEditor('${id}')" title="Edit TTS Text">
|
|
<i class="bi bi-pencil-square"></i> TTS
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<!-- Action Buttons -->
|
|
<button class="toolbar-action-btn btn-primary" onclick="generateMarkerAudio('${id}')" title="Generate Audio">
|
|
<i class="bi bi-play-circle-fill"></i>
|
|
<span>Generate</span>
|
|
</button>
|
|
<button class="toolbar-action-btn btn-outline-danger" title="Remove Section" onclick="removeMarker('${id}')">
|
|
<i class="bi bi-trash3"></i>
|
|
</button>
|
|
`;
|
|
|
|
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>
|
|
`;
|
|
} else {
|
|
// Chapter marker - simpler controls
|
|
extraControls = `
|
|
<div class="toolbar-divider"></div>
|
|
<button class="toolbar-action-btn btn-danger" onclick="generateMarkerAudio('${id}')" title="Generate All Sections">
|
|
<i class="bi bi-play-circle-fill"></i>
|
|
<span>Generate All</span>
|
|
</button>
|
|
<button class="toolbar-action-btn btn-outline-danger" title="Remove Chapter" onclick="removeMarker('${id}')">
|
|
<i class="bi bi-trash3"></i>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
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">
|
|
<!-- Number & Voice -->
|
|
<div class="marker-number-group input-group input-group-sm">
|
|
<span class="input-group-text">#</span>
|
|
<input type="number" class="form-control marker-number-input" value="${num}" onchange="updateMarkerData('${id}', 'num', this.value)">
|
|
</div>
|
|
<select class="form-select voice-select" onchange="updateMarkerData('${id}', 'voice', this.value)">
|
|
${voiceOpts}
|
|
</select>
|
|
${extraControls}
|
|
</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;
|
|
|
|
// ==========================================
|
|
// CASE CHANGE FUNCTIONS
|
|
// ==========================================
|
|
|
|
/**
|
|
* Change the case of selected text
|
|
* @param {string} caseType - Type of case: 'sentence', 'lower', 'upper', 'capitalized', 'title'
|
|
*/
|
|
function changeCase(caseType) {
|
|
const selection = window.getSelection();
|
|
|
|
// Check if there's a valid selection with actual content
|
|
if (!selection || selection.isCollapsed || selection.toString().trim().length === 0) {
|
|
alert('Please select some text first to change its case.');
|
|
return;
|
|
}
|
|
|
|
const selectedText = selection.toString();
|
|
let transformedText = '';
|
|
|
|
switch (caseType) {
|
|
case 'sentence':
|
|
transformedText = toSentenceCase(selectedText);
|
|
break;
|
|
case 'lower':
|
|
transformedText = selectedText.toLowerCase();
|
|
break;
|
|
case 'upper':
|
|
transformedText = selectedText.toUpperCase();
|
|
break;
|
|
case 'capitalized':
|
|
transformedText = toCapitalizedCase(selectedText);
|
|
break;
|
|
case 'title':
|
|
transformedText = toTitleCase(selectedText);
|
|
break;
|
|
default:
|
|
transformedText = selectedText;
|
|
}
|
|
|
|
// Replace the selected text with transformed text
|
|
replaceSelectedText(selection, transformedText);
|
|
}
|
|
|
|
// Make it globally available
|
|
window.changeCase = changeCase;
|
|
|
|
/**
|
|
* Convert text to Sentence case
|
|
* First letter of each sentence is capitalized, rest is lowercase
|
|
* @param {string} text - Input text
|
|
* @returns {string} Transformed text
|
|
*/
|
|
function toSentenceCase(text) {
|
|
// First convert everything to lowercase
|
|
let result = text.toLowerCase();
|
|
|
|
// Capitalize the first letter
|
|
result = result.charAt(0).toUpperCase() + result.slice(1);
|
|
|
|
// Capitalize letter after sentence-ending punctuation (. ! ?)
|
|
result = result.replace(/([.!?]\s*)([a-z])/g, (match, punctuation, letter) => {
|
|
return punctuation + letter.toUpperCase();
|
|
});
|
|
|
|
// Also handle newlines as sentence breaks
|
|
result = result.replace(/(\n\s*)([a-z])/g, (match, newline, letter) => {
|
|
return newline + letter.toUpperCase();
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Convert text to Capitalized Case
|
|
* First letter of every word is capitalized
|
|
* @param {string} text - Input text
|
|
* @returns {string} Transformed text
|
|
*/
|
|
function toCapitalizedCase(text) {
|
|
return text.toLowerCase().replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
}
|
|
|
|
/**
|
|
* Convert text to Title Case
|
|
* Like Capitalized Case but keeps small words (articles, prepositions, conjunctions) lowercase
|
|
* unless they're the first or last word
|
|
* @param {string} text - Input text
|
|
* @returns {string} Transformed text
|
|
*/
|
|
function toTitleCase(text) {
|
|
// Words that should remain lowercase (unless first or last)
|
|
const smallWords = [
|
|
'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'nor',
|
|
'of', 'on', 'or', 'so', 'the', 'to', 'up', 'yet', 'is', 'be',
|
|
'with', 'from', 'into', 'over', 'after', 'under', 'above'
|
|
];
|
|
|
|
const words = text.toLowerCase().split(/(\s+)/); // Split but keep whitespace
|
|
|
|
let isFirstWord = true;
|
|
let result = [];
|
|
|
|
for (let i = 0; i < words.length; i++) {
|
|
const word = words[i];
|
|
|
|
// If it's whitespace, just add it
|
|
if (/^\s+$/.test(word)) {
|
|
result.push(word);
|
|
continue;
|
|
}
|
|
|
|
// Check if this is the last actual word
|
|
let isLastWord = true;
|
|
for (let j = i + 1; j < words.length; j++) {
|
|
if (!/^\s+$/.test(words[j])) {
|
|
isLastWord = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Capitalize if it's first word, last word, or not a small word
|
|
if (isFirstWord || isLastWord || !smallWords.includes(word.toLowerCase())) {
|
|
result.push(word.charAt(0).toUpperCase() + word.slice(1));
|
|
} else {
|
|
result.push(word);
|
|
}
|
|
|
|
isFirstWord = false;
|
|
}
|
|
|
|
return result.join('');
|
|
}
|
|
|
|
/**
|
|
* Replace selected text with new text while preserving cursor position
|
|
* @param {Selection} selection - Current selection
|
|
* @param {string} newText - Text to insert
|
|
*/
|
|
function replaceSelectedText(selection, newText) {
|
|
if (!selection.rangeCount) return;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
// Delete the selected content
|
|
range.deleteContents();
|
|
|
|
// Create a text node with the new text
|
|
const textNode = document.createTextNode(newText);
|
|
|
|
// Insert the new text
|
|
range.insertNode(textNode);
|
|
|
|
// Move cursor to end of inserted text
|
|
range.setStartAfter(textNode);
|
|
range.setEndAfter(textNode);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
|
|
// ==========================================
|
|
// NORMALIZE FUNCTIONS
|
|
// ==========================================
|
|
|
|
/**
|
|
* Normalize section text (clean up formatting)
|
|
* If text is selected, only normalize the selection
|
|
* If no text is selected, normalize the entire section
|
|
* @param {string} markerId - Marker ID
|
|
*/
|
|
function normalizeSection(markerId) {
|
|
const selection = window.getSelection();
|
|
|
|
// Check if there's a valid selection with actual content
|
|
if (selection && !selection.isCollapsed && selection.toString().trim().length > 0) {
|
|
// Normalize only the selected text
|
|
normalizeSelection(selection, markerId);
|
|
} else {
|
|
// No selection - normalize entire section
|
|
normalizeEntireSection(markerId);
|
|
}
|
|
}
|
|
|
|
// Make it globally available
|
|
window.normalizeSection = normalizeSection;
|
|
|
|
/**
|
|
* Normalize only the selected text - removes all formatting
|
|
* @param {Selection} selection - Current selection
|
|
* @param {string} markerId - Marker ID for context
|
|
*/
|
|
function normalizeSelection(selection, markerId) {
|
|
if (!selection.rangeCount) return;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
// Get the selected text content (plain text, no formatting)
|
|
const selectedText = selection.toString();
|
|
|
|
// Clean the text: remove extra whitespace, normalize line breaks
|
|
const cleanText = selectedText
|
|
.replace(/[\r\n]+/g, ' ') // Replace line breaks with spaces
|
|
.replace(/\s+/g, ' ') // Collapse multiple spaces
|
|
.trim();
|
|
|
|
if (!cleanText) return;
|
|
|
|
// Get the start and end block elements
|
|
const startContainer = range.startContainer;
|
|
const endContainer = range.endContainer;
|
|
const startBlock = getParentBlock(startContainer);
|
|
const endBlock = getParentBlock(endContainer);
|
|
|
|
// Check if selection is entirely within a single formatting block (h1, h2, h3, etc.)
|
|
if (startBlock && startBlock === endBlock && isFormattingBlock(startBlock)) {
|
|
const blockText = startBlock.textContent.trim();
|
|
const selectedTrimmed = cleanText;
|
|
|
|
// If we're selecting most or all of the block content, replace the entire block
|
|
if (blockText === selectedTrimmed || selectedTrimmed.length >= blockText.length * 0.8) {
|
|
// Replace the entire block with a paragraph
|
|
const p = document.createElement('p');
|
|
p.textContent = cleanText;
|
|
startBlock.parentNode.replaceChild(p, startBlock);
|
|
|
|
// Set cursor at end of new paragraph
|
|
const newRange = document.createRange();
|
|
newRange.selectNodeContents(p);
|
|
newRange.collapse(false);
|
|
selection.removeAllRanges();
|
|
selection.addRange(newRange);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check if selection spans multiple blocks
|
|
if (startBlock !== endBlock) {
|
|
// Handle multi-block selection
|
|
normalizeMultiBlockSelection(range, selection, cleanText);
|
|
return;
|
|
}
|
|
|
|
// For inline formatting (bold, italic, etc.) within a single block
|
|
// First, try using execCommand to remove formatting
|
|
try {
|
|
// Store the clean text before manipulation
|
|
const textToInsert = cleanText;
|
|
|
|
// Remove inline formatting using execCommand
|
|
document.execCommand('removeFormat', false, null);
|
|
|
|
// After removeFormat, check if we need to handle any remaining issues
|
|
const newSelection = window.getSelection();
|
|
if (newSelection.rangeCount > 0) {
|
|
const newRange = newSelection.getRangeAt(0);
|
|
const currentText = newSelection.toString();
|
|
|
|
// If the text is still the same (formatting was removed), we're done
|
|
// But if we're inside a heading, we need to convert it
|
|
const parentBlock = getParentBlock(newRange.commonAncestorContainer);
|
|
if (parentBlock && isFormattingBlock(parentBlock)) {
|
|
// Convert the heading to a paragraph
|
|
const p = document.createElement('p');
|
|
p.innerHTML = parentBlock.innerHTML;
|
|
parentBlock.parentNode.replaceChild(p, parentBlock);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log('execCommand failed, using manual approach:', e);
|
|
manualNormalizeSelection(range, selection, cleanText);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle normalization when selection spans multiple blocks
|
|
* @param {Range} range - Selection range
|
|
* @param {Selection} selection - Current selection
|
|
* @param {string} cleanText - Cleaned text to insert
|
|
*/
|
|
function normalizeMultiBlockSelection(range, selection, cleanText) {
|
|
const startBlock = getParentBlock(range.startContainer);
|
|
const endBlock = getParentBlock(range.endContainer);
|
|
const editor = document.getElementById('bulk-editor');
|
|
|
|
if (!startBlock || !endBlock || !editor) {
|
|
manualNormalizeSelection(range, selection, cleanText);
|
|
return;
|
|
}
|
|
|
|
// Collect all blocks between start and end
|
|
const blocksToRemove = [];
|
|
let currentBlock = startBlock.nextElementSibling;
|
|
|
|
while (currentBlock && currentBlock !== endBlock) {
|
|
// Don't remove editor markers
|
|
if (!currentBlock.classList || !currentBlock.classList.contains('editor-marker')) {
|
|
blocksToRemove.push(currentBlock);
|
|
}
|
|
currentBlock = currentBlock.nextElementSibling;
|
|
}
|
|
|
|
// Create a new paragraph with the clean text
|
|
const p = document.createElement('p');
|
|
p.textContent = cleanText;
|
|
|
|
// Replace the start block with the new paragraph
|
|
startBlock.parentNode.replaceChild(p, startBlock);
|
|
|
|
// Remove intermediate blocks
|
|
blocksToRemove.forEach(block => block.remove());
|
|
|
|
// Remove the end block if it's different from start
|
|
if (endBlock && endBlock !== startBlock && endBlock.parentNode) {
|
|
endBlock.remove();
|
|
}
|
|
|
|
// Set cursor at end of new paragraph
|
|
const newRange = document.createRange();
|
|
newRange.selectNodeContents(p);
|
|
newRange.collapse(false);
|
|
selection.removeAllRanges();
|
|
selection.addRange(newRange);
|
|
}
|
|
|
|
/**
|
|
* Manual fallback for normalizing selection
|
|
* @param {Range} range - Selection range
|
|
* @param {Selection} selection - Current selection
|
|
* @param {string} cleanText - Cleaned text to insert
|
|
*/
|
|
function manualNormalizeSelection(range, selection, cleanText) {
|
|
const startBlock = getParentBlock(range.startContainer);
|
|
|
|
// Delete the selected content
|
|
range.deleteContents();
|
|
|
|
// If we're in a formatting block that's now empty or nearly empty, replace it
|
|
if (startBlock && isFormattingBlock(startBlock)) {
|
|
const remainingText = startBlock.textContent.trim();
|
|
if (remainingText.length === 0) {
|
|
// Block is empty, replace with paragraph containing clean text
|
|
const p = document.createElement('p');
|
|
p.textContent = cleanText;
|
|
startBlock.parentNode.replaceChild(p, startBlock);
|
|
|
|
// Set cursor at end
|
|
const newRange = document.createRange();
|
|
newRange.selectNodeContents(p);
|
|
newRange.collapse(false);
|
|
selection.removeAllRanges();
|
|
selection.addRange(newRange);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Insert clean text node
|
|
const textNode = document.createTextNode(cleanText);
|
|
range.insertNode(textNode);
|
|
|
|
// Clean up any empty formatting elements
|
|
const commonAncestor = range.commonAncestorContainer;
|
|
cleanEmptyFormattingElements(commonAncestor);
|
|
|
|
// Also clean parent elements
|
|
if (commonAncestor.parentNode) {
|
|
cleanEmptyFormattingElements(commonAncestor.parentNode);
|
|
}
|
|
|
|
// Move cursor to end of inserted text
|
|
const newRange = document.createRange();
|
|
newRange.setStartAfter(textNode);
|
|
newRange.setEndAfter(textNode);
|
|
selection.removeAllRanges();
|
|
selection.addRange(newRange);
|
|
}
|
|
|
|
/**
|
|
* Get the parent block element of a node
|
|
* @param {Node} node - The node to find parent block for
|
|
* @returns {Element|null} The parent block element or null
|
|
*/
|
|
function getParentBlock(node) {
|
|
const blockTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'DIV', 'LI', 'BLOCKQUOTE'];
|
|
let current = node;
|
|
|
|
// If it's a text node, start with parent
|
|
while (current && current.nodeType !== 1) {
|
|
current = current.parentNode;
|
|
}
|
|
|
|
while (current) {
|
|
if (current.nodeType === 1 && blockTags.includes(current.tagName)) {
|
|
return current;
|
|
}
|
|
// Stop at the editor boundary
|
|
if (current.id === 'bulk-editor') {
|
|
return null;
|
|
}
|
|
current = current.parentNode;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if an element is a formatting block (heading, etc.)
|
|
* @param {Element} element - Element to check
|
|
* @returns {boolean} True if it's a formatting block
|
|
*/
|
|
function isFormattingBlock(element) {
|
|
if (!element || element.nodeType !== 1) return false;
|
|
const formattingTags = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE'];
|
|
return formattingTags.includes(element.tagName);
|
|
}
|
|
|
|
/**
|
|
* Clean up empty formatting elements after deletion
|
|
* @param {Node} container - Container to clean
|
|
*/
|
|
function cleanEmptyFormattingElements(container) {
|
|
if (!container || container.nodeType !== 1) return;
|
|
|
|
const formattingTags = ['B', 'STRONG', 'I', 'EM', 'U', 'STRIKE', 'S', 'SPAN', 'FONT'];
|
|
|
|
// First pass: clean direct children
|
|
formattingTags.forEach(tag => {
|
|
let elements;
|
|
try {
|
|
elements = container.querySelectorAll ? container.querySelectorAll(tag) : [];
|
|
} catch (e) {
|
|
elements = [];
|
|
}
|
|
|
|
elements.forEach(el => {
|
|
// Remove if empty or contains only whitespace
|
|
if (!el.textContent.trim()) {
|
|
el.remove();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Second pass: unwrap formatting elements that contain only text (to flatten the structure)
|
|
formattingTags.forEach(tag => {
|
|
let elements;
|
|
try {
|
|
elements = container.getElementsByTagName ? container.getElementsByTagName(tag) : [];
|
|
} catch (e) {
|
|
elements = [];
|
|
}
|
|
|
|
// Convert to array since we'll be modifying the DOM
|
|
Array.from(elements).forEach(el => {
|
|
// If this element only contains a text node, unwrap it
|
|
if (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3) {
|
|
const textContent = el.textContent;
|
|
const textNode = document.createTextNode(textContent);
|
|
el.parentNode.replaceChild(textNode, el);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalize the entire section after a marker
|
|
* @param {string} markerId - Marker ID
|
|
*/
|
|
function normalizeEntireSection(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());
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
});
|