1161 lines
38 KiB
JavaScript
1161 lines
38 KiB
JavaScript
/**
|
|
* Markdown Editor Module
|
|
* UPDATED: Lazy audio loading support — tracks db_id for each block
|
|
*/
|
|
|
|
// ============================================
|
|
// Editor State
|
|
// ============================================
|
|
|
|
let editorBlocks = [];
|
|
let activeBlockId = null;
|
|
let isToolbarClick = false;
|
|
|
|
let panelState = {
|
|
startingBlockId: null,
|
|
blockCount: 10,
|
|
voice: 'af_heart',
|
|
pickMode: false
|
|
};
|
|
|
|
// ============================================
|
|
// Initialization
|
|
// ============================================
|
|
|
|
function initMarkdownEditor() {
|
|
const editor = document.getElementById('markdownEditor');
|
|
|
|
editor.addEventListener('click', function(e) {
|
|
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 (lazy audio loading)');
|
|
}
|
|
|
|
// ============================================
|
|
// Audiobook Maker Panel
|
|
// ============================================
|
|
|
|
function initAudiobookMakerPanel() {
|
|
updatePanelUI();
|
|
}
|
|
|
|
function updatePanelUI() {
|
|
const textBlocks = getTextBlocks();
|
|
const totalBlocks = textBlocks.length;
|
|
|
|
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);
|
|
|
|
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(';
|
|
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();
|
|
}
|
|
|
|
// ============================================
|
|
// Section Markers
|
|
// ============================================
|
|
|
|
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
|
|
// ============================================
|
|
|
|
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,
|
|
db_id: null, // Database ID — set when loaded from server
|
|
has_audio: false, // Server-reported audio presence
|
|
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,
|
|
''
|
|
);
|
|
}
|
|
|
|
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 = ``;
|
|
|
|
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;
|
|
blockData.db_id = block.id; // Track DB ID for lazy audio loading
|
|
blockData.has_audio = !!block.has_audio; // Server-reported audio presence
|
|
}
|
|
|
|
const blockEl = document.getElementById(blockId);
|
|
if (blockEl && block.tts_text) {
|
|
blockEl.dataset.ttsText = block.tts_text;
|
|
}
|
|
|
|
// Show audio indicator based on has_audio flag (audio will be lazy-loaded)
|
|
if (block.has_audio && 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 loading...';
|
|
}
|
|
}
|
|
}
|
|
|
|
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.has_audio) {
|
|
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();
|
|
}
|