Fix normalize button to only normalize selected text

This commit is contained in:
Ashim Kumar
2026-01-16 17:07:33 +06:00
parent 1737567b83
commit 93119fcee6

View File

@@ -157,7 +157,7 @@ function createMarkerHTML(type, num, voice = 'af_alloy', markerId = null) {
</ul>
<button class="btn btn-outline-dark" title="Bold" onmousedown="event.preventDefault(); applyFormat('bold')"><i class="bi bi-type-bold"></i></button>
<button class="btn btn-outline-dark" title="Italic" onmousedown="event.preventDefault(); applyFormat('italic')"><i class="bi bi-type-italic"></i></button>
<button class="btn btn-outline-dark" title="Normalize" onmousedown="event.preventDefault(); normalizeSection('${id}')"><i class="bi bi-eraser"></i></button>
<button class="btn btn-outline-dark" title="Normalize (select text first, or normalizes entire section)" onmousedown="event.preventDefault(); normalizeSection('${id}')"><i class="bi bi-eraser"></i></button>
<div class="vr bg-secondary mx-2"></div>
<button class="btn btn-outline-success" onclick="triggerImageUpload('${id}')" title="Add Image">
<i class="bi bi-image"></i>
@@ -575,9 +575,307 @@ window.formatBlock = formatBlock;
/**
* Normalize section text (clean up formatting)
* If text is selected, only normalize the selection
* If no text is selected, normalize the entire section
* @param {string} markerId - Marker ID
*/
function normalizeSection(markerId) {
const selection = window.getSelection();
// Check if there's a valid selection with actual content
if (selection && !selection.isCollapsed && selection.toString().trim().length > 0) {
// Normalize only the selected text
normalizeSelection(selection, markerId);
} else {
// No selection - normalize entire section
normalizeEntireSection(markerId);
}
}
// Make it globally available
window.normalizeSection = normalizeSection;
/**
* Normalize only the selected text - removes all formatting
* @param {Selection} selection - Current selection
* @param {string} markerId - Marker ID for context
*/
function normalizeSelection(selection, markerId) {
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
// Get the selected text content (plain text, no formatting)
const selectedText = selection.toString();
// Clean the text: remove extra whitespace, normalize line breaks
const cleanText = selectedText
.replace(/[\r\n]+/g, ' ') // Replace line breaks with spaces
.replace(/\s+/g, ' ') // Collapse multiple spaces
.trim();
if (!cleanText) return;
// Get the start and end block elements
const startContainer = range.startContainer;
const endContainer = range.endContainer;
const startBlock = getParentBlock(startContainer);
const endBlock = getParentBlock(endContainer);
// Check if selection is entirely within a single formatting block (h1, h2, h3, etc.)
if (startBlock && startBlock === endBlock && isFormattingBlock(startBlock)) {
const blockText = startBlock.textContent.trim();
const selectedTrimmed = cleanText;
// If we're selecting most or all of the block content, replace the entire block
if (blockText === selectedTrimmed || selectedTrimmed.length >= blockText.length * 0.8) {
// Replace the entire block with a paragraph
const p = document.createElement('p');
p.textContent = cleanText;
startBlock.parentNode.replaceChild(p, startBlock);
// Set cursor at end of new paragraph
const newRange = document.createRange();
newRange.selectNodeContents(p);
newRange.collapse(false);
selection.removeAllRanges();
selection.addRange(newRange);
return;
}
}
// Check if selection spans multiple blocks
if (startBlock !== endBlock) {
// Handle multi-block selection
normalizeMultiBlockSelection(range, selection, cleanText);
return;
}
// For inline formatting (bold, italic, etc.) within a single block
// First, try using execCommand to remove formatting
try {
// Store the clean text before manipulation
const textToInsert = cleanText;
// Remove inline formatting using execCommand
document.execCommand('removeFormat', false, null);
// After removeFormat, check if we need to handle any remaining issues
const newSelection = window.getSelection();
if (newSelection.rangeCount > 0) {
const newRange = newSelection.getRangeAt(0);
const currentText = newSelection.toString();
// If the text is still the same (formatting was removed), we're done
// But if we're inside a heading, we need to convert it
const parentBlock = getParentBlock(newRange.commonAncestorContainer);
if (parentBlock && isFormattingBlock(parentBlock)) {
// Convert the heading to a paragraph
const p = document.createElement('p');
p.innerHTML = parentBlock.innerHTML;
parentBlock.parentNode.replaceChild(p, parentBlock);
}
}
} catch (e) {
console.log('execCommand failed, using manual approach:', e);
manualNormalizeSelection(range, selection, cleanText);
}
}
/**
* Handle normalization when selection spans multiple blocks
* @param {Range} range - Selection range
* @param {Selection} selection - Current selection
* @param {string} cleanText - Cleaned text to insert
*/
function normalizeMultiBlockSelection(range, selection, cleanText) {
const startBlock = getParentBlock(range.startContainer);
const endBlock = getParentBlock(range.endContainer);
const editor = document.getElementById('bulk-editor');
if (!startBlock || !endBlock || !editor) {
manualNormalizeSelection(range, selection, cleanText);
return;
}
// Collect all blocks between start and end
const blocksToRemove = [];
let currentBlock = startBlock.nextElementSibling;
while (currentBlock && currentBlock !== endBlock) {
// Don't remove editor markers
if (!currentBlock.classList || !currentBlock.classList.contains('editor-marker')) {
blocksToRemove.push(currentBlock);
}
currentBlock = currentBlock.nextElementSibling;
}
// Create a new paragraph with the clean text
const p = document.createElement('p');
p.textContent = cleanText;
// Replace the start block with the new paragraph
startBlock.parentNode.replaceChild(p, startBlock);
// Remove intermediate blocks
blocksToRemove.forEach(block => block.remove());
// Remove the end block if it's different from start
if (endBlock && endBlock !== startBlock && endBlock.parentNode) {
endBlock.remove();
}
// Set cursor at end of new paragraph
const newRange = document.createRange();
newRange.selectNodeContents(p);
newRange.collapse(false);
selection.removeAllRanges();
selection.addRange(newRange);
}
/**
* Manual fallback for normalizing selection
* @param {Range} range - Selection range
* @param {Selection} selection - Current selection
* @param {string} cleanText - Cleaned text to insert
*/
function manualNormalizeSelection(range, selection, cleanText) {
const startBlock = getParentBlock(range.startContainer);
// Delete the selected content
range.deleteContents();
// If we're in a formatting block that's now empty or nearly empty, replace it
if (startBlock && isFormattingBlock(startBlock)) {
const remainingText = startBlock.textContent.trim();
if (remainingText.length === 0) {
// Block is empty, replace with paragraph containing clean text
const p = document.createElement('p');
p.textContent = cleanText;
startBlock.parentNode.replaceChild(p, startBlock);
// Set cursor at end
const newRange = document.createRange();
newRange.selectNodeContents(p);
newRange.collapse(false);
selection.removeAllRanges();
selection.addRange(newRange);
return;
}
}
// Insert clean text node
const textNode = document.createTextNode(cleanText);
range.insertNode(textNode);
// Clean up any empty formatting elements
const commonAncestor = range.commonAncestorContainer;
cleanEmptyFormattingElements(commonAncestor);
// Also clean parent elements
if (commonAncestor.parentNode) {
cleanEmptyFormattingElements(commonAncestor.parentNode);
}
// Move cursor to end of inserted text
const newRange = document.createRange();
newRange.setStartAfter(textNode);
newRange.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(newRange);
}
/**
* Get the parent block element of a node
* @param {Node} node - The node to find parent block for
* @returns {Element|null} The parent block element or null
*/
function getParentBlock(node) {
const blockTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'DIV', 'LI', 'BLOCKQUOTE'];
let current = node;
// If it's a text node, start with parent
while (current && current.nodeType !== 1) {
current = current.parentNode;
}
while (current) {
if (current.nodeType === 1 && blockTags.includes(current.tagName)) {
return current;
}
// Stop at the editor boundary
if (current.id === 'bulk-editor') {
return null;
}
current = current.parentNode;
}
return null;
}
/**
* Check if an element is a formatting block (heading, etc.)
* @param {Element} element - Element to check
* @returns {boolean} True if it's a formatting block
*/
function isFormattingBlock(element) {
if (!element || element.nodeType !== 1) return false;
const formattingTags = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE'];
return formattingTags.includes(element.tagName);
}
/**
* Clean up empty formatting elements after deletion
* @param {Node} container - Container to clean
*/
function cleanEmptyFormattingElements(container) {
if (!container || container.nodeType !== 1) return;
const formattingTags = ['B', 'STRONG', 'I', 'EM', 'U', 'STRIKE', 'S', 'SPAN', 'FONT'];
// First pass: clean direct children
formattingTags.forEach(tag => {
let elements;
try {
elements = container.querySelectorAll ? container.querySelectorAll(tag) : [];
} catch (e) {
elements = [];
}
elements.forEach(el => {
// Remove if empty or contains only whitespace
if (!el.textContent.trim()) {
el.remove();
}
});
});
// Second pass: unwrap formatting elements that contain only text (to flatten the structure)
formattingTags.forEach(tag => {
let elements;
try {
elements = container.getElementsByTagName ? container.getElementsByTagName(tag) : [];
} catch (e) {
elements = [];
}
// Convert to array since we'll be modifying the DOM
Array.from(elements).forEach(el => {
// If this element only contains a text node, unwrap it
if (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3) {
const textContent = el.textContent;
const textNode = document.createTextNode(textContent);
el.parentNode.replaceChild(textNode, el);
}
});
});
}
/**
* Normalize the entire section after a marker
* @param {string} markerId - Marker ID
*/
function normalizeEntireSection(markerId) {
const marker = document.getElementById(`marker-${markerId}`);
if (!marker) return;
@@ -600,9 +898,6 @@ function normalizeSection(markerId) {
nodesToRemove.forEach(n => n.remove());
}
// Make it globally available
window.normalizeSection = normalizeSection;
/**
* Insert HTML at cursor position
* @param {string} html - HTML string to insert