Files
audiobook-maker-pro-v4.2/static/js/markdown-editor.js
2026-05-22 18:28:47 +06:00

1164 lines
38 KiB
JavaScript

/**
* Markdown Editor Module
* UPDATED: Data-driven section markers (stored in editorBlocks array)
* REMOVED: duplicate renderDocumentBlocks (now only in pdf-handler.js)
* FIXED: repairAllNewBlockLines no longer removes lines after section-dividers
* FIXED: removeSection is data-driven, no orphan dividers
* ADDED: addSectionAtLine function for Custom Section Marker button
*/
// ============================================
// Editor State
// ============================================
let editorBlocks = [];
let activeBlockId = null;
let isToolbarClick = false;
// Panel state
let panelState = {
startingBlockId: null,
blockCount: 10,
voice: 'af_heart',
pickMode: false
};
// ============================================
// Initialization
// ============================================
function initMarkdownEditor() {
const editor = document.getElementById('markdownEditor');
editor.addEventListener('click', function(e) {
// Pick mode: clicking a block sets it as starting block
if (panelState.pickMode) {
const blockEl = e.target.closest('.md-block');
if (blockEl && !blockEl.classList.contains('editing')) {
setStartingBlock(blockEl.id);
exitPickMode();
e.preventDefault();
e.stopPropagation();
return;
}
}
if (e.target === editor || e.target.id === 'emptyEditorMessage') {
if (editorBlocks.length === 0) {
addBlock('paragraph', '');
repairAllNewBlockLines();
updatePanelUI();
}
}
});
document.addEventListener('keydown', handleGlobalKeydown);
document.addEventListener('mousedown', function(e) {
if (e.target.closest('.md-block-toolbar') ||
e.target.closest('.dropdown-menu') ||
e.target.closest('.toolbar-btn') ||
e.target.closest('.btn-merge-section')) {
isToolbarClick = true;
} else {
isToolbarClick = false;
}
});
restorePanelSettings();
console.log('📝 Markdown editor initialized (data-driven sections)');
}
// ============================================
// Audiobook Maker Panel
// ============================================
function initAudiobookMakerPanel() {
updatePanelUI();
}
function updatePanelUI() {
const textBlocks = getTextBlocks();
const totalBlocks = textBlocks.length;
// Update total blocks stat with NULL checks
const totalEl = document.getElementById('ampTotalBlocks');
if (totalEl) totalEl.textContent = totalBlocks;
const genCount = textBlocks.filter(b => {
const data = editorBlocks.find(eb => eb.id === b.id);
return data && data.audio_data;
}).length;
const genEl = document.getElementById('ampGeneratedBlocks');
if (genEl) genEl.textContent = genCount;
const remainEl = document.getElementById('ampRemainingBlocks');
if (remainEl) remainEl.textContent = (totalBlocks - genCount);
// Validate starting block
if (!panelState.startingBlockId || !document.getElementById(panelState.startingBlockId)) {
if (textBlocks.length > 0) {
panelState.startingBlockId = textBlocks[0].id;
} else {
panelState.startingBlockId = null;
}
}
updateStartingBlockDisplay();
updateBlockCountLimits();
updateBlockHighlights();
}
function getTextBlocks() {
const editor = document.getElementById('markdownEditor');
if (!editor) return [];
const allBlocks = editor.querySelectorAll('.md-block');
const textBlocks = [];
allBlocks.forEach(block => {
const blockType = block.dataset.blockType || 'paragraph';
if (blockType !== 'image') {
const textarea = block.querySelector('.md-block-textarea');
const content = textarea ? textarea.value.trim() : '';
const isImageContent = content.startsWith('![') && content.includes('](');
if (!isImageContent) {
textBlocks.push(block);
}
}
});
return textBlocks;
}
function getAllContentBlocks() {
const editor = document.getElementById('markdownEditor');
if (!editor) return [];
return Array.from(editor.querySelectorAll('.md-block'));
}
function getTextBlockIndex(blockId) {
const textBlocks = getTextBlocks();
return textBlocks.findIndex(b => b.id === blockId);
}
function updateStartingBlockDisplay() {
const numEl = document.getElementById('ampStartBlockNum');
if (!numEl) return;
if (panelState.startingBlockId) {
const idx = getTextBlockIndex(panelState.startingBlockId);
numEl.textContent = idx >= 0 ? (idx + 1) : '-';
} else {
numEl.textContent = '-';
}
}
function updateBlockCountLimits() {
const input = document.getElementById('ampBlockCount');
if (!input) return;
const textBlocks = getTextBlocks();
const startIdx = panelState.startingBlockId ? getTextBlockIndex(panelState.startingBlockId) : 0;
let remaining = 0;
if (startIdx >= 0) {
remaining = textBlocks.length - startIdx;
}
const maxVal = Math.max(1, remaining);
input.max = maxVal;
if (panelState.blockCount > maxVal) panelState.blockCount = maxVal;
if (panelState.blockCount < 1 && maxVal >= 1) panelState.blockCount = Math.min(10, maxVal);
input.value = panelState.blockCount;
}
function updateBlockHighlights() {
document.querySelectorAll('.md-block.starting-block').forEach(b => b.classList.remove('starting-block'));
document.querySelectorAll('.md-block.in-gen-range').forEach(b => b.classList.remove('in-gen-range'));
if (!panelState.startingBlockId) return;
const startEl = document.getElementById(panelState.startingBlockId);
if (!startEl) return;
startEl.classList.add('starting-block');
const textBlocks = getTextBlocks();
const startIdx = getTextBlockIndex(panelState.startingBlockId);
if (startIdx >= 0) {
const endIdx = Math.min(startIdx + panelState.blockCount, textBlocks.length);
for (let i = startIdx; i < endIdx; i++) {
if (i !== startIdx) {
textBlocks[i].classList.add('in-gen-range');
}
}
}
}
function setStartingBlock(blockId) {
panelState.startingBlockId = blockId;
updatePanelUI();
savePanelSettings();
}
function enterPickMode() {
panelState.pickMode = true;
const editor = document.getElementById('markdownEditor');
if (editor) editor.classList.add('editor-pick-mode');
const indicator = document.getElementById('ampStartIndicator');
if (indicator) indicator.classList.add('pick-mode');
}
function exitPickMode() {
panelState.pickMode = false;
const editor = document.getElementById('markdownEditor');
if (editor) editor.classList.remove('editor-pick-mode');
const indicator = document.getElementById('ampStartIndicator');
if (indicator) indicator.classList.remove('pick-mode');
}
function togglePickMode() {
if (panelState.pickMode) {
exitPickMode();
} else {
enterPickMode();
}
}
function handleBlockCountChange(value) {
const num = parseInt(value);
if (isNaN(num) || num < 1) return;
const input = document.getElementById('ampBlockCount');
const maxVal = parseInt(input.max) || 999;
panelState.blockCount = Math.min(num, maxVal);
if (input) input.value = panelState.blockCount;
updateBlockHighlights();
savePanelSettings();
}
function adjustBlockCount(delta) {
const input = document.getElementById('ampBlockCount');
const current = input ? (parseInt(input.value) || 1) : 1;
const maxVal = input ? (parseInt(input.max) || 999) : 999;
const newVal = Math.max(1, Math.min(current + delta, maxVal));
panelState.blockCount = newVal;
if (input) input.value = newVal;
updateBlockHighlights();
savePanelSettings();
}
function handleVoiceChange(value) {
panelState.voice = value;
savePanelSettings();
}
function savePanelSettings() {
sessionStorage.setItem('ampSettings', JSON.stringify({
blockCount: panelState.blockCount,
voice: panelState.voice
}));
}
function restorePanelSettings() {
const saved = sessionStorage.getItem('ampSettings');
if (saved) {
try {
const settings = JSON.parse(saved);
if (settings.blockCount) panelState.blockCount = settings.blockCount;
if (settings.voice) panelState.voice = settings.voice;
} catch(e) { /* ignore */ }
}
}
function advanceStartingBlockAfterGeneration(generatedCount) {
const textBlocks = getTextBlocks();
const startIdx = getTextBlockIndex(panelState.startingBlockId);
if (startIdx >= 0) {
const nextIdx = startIdx + generatedCount;
if (nextIdx < textBlocks.length) {
panelState.startingBlockId = textBlocks[nextIdx].id;
} else {
if (textBlocks.length > 0) {
panelState.startingBlockId = textBlocks[textBlocks.length - 1].id;
}
}
}
updatePanelUI();
savePanelSettings();
}
// ============================================
// DATA-DRIVEN Section Marker System
// ============================================
function makeSectionStart(blockId, title = null) {
const blockData = editorBlocks.find(b => b.id === blockId);
if (!blockData) return;
if (blockData.sectionStart) return;
const sectionNum = editorBlocks.filter(b => b.sectionStart).length + 1;
blockData.sectionStart = true;
blockData.sectionName = title || `Section ${sectionNum}`;
renderAllSectionDividers();
renderDocumentOutline();
}
function removeSection(blockId) {
const blockData = editorBlocks.find(b => b.id === blockId);
if (blockData) {
blockData.sectionStart = false;
blockData.sectionName = '';
}
renderAllSectionDividers();
renderDocumentOutline();
}
function renderAllSectionDividers() {
const editor = document.getElementById('markdownEditor');
if (!editor) return;
editor.querySelectorAll('.section-divider').forEach(d => d.remove());
for (const blockData of editorBlocks) {
if (!blockData.sectionStart) continue;
const blockEl = document.getElementById(blockData.id);
if (!blockEl) continue;
const divider = document.createElement('div');
divider.className = 'section-divider';
divider.dataset.targetBlock = blockData.id;
divider.innerHTML = `
<div class="divider-line"></div>
<div class="divider-content">
<span class="section-title" contenteditable="true"
onblur="handleSectionTitleEdit('${blockData.id}', this.textContent)">${escapeHtml(blockData.sectionName || 'Section')}</span>
<button class="btn-merge-section" onclick="removeSection('${blockData.id}')" title="Merge with previous section">
<i class="bi bi-link-45deg"></i> Merge Section
</button>
</div>
<div class="divider-line"></div>
`;
blockEl.before(divider);
}
}
function handleSectionTitleEdit(blockId, newTitle) {
const blockData = editorBlocks.find(b => b.id === blockId);
if (blockData) {
blockData.sectionName = newTitle.trim() || 'Section';
}
renderDocumentOutline();
}
function renderDocumentOutline() {
const outline = document.getElementById('documentOutlineList');
if (!outline) return;
const sections = editorBlocks.filter(b => b.sectionStart);
if (sections.length === 0) {
outline.innerHTML = '<li class="text-muted small">No sections found.</li>';
return;
}
outline.innerHTML = '';
sections.forEach((blockData) => {
const title = blockData.sectionName || 'Section';
const li = document.createElement('li');
li.textContent = title;
li.title = title;
li.onclick = () => {
const targetEl = document.getElementById(blockData.id);
if (targetEl) {
targetEl.scrollIntoView({behavior: 'smooth', block: 'center'});
}
};
outline.appendChild(li);
});
}
// ============================================
// Block Merge & Split Logic
// ============================================
function mergeBlockUp(blockId) {
const block = document.getElementById(blockId);
if (!block) return;
let prevBlock = block.previousElementSibling;
while (prevBlock && !prevBlock.classList.contains('md-block')) {
prevBlock = prevBlock.previousElementSibling;
}
if (!prevBlock) {
showNotification('No previous text block found to merge with.', 'warning');
return;
}
const currentTextarea = block.querySelector('.md-block-textarea');
const prevTextarea = prevBlock.querySelector('.md-block-textarea');
if (!currentTextarea || !prevTextarea) return;
prevTextarea.value = prevTextarea.value.trim() + " " + currentTextarea.value.trim();
const currentData = editorBlocks.find(b => b.id === blockId);
const prevData = editorBlocks.find(b => b.id === prevBlock.id);
if (currentData && currentData.sectionStart && prevData) {
if (!prevData.sectionStart) {
prevData.sectionStart = true;
prevData.sectionName = currentData.sectionName;
}
}
exitEditMode(prevBlock.id);
deleteBlock(blockId);
showNotification('Merged with previous block', 'info');
}
function mergeBlockDown(blockId) {
const block = document.getElementById(blockId);
if (!block) return;
let nextBlock = block.nextElementSibling;
while (nextBlock && !nextBlock.classList.contains('md-block')) {
nextBlock = nextBlock.nextElementSibling;
}
if (!nextBlock) {
showNotification('No next text block found to merge with.', 'warning');
return;
}
const currentTextarea = block.querySelector('.md-block-textarea');
const nextTextarea = nextBlock.querySelector('.md-block-textarea');
if (!currentTextarea || !nextTextarea) return;
currentTextarea.value = currentTextarea.value.trim() + " " + nextTextarea.value.trim();
exitEditMode(blockId);
deleteBlock(nextBlock.id);
showNotification('Merged with next block', 'info');
}
function splitBlock(blockId) {
const block = document.getElementById(blockId);
if (!block) return;
const textarea = block.querySelector('.md-block-textarea');
if (!textarea) return;
const pos = textarea.selectionStart;
const text = textarea.value;
const part1 = text.substring(0, pos).trim();
const part2 = text.substring(pos).trim();
if (!part2) {
showNotification('Cursor is at the end. Nothing to split.', 'warning');
return;
}
textarea.value = part1;
exitEditMode(blockId);
const newBlockId = addBlock('paragraph', part2, block);
repairAllNewBlockLines();
enterEditMode(newBlockId);
showNotification('Block split successfully', 'success');
}
// ============================================
// Block Management
// ============================================
function addBlock(type = 'paragraph', content = '', afterElement = null, images = []) {
const blockId = 'block_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
const block = document.createElement('div');
block.className = 'md-block';
block.id = blockId;
block.dataset.blockId = blockId;
block.dataset.blockType = type;
block.dataset.ttsText = '';
const renderedContent = renderBlockContent(type, content, blockId, images);
const isImageBlock = (type === 'image');
block.innerHTML = `
${!isImageBlock ? `
<div class="md-block-toolbar">
<button class="toolbar-btn" onmousedown="event.preventDefault(); applyFormat('bold')" title="Bold">
<i class="bi bi-type-bold"></i>
</button>
<button class="toolbar-btn" onmousedown="event.preventDefault(); applyFormat('italic')" title="Italic">
<i class="bi bi-type-italic"></i>
</button>
<div class="toolbar-divider"></div>
<button class="toolbar-btn action-btn-text" onmousedown="event.preventDefault(); splitBlock('${blockId}')" title="Split block at cursor position">
<i class="bi bi-scissors me-1"></i>Split
</button>
<div class="toolbar-divider"></div>
<button class="toolbar-btn action-btn-text text-primary" onmousedown="event.preventDefault(); makeSectionStart('${blockId}')" title="Mark as section start">
<i class="bi bi-plus-circle me-1"></i>Section
</button>
</div>` : ''}
<div class="block-actions-indicator">
${!isImageBlock ? `
<button class="action-indicator-btn" onclick="event.stopPropagation(); mergeBlockUp('${blockId}')" title="Merge Up">
<i class="bi bi-arrow-up-square"></i>
</button>
<button class="action-indicator-btn" onclick="event.stopPropagation(); mergeBlockDown('${blockId}')" title="Merge Down">
<i class="bi bi-arrow-down-square"></i>
</button>
<button class="action-indicator-btn edit-block-btn" onclick="enterEditMode('${blockId}')" title="Edit">
<i class="bi bi-pencil"></i>
</button>` : ''}
<button class="action-indicator-btn delete-block-btn" onclick="deleteBlock('${blockId}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
${!isImageBlock ? `
<div class="audio-indicator no-audio" title="No audio generated">
<i class="bi bi-soundwave"></i>
</div>` : ''}
<div class="md-block-content">${renderedContent}</div>
<div class="md-block-edit">
<textarea class="md-block-textarea"
placeholder="Start typing..."
oninput="handleBlockInput(event, '${blockId}')"
onkeydown="handleBlockKeydown(event, '${blockId}')"
onblur="handleBlockBlur(event, '${blockId}')">${escapeHtml(content)}</textarea>
</div>
`;
const editor = document.getElementById('markdownEditor');
if (afterElement) {
if (afterElement.classList.contains('new-block-line')) {
afterElement.after(block);
} else {
const nextSibling = afterElement.nextElementSibling;
if (nextSibling && nextSibling.classList.contains('new-block-line')) {
nextSibling.after(block);
} else {
afterElement.after(block);
}
}
} else {
editor?.appendChild(block);
}
editorBlocks.push({
id: blockId,
type: type,
content: content,
images: images,
sectionStart: false,
sectionName: '',
audio_data: null,
audio_format: null,
transcription: null,
tts_text: ''
});
if (!content && type !== 'image') {
setTimeout(() => enterEditMode(blockId), 100);
}
setTimeout(() => updatePanelUI(), 50);
return blockId;
}
function deleteBlock(blockId) {
const block = document.getElementById(blockId);
if (!block) return;
if (activeBlockId === blockId) {
activeBlockId = null;
}
if (panelState.startingBlockId === blockId) {
const textBlocks = getTextBlocks();
const idx = textBlocks.findIndex(b => b.id === blockId);
if (idx >= 0 && idx + 1 < textBlocks.length) {
panelState.startingBlockId = textBlocks[idx + 1].id;
} else if (idx > 0) {
panelState.startingBlockId = textBlocks[idx - 1].id;
} else {
panelState.startingBlockId = null;
}
}
const blockData = editorBlocks.find(b => b.id === blockId);
const hadSection = blockData && blockData.sectionStart;
const nextLine = block.nextElementSibling;
if (nextLine && nextLine.classList.contains('new-block-line')) {
nextLine.remove();
}
const divider = document.querySelector(`.section-divider[data-target-block="${blockId}"]`);
if (divider) divider.remove();
block.remove();
editorBlocks = editorBlocks.filter(b => b.id !== blockId);
repairAllNewBlockLines();
if (hadSection) {
renderAllSectionDividers();
renderDocumentOutline();
}
checkEmptyEditor();
updatePanelUI();
}
function enterEditMode(blockId) {
if (activeBlockId && activeBlockId !== blockId) {
exitEditMode(activeBlockId);
}
const block = document.getElementById(blockId);
if (!block) return;
block.classList.add('editing');
activeBlockId = blockId;
const textarea = block.querySelector('.md-block-textarea');
if (textarea) {
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
autoResizeTextarea(textarea);
}
}
function exitEditMode(blockId) {
const block = document.getElementById(blockId);
if (!block) return;
block.classList.remove('editing');
const textarea = block.querySelector('.md-block-textarea');
const contentDiv = block.querySelector('.md-block-content');
if (textarea && contentDiv) {
const content = textarea.value.trim();
const blockType = block.dataset.blockType || 'paragraph';
const blockData = editorBlocks.find(b => b.id === blockId);
const images = blockData ? (blockData.images || []) : [];
if (content === '') {
contentDiv.innerHTML = renderBlockContent(blockType, '', blockId, images);
if (blockData) blockData.content = '';
} else {
contentDiv.innerHTML = renderBlockContent(blockType, content, blockId, images);
if (blockData) blockData.content = content;
}
}
if (activeBlockId === blockId) {
activeBlockId = null;
}
repairAllNewBlockLines();
updatePanelUI();
}
function handleBlockBlur(event, blockId) {
if (isToolbarClick) {
isToolbarClick = false;
setTimeout(() => {
const block = document.getElementById(blockId);
if (block && block.classList.contains('editing')) {
const textarea = block.querySelector('.md-block-textarea');
if (textarea) textarea.focus();
}
}, 10);
return;
}
exitEditMode(blockId);
}
function autoResizeTextarea(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.max(textarea.scrollHeight, 60) + 'px';
}
function handleBlockInput(event, blockId) {
autoResizeTextarea(event.target);
}
function handleBlockKeydown(event, blockId) {
const textarea = event.target;
if (event.key === 'Escape') {
event.preventDefault();
textarea.blur();
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
exitEditMode(blockId);
const block = document.getElementById(blockId);
addBlock('paragraph', '', block);
repairAllNewBlockLines();
return;
}
if (event.key === 'Backspace' && textarea.value === '') {
event.preventDefault();
const block = document.getElementById(blockId);
let prevBlock = block.previousElementSibling;
while (prevBlock && !prevBlock.classList.contains('md-block')) {
prevBlock = prevBlock.previousElementSibling;
}
deleteBlock(blockId);
if (prevBlock) enterEditMode(prevBlock.id);
return;
}
}
function handleGlobalKeydown(event) {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
if (typeof saveProject === 'function') saveProject();
return;
}
if (event.key === 'Escape' && panelState.pickMode) {
exitPickMode();
}
}
function applyFormat(format) {
if (!activeBlockId) return;
const block = document.getElementById(activeBlockId);
const textarea = block.querySelector('.md-block-textarea');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
if (start === end) return;
let wrapper = '';
switch (format) {
case 'bold': wrapper = '**'; break;
case 'italic': wrapper = '*'; break;
}
const newText = textarea.value.substring(0, start) +
wrapper + selectedText + wrapper +
textarea.value.substring(end);
textarea.value = newText;
textarea.setSelectionRange(start + wrapper.length, end + wrapper.length);
textarea.focus();
}
function changeCase(caseType) {
if (!activeBlockId) return;
const block = document.getElementById(activeBlockId);
const textarea = block.querySelector('.md-block-textarea');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
let selectedText = textarea.value.substring(start, end);
if (start === end) return;
switch (caseType) {
case 'lower': selectedText = selectedText.toLowerCase(); break;
case 'upper': selectedText = selectedText.toUpperCase(); break;
case 'sentence': selectedText = selectedText.toLowerCase().replace(/(^\w|\.\s+\w)/g, c => c.toUpperCase()); break;
case 'title': selectedText = selectedText.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()); break;
}
textarea.value = textarea.value.substring(0, start) + selectedText + textarea.value.substring(end);
textarea.setSelectionRange(start, start + selectedText.length);
textarea.focus();
}
function escapeHtml(text) {
if(!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderBlockContent(type, content, blockId, images) {
if (!content) {
return `<span class="md-block-placeholder">Click to edit</span>`;
}
if (type === 'image') {
if (images && images.length > 0) {
const img = images[0];
if (img.data) {
return `<div class="image-block">
<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">
</div>`;
}
}
const dataUriMatch = content.match(/!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)/);
if (dataUriMatch) {
return `<div class="image-block">
<img src="${dataUriMatch[2]}" alt="${dataUriMatch[1] || 'Image'}">
</div>`;
}
return `<div class="image-block">
<div class="image-upload-placeholder">
<i class="bi bi-image"></i>
<p>Click to upload an image</p>
</div>
</div>`;
}
let safeContent = content;
if (content.length > 10000 && content.includes('base64,')) {
safeContent = content.replace(
/!\[([^\]]*)\]\(data:image\/[^;]+;base64,[^)]+\)/g,
'![$1](embedded-image)'
);
}
try {
return marked.parse(safeContent, { breaks: true, gfm: true });
} catch (e) {
return `<p>${escapeHtml(content)}</p>`;
}
}
// ============================================
// New Block Line & DOM Cleanup
// ============================================
function createNewBlockLine() {
const line = document.createElement('div');
line.className = 'new-block-line';
const lineId = 'line_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
line.innerHTML = `
<div class="add-line-buttons">
<button class="add-line-btn" onclick="addBlockAtLine(this); event.stopPropagation();" title="Add text block">
<i class="bi bi-plus"></i>
</button>
<button class="add-line-btn image-btn" onclick="document.getElementById('imageLineInput_${lineId}').click(); event.stopPropagation();" title="Add image">
<i class="bi bi-image"></i>
</button>
<button class="add-line-btn section-btn" onclick="addSectionAtLine(this); event.stopPropagation();" title="Add new section divider">
<i class="bi bi-layout-split"></i>
</button>
</div>
<input type="file" id="imageLineInput_${lineId}" accept="image/*" hidden
onchange="handleImageAtLine(event, this)">
`;
return line;
}
function addBlockAtLine(button) {
const line = button.closest('.new-block-line');
addBlock('paragraph', '', line);
repairAllNewBlockLines();
}
function handleImageAtLine(event, inputEl) {
const file = event.target.files[0];
if (!file || !file.type.startsWith('image/')) return;
const line = inputEl.closest('.new-block-line');
if (!line) return;
const reader = new FileReader();
reader.onload = function(e) {
const base64Data = e.target.result.split(',')[1];
const format = file.type.split('/')[1] === 'jpeg' ? 'jpeg' : file.type.split('/')[1];
const images = [{
data: base64Data,
format: format,
alt_text: file.name,
position: 'before'
}];
const content = `![${file.name}](embedded-image.${format})`;
addBlock('image', content, line, images);
repairAllNewBlockLines();
updatePanelUI();
};
reader.readAsDataURL(file);
event.target.value = '';
}
function addSectionAtLine(button) {
const line = button.closest('.new-block-line');
const blockId = addBlock('heading2', 'New Section Heading', line);
makeSectionStart(blockId, 'New Section Heading');
repairAllNewBlockLines();
enterEditMode(blockId);
}
function ensureNewBlockLineAfter(element) {
if (!element) return;
const next = element.nextElementSibling;
if (next && next.classList.contains('new-block-line')) {
return;
}
element.after(createNewBlockLine());
}
function repairAllNewBlockLines() {
const editor = document.getElementById('markdownEditor');
if (!editor) return;
const children = Array.from(editor.children);
for (let i = 0; i < children.length; i++) {
const el = children[i];
if (el.classList.contains('new-block-line')) {
const prev = el.previousElementSibling;
if (!prev || prev.classList.contains('new-block-line')) {
el.remove();
}
}
}
const updatedChildren = Array.from(editor.children);
for (let i = 0; i < updatedChildren.length; i++) {
const el = updatedChildren[i];
if (el.classList.contains('md-block')) {
ensureNewBlockLineAfter(el);
}
}
}
function checkEmptyEditor() {
const editor = document.getElementById('markdownEditor');
if(!editor) return;
const blocks = editor.querySelectorAll('.md-block');
const emptyMessage = document.getElementById('emptyEditorMessage');
const panel = document.getElementById('audiobookMakerPanel');
const sidebar = document.getElementById('documentOutlineSidebar');
if (blocks.length === 0) {
editor.querySelectorAll('.new-block-line').forEach(line => line.remove());
editor.querySelectorAll('.section-divider').forEach(div => div.remove());
if (emptyMessage) emptyMessage.style.display = 'block';
if (panel) panel.style.display = 'none';
if (sidebar) sidebar.style.display = 'none';
} else {
if (emptyMessage) emptyMessage.style.display = 'none';
if (panel) panel.style.display = 'flex';
if (sidebar) sidebar.style.display = 'block';
}
}
// ============================================
// Content Collection
// ============================================
function collectEditorContent() {
const editor = document.getElementById('markdownEditor');
if (!editor) return [];
const chapters = [];
let currentChapter = null;
let blockOrder = 0;
let sectionCounter = 0;
const allBlockEls = editor.querySelectorAll('.md-block');
for (const el of allBlockEls) {
const blockData = editorBlocks.find(b => b.id === el.id);
if (!blockData) continue;
if (blockData.sectionStart) {
if (currentChapter && currentChapter.blocks.length > 0) {
chapters.push(currentChapter);
}
sectionCounter++;
currentChapter = {
chapter_number: sectionCounter,
title: blockData.sectionName || `Section ${sectionCounter}`,
voice: panelState.voice || 'af_heart',
blocks: []
};
blockOrder = 0;
}
if (!currentChapter) {
sectionCounter++;
const firstContent = el.querySelector('.md-block-textarea') ? el.querySelector('.md-block-textarea').value.trim() : '';
const plainText = stripMarkdown(firstContent);
const snippet = plainText ? (plainText.substring(0, 40) + (plainText.length > 40 ? '...' : '')) : 'Section 1';
currentChapter = {
chapter_number: sectionCounter,
title: blockData.sectionName || snippet,
voice: panelState.voice || 'af_heart',
blocks: []
};
}
blockOrder++;
const textarea = el.querySelector('.md-block-textarea');
const content = textarea ? textarea.value : '';
const blockType = el.dataset.blockType || 'paragraph';
if (content.trim() || blockType === 'image') {
currentChapter.blocks.push({
block_order: blockOrder,
block_type: blockType,
content: content,
tts_text: el.dataset.ttsText || '',
audio_data: blockData.audio_data || '',
audio_format: blockData.audio_format || 'mp3',
transcription: blockData.transcription || [],
images: blockData.images || []
});
}
}
if (currentChapter && currentChapter.blocks.length > 0) {
chapters.push(currentChapter);
}
return chapters;
}
function renderProjectInEditor(projectData) {
const editor = document.getElementById('markdownEditor');
if(!editor) return;
editor.innerHTML = '';
editorBlocks = [];
const emptyMessage = document.getElementById('emptyEditorMessage');
if (emptyMessage) emptyMessage.style.display = 'none';
if (projectData.chapters && projectData.chapters.length > 0 && projectData.chapters[0].voice) {
panelState.voice = projectData.chapters[0].voice;
const voiceSelect = document.getElementById('ampVoiceSelect');
if (voiceSelect) voiceSelect.value = panelState.voice;
}
for (const chapter of projectData.chapters) {
let isFirstInChapter = true;
for (const block of chapter.blocks) {
const lastChild = editor.lastElementChild;
const blockId = addBlock(
block.block_type,
block.content,
lastChild,
block.images || []
);
if (isFirstInChapter) {
const blockData = editorBlocks.find(b => b.id === blockId);
if (blockData) {
blockData.sectionStart = true;
blockData.sectionName = chapter.title || 'Section';
}
isFirstInChapter = false;
}
const blockData = editorBlocks.find(b => b.id === blockId);
if (blockData) {
blockData.audio_data = block.audio_data;
blockData.audio_format = block.audio_format;
blockData.transcription = block.transcription;
blockData.tts_text = block.tts_text;
}
const blockEl = document.getElementById(blockId);
if (blockEl && block.tts_text) {
blockEl.dataset.ttsText = block.tts_text;
}
if (block.audio_data && block.block_type !== 'image') {
if (blockEl) {
const indicator = blockEl.querySelector('.audio-indicator');
if (indicator) {
indicator.classList.remove('no-audio');
indicator.classList.add('has-audio');
indicator.title = 'Audio generated';
}
}
}
if (blockEl) {
ensureNewBlockLineAfter(blockEl);
}
}
}
renderAllSectionDividers();
repairAllNewBlockLines();
const textBlocks = getTextBlocks();
let foundStart = false;
for (const tb of textBlocks) {
const data = editorBlocks.find(b => b.id === tb.id);
if (!data || !data.audio_data) {
panelState.startingBlockId = tb.id;
foundStart = true;
break;
}
}
if (!foundStart && textBlocks.length > 0) {
panelState.startingBlockId = textBlocks[textBlocks.length - 1].id;
}
// যুক্ত করা হলো: প্রজেক্ট লোড হলেও প্যানেলের ব্লক কাউন্ট যেন মোট ব্লকের সমান থাকে
if (textBlocks.length > 0) {
panelState.blockCount = textBlocks.length;
}
updatePanelUI();
renderDocumentOutline();
checkEmptyEditor();
}