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