943 lines
31 KiB
JavaScript
943 lines
31 KiB
JavaScript
/**
|
|
* Markdown Editor Module - Notion/Obsidian Style
|
|
* UPDATED: Removed slash command feature (redundant with new-block-line buttons)
|
|
* UPDATED: Chapter marker no longer auto-creates empty text block
|
|
* UPDATED: Image upload in new-block-line buttons
|
|
*/
|
|
|
|
// ============================================
|
|
// Editor State
|
|
// ============================================
|
|
|
|
let editorBlocks = [];
|
|
let activeBlockId = null;
|
|
let isToolbarClick = false;
|
|
|
|
// ============================================
|
|
// Initialization
|
|
// ============================================
|
|
|
|
function initMarkdownEditor() {
|
|
const editor = document.getElementById('markdownEditor');
|
|
|
|
editor.addEventListener('click', function(e) {
|
|
if (e.target === editor || e.target.id === 'emptyEditorMessage') {
|
|
if (editorBlocks.length === 0) {
|
|
addChapterMarker(1);
|
|
}
|
|
}
|
|
});
|
|
|
|
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')) {
|
|
isToolbarClick = true;
|
|
} else {
|
|
isToolbarClick = false;
|
|
}
|
|
});
|
|
|
|
console.log('📝 Markdown editor initialized');
|
|
}
|
|
|
|
// ============================================
|
|
// New Block Line Helper
|
|
// ============================================
|
|
|
|
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)" title="Add text block">
|
|
<i class="bi bi-plus"></i>
|
|
</button>
|
|
<button class="add-line-btn image-btn" onclick="triggerImageAtLine('${lineId}')" title="Add image">
|
|
<i class="bi bi-image"></i>
|
|
</button>
|
|
<button class="add-line-btn chapter-btn" onclick="addChapterAtLine(this)" title="Add chapter marker">
|
|
<i class="bi bi-bookmark-star"></i>
|
|
</button>
|
|
</div>
|
|
<input type="file" id="imageLineInput_${lineId}" accept="image/*" hidden
|
|
onchange="handleImageAtLine(event, this)">
|
|
`;
|
|
return line;
|
|
}
|
|
|
|
function triggerImageAtLine(lineId) {
|
|
const fileInput = document.getElementById(`imageLineInput_${lineId}`);
|
|
if (fileInput) {
|
|
fileInput.click();
|
|
}
|
|
}
|
|
|
|
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 = ``;
|
|
|
|
const blockId = addBlock('image', content, line, images);
|
|
|
|
const blockEl = document.getElementById(blockId);
|
|
if (blockEl) {
|
|
const contentDiv = blockEl.querySelector('.md-block-content');
|
|
if (contentDiv) {
|
|
contentDiv.innerHTML = `
|
|
<div class="image-block">
|
|
<img src="${e.target.result}" alt="${file.name}">
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
repairAllNewBlockLines();
|
|
showNotification('Image added', 'success');
|
|
};
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
event.target.value = '';
|
|
}
|
|
|
|
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') || el.classList.contains('chapter-marker')) {
|
|
ensureNewBlockLineAfter(el);
|
|
}
|
|
}
|
|
|
|
const finalChildren = Array.from(editor.children);
|
|
for (let i = 1; i < finalChildren.length; i++) {
|
|
if (finalChildren[i].classList.contains('new-block-line') &&
|
|
finalChildren[i-1].classList.contains('new-block-line')) {
|
|
finalChildren[i].remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Chapter Markers
|
|
// ============================================
|
|
|
|
function addChapterMarker(chapterNumber = 1, voice = 'af_heart', afterElement = null) {
|
|
const markerId = 'chapter_' + Date.now();
|
|
|
|
const marker = document.createElement('div');
|
|
marker.className = 'chapter-marker';
|
|
marker.id = markerId;
|
|
marker.dataset.chapterNumber = chapterNumber;
|
|
marker.dataset.voice = voice;
|
|
|
|
marker.innerHTML = `
|
|
<div class="chapter-marker-left">
|
|
<span class="chapter-label">Chapter</span>
|
|
<input type="number" class="form-control chapter-number-input"
|
|
value="${chapterNumber}" min="1"
|
|
onchange="updateChapterNumber('${markerId}', this.value)">
|
|
<select class="form-select chapter-voice-select"
|
|
onchange="updateChapterVoice('${markerId}', this.value)">
|
|
${getVoiceOptions(voice)}
|
|
</select>
|
|
</div>
|
|
<div class="chapter-actions">
|
|
<button class="btn btn-success btn-sm" onclick="generateChapterAudio('${markerId}')">
|
|
<i class="bi bi-play-fill me-1"></i> Generate All
|
|
</button>
|
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteChapter('${markerId}')">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
const editor = document.getElementById('markdownEditor');
|
|
const emptyMessage = document.getElementById('emptyEditorMessage');
|
|
|
|
if (emptyMessage) {
|
|
emptyMessage.style.display = 'none';
|
|
}
|
|
|
|
if (afterElement) {
|
|
afterElement.after(marker);
|
|
} else {
|
|
editor.appendChild(marker);
|
|
}
|
|
|
|
ensureNewBlockLineAfter(marker);
|
|
|
|
return markerId;
|
|
}
|
|
|
|
function getNextChapterNumber() {
|
|
const chapterMarkers = document.querySelectorAll('.chapter-marker');
|
|
let maxChapter = 0;
|
|
chapterMarkers.forEach(m => {
|
|
const num = parseInt(m.dataset.chapterNumber) || 0;
|
|
if (num > maxChapter) maxChapter = num;
|
|
});
|
|
return maxChapter + 1;
|
|
}
|
|
|
|
function updateChapterNumber(markerId, value) {
|
|
const marker = document.getElementById(markerId);
|
|
if (marker) {
|
|
marker.dataset.chapterNumber = value;
|
|
}
|
|
}
|
|
|
|
function updateChapterVoice(markerId, value) {
|
|
const marker = document.getElementById(markerId);
|
|
if (marker) {
|
|
marker.dataset.voice = value;
|
|
}
|
|
}
|
|
|
|
function deleteChapter(markerId) {
|
|
const marker = document.getElementById(markerId);
|
|
if (!marker) return;
|
|
|
|
const nextLine = marker.nextElementSibling;
|
|
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
|
nextLine.remove();
|
|
}
|
|
|
|
marker.remove();
|
|
|
|
repairAllNewBlockLines();
|
|
checkEmptyEditor();
|
|
}
|
|
|
|
// ============================================
|
|
// Block Deletion
|
|
// ============================================
|
|
|
|
function deleteBlock(blockId) {
|
|
const block = document.getElementById(blockId);
|
|
if (!block) return;
|
|
|
|
if (activeBlockId === blockId) {
|
|
activeBlockId = null;
|
|
}
|
|
|
|
const nextLine = block.nextElementSibling;
|
|
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
|
nextLine.remove();
|
|
}
|
|
|
|
block.remove();
|
|
editorBlocks = editorBlocks.filter(b => b.id !== blockId);
|
|
|
|
repairAllNewBlockLines();
|
|
checkEmptyEditor();
|
|
}
|
|
|
|
// ============================================
|
|
// 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');
|
|
|
|
if (isImageBlock) {
|
|
block.innerHTML = `
|
|
<div class="block-actions-indicator">
|
|
<button class="action-indicator-btn delete-block-btn" onclick="deleteBlock('${blockId}')" title="Delete block">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</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}')">${content}</textarea>
|
|
</div>
|
|
`;
|
|
} else {
|
|
block.innerHTML = `
|
|
<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>
|
|
<div class="dropdown">
|
|
<button class="toolbar-btn dropdown-toggle" data-bs-toggle="dropdown" title="Change Case" onmousedown="event.preventDefault()">
|
|
Aa
|
|
</button>
|
|
<ul class="dropdown-menu">
|
|
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('sentence')">Sentence case</a></li>
|
|
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('lower')">lowercase</a></li>
|
|
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('upper')">UPPERCASE</a></li>
|
|
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('title')">Title Case</a></li>
|
|
</ul>
|
|
</div>
|
|
<div class="toolbar-divider"></div>
|
|
<button class="toolbar-btn" onmousedown="event.preventDefault(); openTtsEditor('${blockId}', getBlockContent('${blockId}'))" title="Edit TTS Text">
|
|
<i class="bi bi-mic"></i>
|
|
</button>
|
|
<button class="toolbar-btn" onmousedown="event.preventDefault(); generateBlockAudio('${blockId}')" title="Generate Audio">
|
|
<i class="bi bi-soundwave"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="block-actions-indicator">
|
|
<button class="action-indicator-btn edit-block-btn" onclick="enterEditMode('${blockId}')" title="Click to edit">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<button class="action-indicator-btn delete-block-btn" onclick="deleteBlock('${blockId}')" title="Delete block">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<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}')">${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
|
|
});
|
|
|
|
if (!content && type !== 'image') {
|
|
setTimeout(() => enterEditMode(blockId), 100);
|
|
}
|
|
|
|
return blockId;
|
|
}
|
|
|
|
function addBlockAtLine(button) {
|
|
const line = button.closest('.new-block-line');
|
|
const blockId = addBlock('paragraph', '', line);
|
|
repairAllNewBlockLines();
|
|
}
|
|
|
|
function addChapterAtLine(button) {
|
|
const line = button.closest('.new-block-line');
|
|
const nextChapterNum = getNextChapterNumber();
|
|
addChapterMarker(nextChapterNum, 'af_heart', line);
|
|
repairAllNewBlockLines();
|
|
}
|
|
|
|
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>`;
|
|
}
|
|
}
|
|
|
|
if (blockId) {
|
|
const blockData = editorBlocks.find(b => b.id === blockId);
|
|
if (blockData && blockData.images && blockData.images.length > 0) {
|
|
const img = blockData.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 {
|
|
const html = marked.parse(safeContent, { breaks: true, gfm: true });
|
|
return html;
|
|
} catch (e) {
|
|
console.warn('marked.js parse error, rendering as plain text:', e.message);
|
|
return `<p>${escapeHtml(content)}</p>`;
|
|
}
|
|
}
|
|
|
|
function getBlockContent(blockId) {
|
|
const block = document.getElementById(blockId);
|
|
if (block) {
|
|
const textarea = block.querySelector('.md-block-textarea');
|
|
return textarea ? textarea.value : '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// ============================================
|
|
// Edit Mode
|
|
// ============================================
|
|
|
|
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 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 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();
|
|
}
|
|
|
|
function autoResizeTextarea(textarea) {
|
|
textarea.style.height = 'auto';
|
|
textarea.style.height = Math.max(textarea.scrollHeight, 60) + 'px';
|
|
}
|
|
|
|
// ============================================
|
|
// Input Handling
|
|
// ============================================
|
|
|
|
function handleBlockInput(event, blockId) {
|
|
const textarea = event.target;
|
|
autoResizeTextarea(textarea);
|
|
}
|
|
|
|
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();
|
|
|
|
const block = document.getElementById(blockId);
|
|
|
|
exitEditMode(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;
|
|
}
|
|
|
|
const nextLine = block.nextElementSibling;
|
|
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
|
nextLine.remove();
|
|
}
|
|
|
|
block.remove();
|
|
editorBlocks = editorBlocks.filter(b => b.id !== blockId);
|
|
activeBlockId = null;
|
|
|
|
repairAllNewBlockLines();
|
|
|
|
if (prevBlock) {
|
|
enterEditMode(prevBlock.id);
|
|
}
|
|
|
|
checkEmptyEditor();
|
|
return;
|
|
}
|
|
}
|
|
|
|
function handleGlobalKeydown(event) {
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
|
event.preventDefault();
|
|
saveProject();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Text Formatting
|
|
// ============================================
|
|
|
|
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();
|
|
}
|
|
|
|
// ============================================
|
|
// Image Block Handling
|
|
// ============================================
|
|
|
|
function convertToImageBlock(blockId) {
|
|
const block = document.getElementById(blockId);
|
|
if (!block) return;
|
|
|
|
block.dataset.blockType = 'image';
|
|
|
|
const actionsIndicator = block.querySelector('.block-actions-indicator');
|
|
if (actionsIndicator) {
|
|
const editBtn = actionsIndicator.querySelector('.edit-block-btn');
|
|
if (editBtn) editBtn.remove();
|
|
}
|
|
|
|
const audioIndicator = block.querySelector('.audio-indicator');
|
|
if (audioIndicator) audioIndicator.remove();
|
|
const toolbar = block.querySelector('.md-block-toolbar');
|
|
if (toolbar) toolbar.remove();
|
|
|
|
const contentDiv = block.querySelector('.md-block-content');
|
|
contentDiv.innerHTML = `
|
|
<div class="image-block" onclick="triggerImageUpload('${blockId}')">
|
|
<input type="file" id="imageInput_${blockId}" accept="image/*" hidden
|
|
onchange="handleImageUpload(event, '${blockId}')">
|
|
<div class="image-upload-placeholder">
|
|
<i class="bi bi-image"></i>
|
|
<p>Click to upload or drag & drop an image</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const imageBlock = contentDiv.querySelector('.image-block');
|
|
|
|
imageBlock.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
imageBlock.classList.add('drag-over');
|
|
});
|
|
|
|
imageBlock.addEventListener('dragleave', () => {
|
|
imageBlock.classList.remove('drag-over');
|
|
});
|
|
|
|
imageBlock.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
imageBlock.classList.remove('drag-over');
|
|
|
|
const files = e.dataTransfer.files;
|
|
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
|
processImageFile(files[0], blockId);
|
|
}
|
|
});
|
|
}
|
|
|
|
function triggerImageUpload(blockId) {
|
|
document.getElementById(`imageInput_${blockId}`).click();
|
|
}
|
|
|
|
function handleImageUpload(event, blockId) {
|
|
const file = event.target.files[0];
|
|
if (file && file.type.startsWith('image/')) {
|
|
processImageFile(file, blockId);
|
|
}
|
|
}
|
|
|
|
function processImageFile(file, blockId) {
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = function(e) {
|
|
const base64Data = e.target.result.split(',')[1];
|
|
const format = file.type.split('/')[1];
|
|
|
|
const block = document.getElementById(blockId);
|
|
const textarea = block.querySelector('.md-block-textarea');
|
|
const contentDiv = block.querySelector('.md-block-content');
|
|
|
|
textarea.value = ``;
|
|
|
|
contentDiv.innerHTML = `
|
|
<div class="image-block">
|
|
<img src="${e.target.result}" alt="${file.name}">
|
|
</div>
|
|
`;
|
|
|
|
const blockData = editorBlocks.find(b => b.id === blockId);
|
|
if (blockData) {
|
|
blockData.content = textarea.value;
|
|
blockData.images = [{
|
|
data: base64Data,
|
|
format: format,
|
|
alt_text: file.name,
|
|
position: 'before'
|
|
}];
|
|
}
|
|
};
|
|
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
// ============================================
|
|
// Utility Functions
|
|
// ============================================
|
|
|
|
function checkEmptyEditor() {
|
|
const editor = document.getElementById('markdownEditor');
|
|
const blocks = editor.querySelectorAll('.md-block, .chapter-marker');
|
|
const emptyMessage = document.getElementById('emptyEditorMessage');
|
|
|
|
if (blocks.length === 0) {
|
|
editor.querySelectorAll('.new-block-line').forEach(line => line.remove());
|
|
|
|
if (emptyMessage) {
|
|
emptyMessage.style.display = 'block';
|
|
}
|
|
} else {
|
|
if (emptyMessage) {
|
|
emptyMessage.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Content Collection
|
|
// ============================================
|
|
|
|
function collectEditorContent() {
|
|
const editor = document.getElementById('markdownEditor');
|
|
const chapters = [];
|
|
let currentChapter = null;
|
|
let blockOrder = 0;
|
|
|
|
const elements = editor.children;
|
|
|
|
for (let i = 0; i < elements.length; i++) {
|
|
const el = elements[i];
|
|
|
|
if (el.classList.contains('chapter-marker')) {
|
|
if (currentChapter) {
|
|
chapters.push(currentChapter);
|
|
}
|
|
|
|
currentChapter = {
|
|
chapter_number: parseInt(el.dataset.chapterNumber) || 1,
|
|
voice: el.dataset.voice || 'af_heart',
|
|
blocks: []
|
|
};
|
|
blockOrder = 0;
|
|
|
|
} else if (el.classList.contains('md-block')) {
|
|
if (!currentChapter) {
|
|
currentChapter = {
|
|
chapter_number: 1,
|
|
voice: 'af_heart',
|
|
blocks: []
|
|
};
|
|
}
|
|
|
|
blockOrder++;
|
|
|
|
const textarea = el.querySelector('.md-block-textarea');
|
|
const content = textarea ? textarea.value : '';
|
|
const blockType = el.dataset.blockType || 'paragraph';
|
|
const blockData = editorBlocks.find(b => b.id === el.id);
|
|
const hasImages = blockData && blockData.images && blockData.images.length > 0;
|
|
|
|
if (content.trim() || (blockType === 'image' && hasImages)) {
|
|
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');
|
|
editor.innerHTML = '';
|
|
editorBlocks = [];
|
|
|
|
const emptyMessage = document.getElementById('emptyEditorMessage');
|
|
if (emptyMessage) {
|
|
emptyMessage.style.display = 'none';
|
|
}
|
|
|
|
for (const chapter of projectData.chapters) {
|
|
addChapterMarker(chapter.chapter_number, chapter.voice);
|
|
|
|
for (const block of chapter.blocks) {
|
|
const lastChild = editor.lastElementChild;
|
|
|
|
const blockId = addBlock(
|
|
block.block_type,
|
|
block.content,
|
|
lastChild,
|
|
block.images || []
|
|
);
|
|
|
|
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.block_type === 'image' && block.images && block.images.length > 0) {
|
|
const img = block.images[0];
|
|
if (img.data) {
|
|
const contentDiv = blockEl.querySelector('.md-block-content');
|
|
if (contentDiv) {
|
|
contentDiv.innerHTML = `<div class="image-block">
|
|
<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">
|
|
</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (block.audio_data && block.block_type !== 'image') {
|
|
const indicator = blockEl.querySelector('.audio-indicator');
|
|
if (indicator) {
|
|
indicator.classList.remove('no-audio');
|
|
indicator.classList.add('has-audio');
|
|
indicator.title = 'Audio generated';
|
|
}
|
|
}
|
|
|
|
ensureNewBlockLineAfter(blockEl);
|
|
}
|
|
}
|
|
|
|
repairAllNewBlockLines();
|
|
}
|