import type { EditorState } from '../richTextTypes' import { analyzeSelection, restoreSelection, getBlocksBetween } from './selection' export function formatting(state: EditorState) { const { doc } = state if (!doc) { return { text: () => {}, block: () => {}, list: () => {}, clear: () => {} } } const text = (command: string) => { const selection = doc.getSelection() if (!selection || !selection.rangeCount) { return } const range = selection.getRangeAt(0) const container = range.commonAncestorContainer const parentBlock = container.nodeType === 3 ? container.parentElement : container as HTMLElement // Preserve RTL direction when applying text formatting const isRTL = parentBlock?.closest('[dir="rtl"]') !== null // Don't apply inline styles directly to block elements if (parentBlock?.tagName.match(/^H[1-6]|P|BLOCKQUOTE|LI$/)) { if (!range.collapsed && range.toString().trim()) { let element: HTMLElement if (command === 'underline') { element = doc.createElement('u') } else if (command === 'bold') { element = doc.createElement('b') } else if (command === 'italic') { element = doc.createElement('i') } else { return } if (isRTL) { element.dir = 'rtl' } range.surroundContents(element) } } else { if (range.collapsed) { return } // No selection, nothing to format let element: HTMLElement if (command === 'bold') { element = doc.createElement('b') } else if (command === 'italic') { element = doc.createElement('i') } else if (command === 'underline') { element = doc.createElement('u') } else { return } try { range.surroundContents(element) } catch { // If surroundContents fails (e.g., for selections across multiple nodes) // Extract the fragment, wrap it, and insert it back const fragment = range.extractContents() element.appendChild(fragment) range.insertNode(element) } } } const block = (command: string, tag: string) => { const selection = doc.getSelection() if (!selection || !selection.rangeCount) { return } const range = selection.getRangeAt(0) const container = range.commonAncestorContainer const parentBlock = container.nodeType === 3 ? container.parentElement : container as HTMLElement const isRTL = parentBlock?.closest('[dir="rtl"]') !== null // Remove any invalid inline style wrapping if (parentBlock?.closest('u, b, i')) { const wrapper = parentBlock.closest('u, b, i') if (wrapper) { const parent = wrapper.parentNode while (wrapper.firstChild) { parent?.insertBefore(wrapper.firstChild, wrapper) } wrapper.remove() } } // Create a new block element of the desired type const newBlock = doc.createElement(tag) if (isRTL) { newBlock.dir = 'rtl' } // Find the current block to replace const currentBlock = parentBlock?.closest('p,h1,h2,h3,h4,h5,h6,blockquote,div') || parentBlock if (currentBlock) { // Copy content to the new block while (currentBlock.firstChild) { newBlock.appendChild(currentBlock.firstChild) } // Replace the current block with the new one currentBlock.parentNode?.replaceChild(newBlock, currentBlock) // Move cursor into the new block range.selectNodeContents(newBlock) range.collapse(false) selection.removeAllRanges() selection.addRange(range) } } const list = (command: string) => { if (!state.doc || !state.range || !state.selection) { return } const listTag = command === 'orderedList' ? 'ol' : 'ul' const selectionInfo = analyzeSelection(state.doc, state.range) if (!selectionInfo) { return } const isRTL = selectionInfo.parent.closest('[dir="rtl"]') !== null // If we're inside a list item, handle toggling off or switching list type const listItem = selectionInfo.parent.closest('li') if (listItem) { const listParent = listItem.parentElement if (!listParent) { return } // If it's the same list type, toggle off if (listParent.tagName.toLowerCase() === listTag) { // Create a paragraph from the list item content const p = state.doc.createElement('p') as HTMLParagraphElement if (isRTL) { p.dir = 'rtl' } while (listItem.firstChild) { p.appendChild(listItem.firstChild) } listParent.parentNode?.insertBefore(p, listParent) listItem.remove() if (!listParent.querySelector('li')) { listParent.remove() } restoreSelection(state.doc, state.range, state.selection, selectionInfo, p) return } // Convert to the other list type const newList = state.doc.createElement(listTag) as HTMLElement if (isRTL) { newList.dir = 'rtl' } const allItems = Array.from(listParent.children) allItems.forEach(item => newList.appendChild(item.cloneNode(true))) listParent.parentNode?.replaceChild(newList, listParent) const newListItem = newList.children[Array.from(allItems).indexOf(listItem)] restoreSelection(state.doc, state.range, state.selection, selectionInfo, newListItem) return } // For single block conversion if (!selectionInfo.isMultiBlock) { const li = state.doc.createElement('li') const listEl = state.doc.createElement(listTag) as HTMLElement if (isRTL) { listEl.dir = 'rtl' } while (selectionInfo.startBlock.firstChild) { li.appendChild(selectionInfo.startBlock.firstChild) } listEl.appendChild(li) selectionInfo.startBlock.parentNode?.replaceChild(listEl, selectionInfo.startBlock) restoreSelection(state.doc, state.range, state.selection, selectionInfo, li) return } // Handle multi-block selection const blocks = getBlocksBetween(selectionInfo.startBlock, selectionInfo.endBlock) const listEl = state.doc.createElement(listTag) as HTMLElement if (isRTL) { listEl.dir = 'rtl' } blocks.forEach((block) => { if (!state.doc) { return } const li = state.doc.createElement('li') while (block.firstChild) { li.appendChild(block.firstChild) } listEl.appendChild(li) block.remove() }) selectionInfo.startBlock.parentNode?.insertBefore(listEl, selectionInfo.startBlock) restoreSelection(state.doc, state.range, state.selection, selectionInfo, listEl) } const clear = () => { console.log('[Clear Format] Starting clear format process', state) console.assert(!!state, '[Clear Format] State must exist') console.assert(!!state.doc, '[Clear Format] Document must exist') console.assert(!!state.range, '[Clear Format] Range must exist') console.assert(!!state.selection, '[Clear Format] Selection must exist') if (!state.doc || !state.range || !state.selection) { console.log('[Clear Format] No document or selection') return } const selectionInfo = analyzeSelection(state.doc, state.range) console.log('[Clear Format] Selection info:', selectionInfo) if (!selectionInfo) { console.log('[Clear Format] No valid selection info') return } // If selection is collapsed (just a cursor), return if (state.range.collapsed) { console.log('[Clear Format] Selection is collapsed, nothing to clear') return } console.log('[Clear Format] Processing selection:', { startContainer: state.range.startContainer, endContainer: state.range.endContainer, selectedText: state.range.toString() }) // Get the selected content const fragment = state.range.cloneContents() const tempDiv = state.doc.createElement('div') tempDiv.appendChild(fragment) console.log('[Clear Format] Original HTML:', tempDiv.innerHTML) // Function to recursively clean a node and its children const cleanNode = (node: Node): Node => { if (!state.doc) { return node } // Text nodes can be returned as-is if (node.nodeType === 3) { return node.cloneNode(true) } if (node.nodeType === 1) { // Element node const el = node as HTMLElement const nodeName = el.nodeName.toLowerCase() const inlineTags = ['b', 'i', 'u', 'strong', 'em', 'span', 'font', 'strike', 'sub', 'sup'] console.log('[Clear Format] Processing element:', nodeName, { hasStyle: el.hasAttribute('style'), style: el.getAttribute('style'), className: el.className }) // For inline formatting elements, just extract the text content if (inlineTags.includes(nodeName)) { // Create a text node with the element's content const textContent = el.textContent || '' console.log('[Clear Format] Extracting text from inline element:', textContent) return state.doc.createTextNode(textContent) } // For block elements, preserve the element type but remove formatting const newEl = state.doc.createElement(nodeName) // Remove any style and class attributes if (el.hasAttribute('style')) { console.log('[Clear Format] Removing style attribute:', el.getAttribute('style')) } if (el.className) { console.log('[Clear Format] Removing class:', el.className) } // Transfer only specific attributes we want to keep (like href for links) if (nodeName === 'a' && el.hasAttribute('href')) { newEl.setAttribute('href', el.getAttribute('href') || '') if (el.hasAttribute('target')) { newEl.setAttribute('target', el.getAttribute('target') || '') } console.log('[Clear Format] Preserving link attributes') } // For images, preserve src and alt if (nodeName === 'img') { if (el.hasAttribute('src')) { newEl.setAttribute('src', el.getAttribute('src') || '') } if (el.hasAttribute('alt')) { newEl.setAttribute('alt', el.getAttribute('alt') || '') } console.log('[Clear Format] Preserving image attributes') } // Clean and append all child nodes Array.from(el.childNodes).forEach((child) => { newEl.appendChild(cleanNode(child)) }) return newEl } return node.cloneNode(true) } // Clean all nodes in the fragment const cleanedFragment = state.doc.createDocumentFragment() Array.from(tempDiv.childNodes).forEach((node) => { cleanedFragment.appendChild(cleanNode(node)) }) // For debugging: check what the cleaned HTML looks like const debugDiv = state.doc.createElement('div') debugDiv.appendChild(cleanedFragment.cloneNode(true)) console.log('[Clear Format] Cleaned HTML:', debugDiv.innerHTML) // Replace the content state.range.deleteContents() state.range.insertNode(cleanedFragment) // Restore selection restoreSelection(state.doc, state.range, state.selection, selectionInfo) // Update content state state.content = state.doc.body.innerHTML console.log('[Clear Format] Updated document HTML:', state.content) } return { text, block, list, clear } } export function getBlockElement(node: Node): HTMLElement | null { const element = node.nodeType === 3 ? node.parentElement : node as HTMLElement if (!element) { return null } return element.closest('p,h1,h2,h3,h4,h5,h6,blockquote,li') || element } export function adjustIndentation(state: EditorState, increase: boolean) { const { doc, range } = state if (!doc || !range) { return } const blockElement = getBlockElement(range.commonAncestorContainer) if (!blockElement) { return } const currentMargin = Number.parseInt(blockElement.style.marginLeft || '0', 10) const step = 20 // pixels to indent/outdent const newMargin = increase ? currentMargin + step : Math.max(0, currentMargin - step) blockElement.style.marginLeft = `${newMargin}px` }