UNPKG

@jager-ai/holy-editor

Version:

Rich text editor with Bible verse slash commands and PWA keyboard tracking, extracted from Holy Habit project

450 lines (376 loc) 12.5 kB
/** * Text Formatter * * Text formatting engine for rich text editing * Extracted from Holy Habit holy-editor-pro.js */ import { FormatAction, FormatState, EditorSelection } from '../types/Editor'; export class TextFormatter { private editorId: string; constructor(editorId: string = 'holyEditor') { this.editorId = editorId; } /** * Get current selection information */ public getCurrentSelection(): EditorSelection | null { const selection = window.getSelection(); if (!selection || !selection.rangeCount) return null; const range = selection.getRangeAt(0); return { range, selectedText: range.toString(), hasSelection: !range.collapsed }; } /** * Toggle bold formatting */ public toggleBold(): void { const selectionInfo = this.getCurrentSelection(); if (!selectionInfo) return; const { range, selectedText, hasSelection } = selectionInfo; if (hasSelection) { const isBold = this.isCurrentlyBold(range); if (isBold) { this.removeBoldFromSelection(range); } else { this.applyBoldToSelection(range, selectedText); } } else { this.setNextInputStyle('bold'); } } /** * Toggle underline formatting */ public toggleUnderline(): void { const selectionInfo = this.getCurrentSelection(); if (!selectionInfo) return; const { range, selectedText, hasSelection } = selectionInfo; if (hasSelection) { const isUnderlined = this.isCurrentlyUnderlined(range); if (isUnderlined) { this.removeUnderlineFromSelection(range); } else { this.applyUnderlineToSelection(range, selectedText); } } else { this.setNextInputStyle('underline'); } } /** * Toggle heading1 formatting */ public toggleHeading1(): void { const selectionInfo = this.getCurrentSelection(); if (!selectionInfo) return; const { range, selectedText, hasSelection } = selectionInfo; if (hasSelection) { const isHeading = this.isCurrentlyHeading(range); if (isHeading) { this.removeHeadingFromSelection(range); } else { this.applyHeading1ToSelection(range, selectedText); } } else { this.setNextInputStyle('heading1'); } } /** * Toggle quote formatting */ public toggleQuote(): void { const selectionInfo = this.getCurrentSelection(); if (!selectionInfo) return; const { range, selectedText, hasSelection } = selectionInfo; if (hasSelection) { const isQuote = this.isCurrentlyQuote(range); if (isQuote) { this.removeQuoteFromSelection(range); } else { this.applyQuoteToSelection(range, selectedText); } } else { this.createEmptyInlineQuote(); } } /** * Apply text color */ public applyTextColor(color: string): void { const selectionInfo = this.getCurrentSelection(); if (!selectionInfo) return; const { range, selectedText, hasSelection } = selectionInfo; if (hasSelection) { this.applyColorToSelection(range, selectedText, color); } else { this.setNextInputStyle('color', color); } } /** * Toggle any formatting style */ public toggleStyle(action: FormatAction): void { switch (action) { case 'bold': this.toggleBold(); break; case 'underline': this.toggleUnderline(); break; case 'heading1': this.toggleHeading1(); break; case 'quote': this.toggleQuote(); break; default: console.warn('❌ Unsupported format action:', action); } } /** * Check current format state */ public getFormatState(): FormatState { const selectionInfo = this.getCurrentSelection(); if (!selectionInfo) { return { bold: false, underline: false, heading1: false, quote: false, textcolor: false }; } const { range } = selectionInfo; return { bold: this.isCurrentlyBold(range), underline: this.isCurrentlyUnderlined(range), heading1: this.isCurrentlyHeading(range), quote: this.isCurrentlyQuote(range), textcolor: this.isCurrentlyTextColor(range) }; } // Private methods for format detection private isCurrentlyBold(range: Range): boolean { return this.hasFormatTag(range, (node) => node.tagName === 'STRONG' || node.tagName === 'B' ); } private isCurrentlyUnderlined(range: Range): boolean { return this.hasFormatTag(range, (node) => node.tagName === 'U' ); } private isCurrentlyHeading(range: Range): boolean { return this.hasFormatTag(range, (node) => node.tagName === 'SPAN' && node.style.fontSize === '20px' && node.style.fontWeight === 'bold' ); } private isCurrentlyQuote(range: Range): boolean { return this.hasFormatTag(range, (node) => node.tagName === 'BLOCKQUOTE' && node.classList && node.classList.contains('inline-quote') ); } private isCurrentlyTextColor(range: Range): boolean { return this.hasFormatTag(range, (node) => node.style && node.style.color && node.style.color !== '' && node.style.color !== 'inherit' ); } private hasFormatTag(range: Range, checkFunction: (node: HTMLElement) => boolean): boolean { const container = range.commonAncestorContainer; let parent = container.nodeType === Node.TEXT_NODE ? container.parentNode : container; while (parent && parent !== document.getElementById(this.editorId)) { if (parent instanceof HTMLElement && checkFunction(parent)) { return true; } parent = parent.parentNode; } return false; } // Private methods for applying formats private applyBoldToSelection(range: Range, selectedText: string): void { const strong = document.createElement('strong'); strong.textContent = selectedText; range.deleteContents(); range.insertNode(strong); this.selectNode(range, strong); console.log('💪 Bold formatting applied'); } private applyUnderlineToSelection(range: Range, selectedText: string): void { const u = document.createElement('u'); u.textContent = selectedText; range.deleteContents(); range.insertNode(u); this.selectNode(range, u); console.log('✏️ Underline formatting applied'); } private applyHeading1ToSelection(range: Range, selectedText: string): void { const span = document.createElement('span'); span.style.fontSize = '20px'; span.style.fontWeight = 'bold'; span.textContent = selectedText; range.deleteContents(); range.insertNode(span); this.selectNode(range, span); console.log('📝 Heading1 formatting applied'); } private applyQuoteToSelection(range: Range, selectedText: string): void { const quote = document.createElement('blockquote'); quote.className = 'inline-quote'; quote.textContent = selectedText; range.deleteContents(); range.insertNode(quote); // Move cursor after quote range.setStartAfter(quote); range.collapse(true); this.updateSelection(range); console.log('💬 Quote formatting applied'); } private applyColorToSelection(range: Range, selectedText: string, color: string): void { const colorSpan = document.createElement('span'); colorSpan.style.color = color; try { range.surroundContents(colorSpan); } catch (e) { // Fallback for complex selections const contents = range.extractContents(); colorSpan.appendChild(contents); range.insertNode(colorSpan); } this.selectNode(range, colorSpan); console.log('🎨 Color formatting applied:', color); } // Private methods for removing formats private removeBoldFromSelection(range: Range): void { this.removeFormatFromSelection(range, (node) => node.tagName === 'STRONG' || node.tagName === 'B' ); console.log('💪 Bold formatting removed'); } private removeUnderlineFromSelection(range: Range): void { this.removeFormatFromSelection(range, (node) => node.tagName === 'U' ); console.log('✏️ Underline formatting removed'); } private removeHeadingFromSelection(range: Range): void { this.removeFormatFromSelection(range, (node) => node.tagName === 'SPAN' && node.style.fontSize === '20px' && node.style.fontWeight === 'bold' ); console.log('📝 Heading formatting removed'); } private removeQuoteFromSelection(range: Range): void { this.removeFormatFromSelection(range, (node) => node.tagName === 'BLOCKQUOTE' && node.classList && node.classList.contains('inline-quote') ); console.log('💬 Quote formatting removed'); } private removeFormatFromSelection(range: Range, checkFunction: (node: HTMLElement) => boolean): void { const container = range.commonAncestorContainer; let parent = container.nodeType === Node.TEXT_NODE ? container.parentNode : container; while (parent && parent !== document.getElementById(this.editorId)) { if (parent instanceof HTMLElement && checkFunction(parent)) { const fragment = this.extractNodeContents(parent); parent.parentNode?.insertBefore(fragment, parent); parent.parentNode?.removeChild(parent); this.restoreSelectionFromFragment(range, fragment, parent.parentNode); break; } parent = parent.parentNode; } } // Utility methods private selectNode(range: Range, node: Node): void { range.selectNodeContents(node); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); } private updateSelection(range: Range): void { const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); } private extractNodeContents(node: Node): DocumentFragment { const fragment = document.createDocumentFragment(); while (node.firstChild) { fragment.appendChild(node.firstChild); } return fragment; } private restoreSelectionFromFragment(range: Range, fragment: DocumentFragment, parentNode: Node | null): void { const newRange = document.createRange(); const startNode = fragment.firstChild || parentNode; const endNode = fragment.lastChild || parentNode; if (startNode && endNode && parentNode) { const endOffset = endNode.textContent ? endNode.textContent.length : 0; newRange.setStart(startNode, 0); newRange.setEnd(endNode, endOffset); this.updateSelection(newRange); } } private createEmptyInlineQuote(): void { const selectionInfo = this.getCurrentSelection(); if (!selectionInfo) return; const { range } = selectionInfo; const quote = document.createElement('blockquote'); quote.className = 'inline-quote'; quote.innerHTML = '​'; // Zero-width space range.insertNode(quote); // Move cursor inside quote range.selectNodeContents(quote); range.collapse(false); this.updateSelection(range); console.log('💬 Empty quote block created'); } private setNextInputStyle(style: string, value?: string): void { const selectionInfo = this.getCurrentSelection(); if (!selectionInfo) return; const { range } = selectionInfo; let marker: HTMLElement; switch (style) { case 'bold': marker = document.createElement('strong'); break; case 'underline': marker = document.createElement('u'); break; case 'heading1': marker = document.createElement('span'); marker.style.fontSize = '20px'; marker.style.fontWeight = 'bold'; break; case 'color': marker = document.createElement('span'); if (value) { marker.style.color = value; } break; default: return; } marker.innerHTML = '​'; // Zero-width space range.insertNode(marker); range.setStartAfter(marker); range.collapse(true); this.updateSelection(range); // Move cursor inside marker range.selectNodeContents(marker); range.collapse(false); this.updateSelection(range); console.log('🎯 Next input style set:', style); } }