Files
audiobook-maker-pro-v4/static/js/markdown-editor.js
Ashim Kumar 8e02b9ad09 first commit
2026-02-20 13:53:36 +06:00

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 = `![${file.name}](embedded-image.${format})`;
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,
'![$1](embedded-image)'
);
}
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 = `![${file.name}](embedded-image.${format})`;
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();
}