Fix normalize button to only normalize selected text
This commit is contained in:
@@ -157,7 +157,7 @@ function createMarkerHTML(type, num, voice = 'af_alloy', markerId = null) {
|
|||||||
</ul>
|
</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="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="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>
|
<div class="vr bg-secondary mx-2"></div>
|
||||||
<button class="btn btn-outline-success" onclick="triggerImageUpload('${id}')" title="Add Image">
|
<button class="btn btn-outline-success" onclick="triggerImageUpload('${id}')" title="Add Image">
|
||||||
<i class="bi bi-image"></i>
|
<i class="bi bi-image"></i>
|
||||||
@@ -575,9 +575,307 @@ window.formatBlock = formatBlock;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize section text (clean up formatting)
|
* 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
|
* @param {string} markerId - Marker ID
|
||||||
*/
|
*/
|
||||||
function normalizeSection(markerId) {
|
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}`);
|
const marker = document.getElementById(`marker-${markerId}`);
|
||||||
if (!marker) return;
|
if (!marker) return;
|
||||||
|
|
||||||
@@ -600,9 +898,6 @@ function normalizeSection(markerId) {
|
|||||||
nodesToRemove.forEach(n => n.remove());
|
nodesToRemove.forEach(n => n.remove());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make it globally available
|
|
||||||
window.normalizeSection = normalizeSection;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert HTML at cursor position
|
* Insert HTML at cursor position
|
||||||
* @param {string} html - HTML string to insert
|
* @param {string} html - HTML string to insert
|
||||||
|
|||||||
Reference in New Issue
Block a user