import type { EditorState } from '../richTextTypes' import { insertLink } from './media' import { addRow, deleteRow, mergeCells, splitCell, insertTable, deleteTable, insertColumn, deleteColumn, alignColumn } from './table' export interface Command { name: string execute: (state: EditorState, value?: string) => void isActive?: (state: EditorState) => boolean getValue?: (state: EditorState) => string | null } export interface CommandRegistry { [key: string]: Command } export interface CommandExecutor { execute: (command: string, value?: string) => void isActive: (command: string) => boolean getValue: (command: string) => string | null } // Helper functions function getCurrentSelection(state: EditorState): { selection: Selection, range: Range } | null { if (!state.doc) { return null } const selection = state.doc.getSelection() if (!selection || !selection.rangeCount) { return null } const range = selection.getRangeAt(0) return { selection, range } } // Alternative helper function to apply formatting to word at cursor function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: string): boolean { if (!range.collapsed) { return false } let textNode = range.startContainer let offset = range.startOffset // If we're in an element node, try to find a text node if (textNode.nodeType !== Node.TEXT_NODE) { const element = textNode as Element if (element.childNodes.length > 0 && offset < element.childNodes.length) { const childNode = element.childNodes[offset] if (childNode.nodeType === Node.TEXT_NODE) { textNode = childNode offset = 0 } else { return false } } else { return false } } // Check if we're already inside a formatting element const { parentElement } = textNode const existingFormatElement = parentElement?.closest(tagName.toLowerCase()) if (existingFormatElement) { // Remove existing formatting try { const parent = existingFormatElement.parentNode if (parent) { // Move all children of the format element to its parent while (existingFormatElement.firstChild) { parent.insertBefore(existingFormatElement.firstChild, existingFormatElement) } parent.removeChild(existingFormatElement) // Position cursor at the same relative position const newRange = doc.createRange() newRange.setStart(textNode, offset) newRange.collapse(true) const selection = doc.getSelection() if (selection) { selection.removeAllRanges() selection.addRange(newRange) } return true } } catch (error) { console.error('Error removing formatting from word:', error) return false } } const text = textNode.textContent || '' if (!text) { return false } // Find word boundaries - support Hebrew, English, and numbers let start = offset let end = offset // Regular expression for word characters (Hebrew, English, numbers) const wordChar = /[\u0590-\u05FF\u0600-\u06FF\w]/ // If we're at the end of a word (cursor right after the last character) // move back one position to include that word if (offset > 0 && wordChar.test(text[offset - 1]) && (offset >= text.length || !wordChar.test(text[offset]))) { start = offset - 1 end = offset - 1 } // Move start backwards to find word beginning while (start > 0 && wordChar.test(text[start - 1])) { start-- } // Move end forwards to find word ending while (end < text.length && wordChar.test(text[end])) { end++ } // If we found a word, apply formatting if (start < end && end > start) { try { // Split the text node into three parts: before, word, after const beforeText = text.substring(0, start) const wordText = text.substring(start, end) const afterText = text.substring(end) // Create the formatting element const formatElement = doc.createElement(tagName) formatElement.textContent = wordText // Replace the text node with the new structure const parent = textNode.parentNode if (parent) { // Create text nodes for before and after if (beforeText) { const beforeNode = doc.createTextNode(beforeText) parent.insertBefore(beforeNode, textNode) } parent.insertBefore(formatElement, textNode) if (afterText) { const afterNode = doc.createTextNode(afterText) parent.insertBefore(afterNode, textNode) } parent.removeChild(textNode) // Position cursor after the formatted word const newRange = doc.createRange() newRange.setStartAfter(formatElement) newRange.collapse(true) const selection = doc.getSelection() if (selection) { selection.removeAllRanges() selection.addRange(newRange) } return true } } catch (error) { console.error('Error applying formatting to word:', error) } } return false } function updateStateAfterCommand(state: EditorState) { if (!state.doc) { return } // Update content state.content = state.doc.body.innerHTML // Update selection state const selection = state.doc.getSelection() if (selection && selection.rangeCount > 0) { state.selection = selection state.range = selection.getRangeAt(0).cloneRange() state.rangeCount = selection.rangeCount } } function findBlockElement(node: Node): Element | null { let current = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as Element while (current) { if (current.matches && current.matches('p,h1,h2,h3,h4,h5,h6,blockquote,li,div')) { return current } current = current.parentElement } return null } // Centralized command creation helper function createCommand(name: string, execute: Command['execute'], isActive?: Command['isActive']): Command { return { name, execute: (state: EditorState, value?: string) => { if (!state.doc) { return } execute(state, value) updateStateAfterCommand(state) }, isActive } } export function createCommandRegistry(state: EditorState): CommandRegistry { // History commands const historyCommands = { undo: createCommand('Undo', () => { if (state.undoStack.length > 0 && state.doc) { const lastContent = state.undoStack.pop() if (lastContent !== undefined) { state.redoStack.push(state.content) state.content = lastContent state.doc.body.innerHTML = lastContent } } }), redo: createCommand('Redo', () => { if (state.redoStack.length > 0 && state.doc) { const nextContent = state.redoStack.pop() if (nextContent !== undefined) { state.undoStack.push(state.content) state.content = nextContent state.doc.body.innerHTML = nextContent } } }) } // Basic text formatting commands with toggle functionality const textCommands = { bold: createCommand('Bold', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } let { range, selection } = selectionInfo // If no text is selected, try to apply formatting to word at cursor if (range.collapsed) { const success = applyFormattingToWordAtCursor(state.doc!, range, 'b') if (success) { return } return } // Check if the selection is already bold const commonAncestor = range.commonAncestorContainer const parentElement = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor as Element const boldElement = parentElement?.closest('b, strong') if (boldElement) { // Remove bold formatting try { const parent = boldElement.parentNode while (boldElement.firstChild) { parent?.insertBefore(boldElement.firstChild, boldElement) } parent?.removeChild(boldElement) // Restore selection selection.removeAllRanges() selection.addRange(range) } catch (error) { console.error('Error removing bold:', error) } } else { // Apply bold formatting try { const b = state.doc!.createElement('b') try { range.surroundContents(b) } catch { const fragment = range.extractContents() b.appendChild(fragment) range.insertNode(b) } range.selectNodeContents(b) selection.removeAllRanges() selection.addRange(range) } catch (error) { console.error('Error applying bold:', error) } } }, (state) => { // isActive function for bold const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const commonAncestor = selectionInfo.range.commonAncestorContainer const parentElement = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor as Element return !!parentElement?.closest('b, strong') }), italic: createCommand('Italic', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } let { range, selection } = selectionInfo // If no text is selected, try to apply formatting to word at cursor if (range.collapsed) { const success = applyFormattingToWordAtCursor(state.doc!, range, 'i') if (success) { return } return } // Check if the selection is already italic const commonAncestor = range.commonAncestorContainer const parentElement = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor as Element const italicElement = parentElement?.closest('i, em') if (italicElement) { // Remove italic formatting try { const parent = italicElement.parentNode while (italicElement.firstChild) { parent?.insertBefore(italicElement.firstChild, italicElement) } parent?.removeChild(italicElement) // Restore selection selection.removeAllRanges() selection.addRange(range) } catch (error) { console.error('Error removing italic:', error) } } else { // Apply italic formatting try { const i = state.doc!.createElement('i') try { range.surroundContents(i) } catch { const fragment = range.extractContents() i.appendChild(fragment) range.insertNode(i) } range.selectNodeContents(i) selection.removeAllRanges() selection.addRange(range) } catch (error) { console.error('Error applying italic:', error) } } }, (state) => { // isActive function for italic const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const commonAncestor = selectionInfo.range.commonAncestorContainer const parentElement = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor as Element return !!parentElement?.closest('i, em') }), underline: createCommand('Underline', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } let { range, selection } = selectionInfo // If no text is selected, try to apply formatting to word at cursor if (range.collapsed) { const success = applyFormattingToWordAtCursor(state.doc!, range, 'u') if (success) { return } return } // Check if the selection is already underlined const commonAncestor = range.commonAncestorContainer const parentElement = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor as Element const underlineElement = parentElement?.closest('u') if (underlineElement) { // Remove underline formatting try { const parent = underlineElement.parentNode while (underlineElement.firstChild) { parent?.insertBefore(underlineElement.firstChild, underlineElement) } parent?.removeChild(underlineElement) // Restore selection selection.removeAllRanges() selection.addRange(range) } catch (error) { console.error('Error removing underline:', error) } } else { // Apply underline formatting try { const u = state.doc!.createElement('u') try { range.surroundContents(u) } catch { const fragment = range.extractContents() u.appendChild(fragment) range.insertNode(u) } range.selectNodeContents(u) selection.removeAllRanges() selection.addRange(range) } catch (error) { console.error('Error applying underline:', error) } } }, (state) => { // isActive function for underline const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const commonAncestor = selectionInfo.range.commonAncestorContainer const parentElement = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor as Element return !!parentElement?.closest('u') }), link: createCommand('Link', (state) => { const { openLinkModal } = state as any if (openLinkModal) { insertLink(openLinkModal, state) } }, (state) => { // isActive function for link const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const commonAncestor = selectionInfo.range.commonAncestorContainer const parentElement = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor as Element return !!parentElement?.closest('a') }) } // Heading commands const headingCommands = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce((acc, cmd) => ({ ...acc, [cmd]: createCommand(`Heading ${cmd}`, (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range, selection } = selectionInfo const currentBlock = findBlockElement(range.commonAncestorContainer) if (!currentBlock) { return } // Store the original selection information before modification const { startContainer } = range const { endContainer } = range const { startOffset } = range const { endOffset } = range const isCollapsed = range.collapsed // Check if we need to toggle off (already in the same heading) const isToggleOff = currentBlock.tagName.toLowerCase() === cmd.toLowerCase() const newTag = isToggleOff ? 'p' : cmd try { const newBlock = state.doc!.createElement(newTag) // Copy content from current block to new block while (currentBlock.firstChild) { newBlock.appendChild(currentBlock.firstChild) } // Replace the old block with the new one currentBlock.parentNode?.replaceChild(newBlock, currentBlock) // Restore selection within the new block try { const newRange = state.doc!.createRange() // Find corresponding nodes in the new block const findCorrespondingNode = (originalNode: Node, originalBlock: Element, newBlock: Element): Node | null => { if (originalNode === originalBlock) { return newBlock } // If it's a text node, find it by traversing the tree if (originalNode.nodeType === Node.TEXT_NODE) { const walker = state.doc!.createTreeWalker( newBlock, NodeFilter.SHOW_TEXT, null ) let textNodeIndex = 0 const originalWalker = state.doc!.createTreeWalker( originalBlock, NodeFilter.SHOW_TEXT, null ) let currentOriginalNode = originalWalker.nextNode() while (currentOriginalNode && currentOriginalNode !== originalNode) { textNodeIndex++ currentOriginalNode = originalWalker.nextNode() } let currentNewNode = walker.nextNode() let currentIndex = 0 while (currentNewNode && currentIndex < textNodeIndex) { currentIndex++ currentNewNode = walker.nextNode() } return currentNewNode } return newBlock.firstChild } const newStartContainer = findCorrespondingNode(startContainer, currentBlock, newBlock) const newEndContainer = findCorrespondingNode(endContainer, currentBlock, newBlock) if (newStartContainer && newEndContainer) { // Set start position if (newStartContainer.nodeType === Node.TEXT_NODE) { const maxOffset = Math.min(startOffset, newStartContainer.textContent?.length || 0) newRange.setStart(newStartContainer, maxOffset) } else { newRange.setStart(newStartContainer, 0) } // Set end position if (!isCollapsed) { if (newEndContainer.nodeType === Node.TEXT_NODE) { const maxOffset = Math.min(endOffset, newEndContainer.textContent?.length || 0) newRange.setEnd(newEndContainer, maxOffset) } else { newRange.setEnd(newEndContainer, 0) } } else { newRange.collapse(true) } // Apply the restored selection selection.removeAllRanges() selection.addRange(newRange) } else { // Fallback: select all content in the new block newRange.selectNodeContents(newBlock) if (isCollapsed) { newRange.collapse(true) } selection.removeAllRanges() selection.addRange(newRange) } } catch (selectionError) { console.warn('Error restoring selection after heading change:', selectionError) // Fallback: place cursor at start of the new block const fallbackRange = state.doc!.createRange() fallbackRange.selectNodeContents(newBlock) fallbackRange.collapse(true) selection.removeAllRanges() selection.addRange(fallbackRange) } // If we created a heading (not toggling off), ensure there's a paragraph after it if (!isToggleOff) { const nextSibling = newBlock.nextElementSibling if (!nextSibling) { // No element after the heading, create a paragraph const p = state.doc!.createElement('p') p.dir = newBlock.dir || state.doc!.body.dir newBlock.parentNode?.insertBefore(p, newBlock.nextSibling) } } } catch (error) { console.error(`Error applying ${cmd} heading:`, error) } }), }), {}) // Block commands const blockCommands = { p: createCommand('Paragraph', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const currentBlock = findBlockElement(range.commonAncestorContainer) if (!currentBlock) { return } // Check if already a paragraph - if so, nothing to do if (currentBlock.tagName.toLowerCase() === 'p') { return } try { const newParagraph = state.doc!.createElement('p') // Copy content from current block to paragraph while (currentBlock.firstChild) { newParagraph.appendChild(currentBlock.firstChild) } // Replace the old block with the paragraph currentBlock.parentNode?.replaceChild(newParagraph, currentBlock) // Move cursor into the new paragraph range.selectNodeContents(newParagraph) range.collapse(false) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(range) } catch (error) { console.error('Error applying paragraph:', error) } }), blockquote: createCommand('Blockquote', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const currentBlock = findBlockElement(range.commonAncestorContainer) if (!currentBlock) { return } // Check if already a blockquote - if so, toggle to paragraph const isToggleOff = currentBlock.tagName.toLowerCase() === 'blockquote' const newTag = isToggleOff ? 'p' : 'blockquote' try { const newBlock = state.doc!.createElement(newTag) // Copy content from current block to new block while (currentBlock.firstChild) { newBlock.appendChild(currentBlock.firstChild) } // Replace the old block with the new one currentBlock.parentNode?.replaceChild(newBlock, currentBlock) // Move cursor into the new block range.selectNodeContents(newBlock) range.collapse(false) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(range) } catch (error) { console.error('Error applying blockquote:', error) } }) } // List commands - simplified implementation const listCommands = { orderedList: createCommand('Ordered List', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const currentBlock = findBlockElement(range.commonAncestorContainer) if (!currentBlock) { return } // Check if we're inside a list item const listItem = currentBlock.closest('li') if (listItem) { const listParent = listItem.parentElement as HTMLElement if (!listParent) { return } // If it's already an ordered list, toggle off to paragraph if (listParent.tagName.toLowerCase() === 'ol') { const p = state.doc!.createElement('p') while (listItem.firstChild) { p.appendChild(listItem.firstChild) } listParent.parentNode?.insertBefore(p, listParent) listItem.remove() if (!listParent.querySelector('li')) { listParent.remove() } range.selectNodeContents(p) range.collapse(false) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(range) return } // Convert unordered list to ordered list if (listParent.tagName.toLowerCase() === 'ul') { const ol = state.doc!.createElement('ol') // Store current cursor position before conversion const currentOffset = range.startOffset const currentContainer = range.startContainer while (listParent.firstChild) { ol.appendChild(listParent.firstChild) } listParent.parentNode?.replaceChild(ol, listParent) // Restore cursor position try { const newRange = state.doc!.createRange() newRange.setStart(currentContainer, currentOffset) newRange.collapse(true) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(newRange) } catch { // Fallback: put cursor at beginning of first list item const firstLi = ol.querySelector('li') if (firstLi) { const newRange = state.doc!.createRange() newRange.selectNodeContents(firstLi) newRange.collapse(true) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(newRange) } } return } } // Convert current block to ordered list try { const ol = state.doc!.createElement('ol') const li = state.doc!.createElement('li') // Copy content to list item while (currentBlock.firstChild) { li.appendChild(currentBlock.firstChild) } ol.appendChild(li) currentBlock.parentNode?.replaceChild(ol, currentBlock) // Move cursor into the list item range.selectNodeContents(li) range.collapse(false) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(range) } catch (error) { console.error('Error creating ordered list:', error) } }), unorderedList: createCommand('Unordered List', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const currentBlock = findBlockElement(range.commonAncestorContainer) if (!currentBlock) { return } // Check if we're inside a list item const listItem = currentBlock.closest('li') if (listItem) { const listParent = listItem.parentElement as HTMLElement if (!listParent) { return } // If it's already an unordered list, toggle off to paragraph if (listParent.tagName.toLowerCase() === 'ul') { const p = state.doc!.createElement('p') while (listItem.firstChild) { p.appendChild(listItem.firstChild) } listParent.parentNode?.insertBefore(p, listParent) listItem.remove() if (!listParent.querySelector('li')) { listParent.remove() } range.selectNodeContents(p) range.collapse(false) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(range) return } // Convert ordered list to unordered list if (listParent.tagName.toLowerCase() === 'ol') { const ul = state.doc!.createElement('ul') // Store current cursor position before conversion const currentOffset = range.startOffset const currentContainer = range.startContainer while (listParent.firstChild) { ul.appendChild(listParent.firstChild) } listParent.parentNode?.replaceChild(ul, listParent) // Restore cursor position try { const newRange = state.doc!.createRange() newRange.setStart(currentContainer, currentOffset) newRange.collapse(true) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(newRange) } catch (e) { // Fallback: put cursor at beginning of first list item console.warn('Could not restore cursor position:', e) const firstLi = ul.querySelector('li') if (firstLi) { const newRange = state.doc!.createRange() newRange.selectNodeContents(firstLi) newRange.collapse(true) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(newRange) } } return } } // Convert current block to unordered list try { const ul = state.doc!.createElement('ul') const li = state.doc!.createElement('li') // Copy content to list item while (currentBlock.firstChild) { li.appendChild(currentBlock.firstChild) } ul.appendChild(li) currentBlock.parentNode?.replaceChild(ul, currentBlock) // Move cursor into the list item range.selectNodeContents(li) range.collapse(false) selectionInfo.selection.removeAllRanges() selectionInfo.selection.addRange(range) } catch (error) { console.error('Error creating unordered list:', error) } }) } // Clear formatting command - enhanced const formatCommands = { clear: createCommand('Clear Formatting', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range, selection } = selectionInfo try { // Function to clean text from unwanted characters function cleanText(text: string): string { return text // Remove zero-width characters .replace(/[\u200B-\u200D\uFEFF]/g, '') // Remove extra whitespace .replace(/\s+/g, ' ') // Remove invisible characters .replace(/\u00A0/g, ' ') // non-breaking space to regular space .trim() } // Function to clean a node recursively function cleanNode(node: Node): Node { if (node.nodeType === Node.TEXT_NODE) { const cleanedText = cleanText(node.textContent || '') return state.doc!.createTextNode(cleanedText) } if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element const tagName = element.tagName.toLowerCase() // Preserve structural elements, links, and media const structuralTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'div', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'b', 'i', 'u', 'a', 'img', 'iframe', 'video', 'audio', 'figure', 'figcaption'] // Remove formatting elements (excluding basic HTML formatting tags) const formattingTags = ['strong', 'em', 'span', 'font', 'strike', 'sub', 'sup', 'mark', 'del', 'ins', 'small', 'big'] if (formattingTags.includes(tagName)) { // For formatting elements, just return the cleaned text content const cleanedText = cleanText(element.textContent || '') return state.doc!.createTextNode(cleanedText) } if (structuralTags.includes(tagName)) { // For structural elements, keep the element but remove all styling attributes const cleanElement = state.doc!.createElement(tagName) // Only preserve essential attributes if (tagName === 'a' && element.hasAttribute('href')) { cleanElement.setAttribute('href', element.getAttribute('href') || '') if (element.hasAttribute('target')) { cleanElement.setAttribute('target', element.getAttribute('target') || '') } if (element.hasAttribute('rel')) { cleanElement.setAttribute('rel', element.getAttribute('rel') || '') } } else if (tagName === 'img') { if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '') if (element.hasAttribute('alt')) cleanElement.setAttribute('alt', element.getAttribute('alt') || '') if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '') if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '') } else if (tagName === 'iframe') { if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '') if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '') if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '') if (element.hasAttribute('frameborder')) cleanElement.setAttribute('frameborder', element.getAttribute('frameborder') || '') if (element.hasAttribute('allowfullscreen')) cleanElement.setAttribute('allowfullscreen', element.getAttribute('allowfullscreen') || '') } else if (tagName === 'video' || tagName === 'audio') { if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '') if (element.hasAttribute('controls')) cleanElement.setAttribute('controls', element.getAttribute('controls') || '') if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '') if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '') } // Clean and append child nodes Array.from(element.childNodes).forEach((child) => { const cleanedChild = cleanNode(child) if (cleanedChild.textContent?.trim() || cleanedChild.nodeType === Node.ELEMENT_NODE) { cleanElement.appendChild(cleanedChild) } }) return cleanElement } } return state.doc!.createTextNode('') } if (range.collapsed) { // If no selection, clean the entire document const { body } = (state.doc!) const cleanedFragment = state.doc!.createDocumentFragment() Array.from(body.childNodes).forEach((node) => { const cleanedNode = cleanNode(node) if (cleanedNode.textContent?.trim() || cleanedNode.nodeType === Node.ELEMENT_NODE) { cleanedFragment.appendChild(cleanedNode) } }) // Clear body and append cleaned content body.innerHTML = '' body.appendChild(cleanedFragment) // If body is empty, add a paragraph if (!body.firstElementChild) { const p = state.doc!.createElement('p') body.appendChild(p) // Set cursor in the paragraph const newRange = state.doc!.createRange() newRange.selectNodeContents(p) newRange.collapse(true) selection.removeAllRanges() selection.addRange(newRange) } } else { // If there's a selection, clean only the selected content const fragment = range.cloneContents() const tempDiv = state.doc!.createElement('div') tempDiv.appendChild(fragment) // Clean all nodes in the temp div const cleanedFragment = state.doc!.createDocumentFragment() Array.from(tempDiv.childNodes).forEach((node) => { const cleanedNode = cleanNode(node) if (cleanedNode.textContent?.trim() || cleanedNode.nodeType === Node.ELEMENT_NODE) { cleanedFragment.appendChild(cleanedNode) } }) // Replace the selection with cleaned content range.deleteContents() range.insertNode(cleanedFragment) // Restore selection range.collapse(false) selection.removeAllRanges() selection.addRange(range) } } catch (error) { console.error('Error clearing formatting:', error) } }) } // Table commands const tableCommands = { insertTable: createCommand('Insert Table', (state, value) => { console.log('insertTable command called with value:', value) // If we have an openTableEditor function in state, use it for new tables if ((state as any).openTableEditor && !value) { console.log('Opening table editor modal for new table') ; (state as any).openTableEditor(null) return } if (!value) { // Default fallback const [rows, cols] = [3, 3] insertTable(rows, cols, state) return } // Check if value is HTML string (from our advanced table editor) if (value.includes(' state.range && deleteTable(state.range)), mergeCells: createCommand('Merge Cells', state => state.range && state.doc && mergeCells(state.range, state.doc)), splitCells: createCommand('Split Cells', state => state.range && state.doc && splitCell(state.range, state.doc)), addRowBefore: createCommand('Add Row Before', state => state.range && state.doc && addRow('before', state.range, state.doc)), addRowAfter: createCommand('Add Row After', state => state.range && state.doc && addRow('after', state.range, state.doc)), deleteRow: createCommand('Delete Row', state => state.range && deleteRow(state.range)), insertColumnLeft: createCommand('Insert Column Left', state => state.range && insertColumn('before', state.range)), insertColumnRight: createCommand('Insert Column Right', state => state.range && insertColumn('after', state.range)), deleteColumn: createCommand('Delete Column', state => state.range && deleteColumn(state.range)) } // Table alignment commands const alignmentCommands = ['Left', 'Center', 'Right', 'Justify'].reduce((acc, align) => ({ ...acc, [`tableAlign${align}`]: createCommand(`Table Align ${align}`, (state) => { if (state.range) { const alignment = align === 'Left' ? 'start' : align === 'Right' ? 'end' : align.toLowerCase() as 'center' | 'justify' alignColumn(state.range, alignment) } }) }), {}) // Text alignment commands (for paragraphs) const textAlignmentCommands = { alignLeft: createCommand('Align Left', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') if (paragraph) { (paragraph as HTMLElement).style.textAlign = 'left' } }, (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') return (paragraph as HTMLElement)?.style.textAlign === 'left' }), alignCenter: createCommand('Align Center', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') if (paragraph) { (paragraph as HTMLElement).style.textAlign = 'center' } }, (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') return (paragraph as HTMLElement)?.style.textAlign === 'center' }), alignRight: createCommand('Align Right', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') if (paragraph) { (paragraph as HTMLElement).style.textAlign = 'right' } }, (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') return (paragraph as HTMLElement)?.style.textAlign === 'right' }), alignJustify: createCommand('Align Justify', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') if (paragraph) { (paragraph as HTMLElement).style.textAlign = 'justify' } }, (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') return (paragraph as HTMLElement)?.style.textAlign === 'justify' }), textDirection: createCommand('Toggle Text Direction', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') if (paragraph) { const currentDir = (paragraph as HTMLElement).dir || 'ltr' ; (paragraph as HTMLElement).dir = currentDir === 'ltr' ? 'rtl' : 'ltr' } }, (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') return (paragraph as HTMLElement)?.dir === 'rtl' }), ltrDirection: createCommand('Set Left to Right Direction', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') if (paragraph) { (paragraph as HTMLElement).dir = 'ltr' } }, (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') return (paragraph as HTMLElement)?.dir === 'ltr' }), rtlDirection: createCommand('Set Right to Left Direction', (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') if (paragraph) { (paragraph as HTMLElement).dir = 'rtl' } }, (state) => { const selectionInfo = getCurrentSelection(state) if (!selectionInfo) { return false } const { range } = selectionInfo const paragraph = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6') : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6') return (paragraph as HTMLElement)?.dir === 'rtl' }) } // View state commands const viewCommands = { fullScreen: createCommand('Full Screen', (state) => { state.isFullscreen = !state.isFullscreen }, state => state.isFullscreen), splitView: createCommand('Split View', (state) => { state.isSplitView = !state.isSplitView }, state => state.isSplitView), codeView: createCommand('Code View', (state) => { state.isCodeView = !state.isCodeView }, state => state.isCodeView) } // Media commands const mediaCommands = { image: createCommand('Insert Image', (state) => { const { openImageModal } = state as any if (openImageModal) { openImageModal() } else { console.warn('Image insertion requires modal implementation') } }), video: createCommand('Insert Video', (state) => { const { openVideoModal } = state as any if (openVideoModal) { openVideoModal() } else { console.warn('Video insertion requires modal implementation') } }), embed: createCommand('Insert Embed', (state) => { const { openEmbedModal } = state as any if (openEmbedModal) { openEmbedModal() } else { console.warn('Embed insertion requires modal implementation') } }) } return { ...historyCommands, ...textCommands, ...headingCommands, ...blockCommands, ...listCommands, ...formatCommands, ...tableCommands, ...alignmentCommands, ...textAlignmentCommands, ...viewCommands, ...mediaCommands } } export function createCommandExecutor(state: EditorState, commands: CommandRegistry): CommandExecutor { return { execute(command: string, value?: string) { const cmd = commands[command] if (cmd) { cmd.execute(state, value) } }, isActive(command: string): boolean { const cmd = commands[command] return cmd.isActive?.(state) || false }, getValue(command: string): string | null { const cmd = commands[command] return cmd.getValue?.(state) || null } } }