first commit
This commit is contained in:
942
static/js/markdown-editor.js
Normal file
942
static/js/markdown-editor.js
Normal file
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user