UNPKG

@mvp-factory/holy-editor

Version:

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

832 lines (708 loc) β€’ 24.4 kB
/** * Holy Editor * * Main editor class that integrates all components * Extracted from Holy Habit holy-editor-pro.js */ import { EditorConfig, EditorSelection, FormatAction, FormatState, BibleVerseData, EditorError, BibleApiError, SlashCommandMatch } from './types/Editor'; import { BibleVerseEngine } from './core/BibleVerseEngine'; import { TextFormatter } from './core/TextFormatter'; import { PWAKeyboardTracker } from './pwa/PWAKeyboardTracker'; import { ToastManager } from './ui/ToastManager'; import { ColorPicker } from './ui/ColorPicker'; import { AutoSaveManager } from './utils/AutoSaveManager'; export class HolyEditor { private editorElement: HTMLElement; private config: EditorConfig; private isInitialized = false; // Core components private bibleEngine: BibleVerseEngine; private textFormatter: TextFormatter; private keyboardTracker: PWAKeyboardTracker; private toastManager: ToastManager; private colorPicker: ColorPicker; private autoSaveManager: AutoSaveManager | null = null; // Event listeners for cleanup private eventListeners: Array<{ element: EventTarget; event: string; handler: EventListener }> = []; // State management private isProcessingSlashCommand = false; private lastInputTime = 0; private inputDebounceMs = 300; constructor(editorId: string, config?: Partial<EditorConfig>) { const editorElement = document.getElementById(editorId); if (!editorElement) { throw new EditorError(`Editor element with id "${editorId}" not found`); } this.editorElement = editorElement; this.config = { enableBibleVerses: true, enableTextFormatting: true, enablePWAKeyboard: true, enableColorPicker: true, enableAutoSave: true, apiEndpoint: '/api/bible_verse_full.php', debounceMs: 300, autoSaveInterval: 30000, autoSaveKey: undefined, keyboardSettings: { threshold: 10, keyboardMin: 150, debounceTime: 0 }, ...config }; // Initialize components this.initializeComponents(); console.log('πŸ“ HolyEditor created for element:', editorId); } /** * Initialize all editor components */ private initializeComponents(): void { try { // Initialize Bible verse engine if (this.config.enableBibleVerses) { this.bibleEngine = BibleVerseEngine.getInstance( this.config.apiEndpoint, this.config.debounceMs ); } // Initialize text formatter if (this.config.enableTextFormatting) { this.textFormatter = new TextFormatter(this.editorElement.id); } // Initialize PWA keyboard tracker if (this.config.enablePWAKeyboard) { this.keyboardTracker = new PWAKeyboardTracker(this.config.keyboardSettings); } // Initialize UI components this.toastManager = ToastManager.getInstance(); if (this.config.enableColorPicker) { this.colorPicker = ColorPicker.getInstance(); } // Initialize auto-save manager if (this.config.enableAutoSave) { this.autoSaveManager = new AutoSaveManager(this.editorElement.id, { interval: this.config.autoSaveInterval, key: this.config.autoSaveKey, onSave: (data) => { this.toastManager.success('λ‚΄μš©μ΄ μžλ™ μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€', 2000); }, onError: (error) => { console.error('❌ Auto-save error:', error); this.toastManager.error('μžλ™ μ €μž₯ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€'); } }); } console.log('πŸ”§ HolyEditor components initialized'); } catch (error) { console.error('❌ Failed to initialize components:', error); throw new EditorError('Component initialization failed', 'INIT_ERROR', error as Error); } } /** * Initialize the editor */ public initialize(): void { if (this.isInitialized) { console.warn('⚠️ HolyEditor already initialized'); return; } try { this.setupEditor(); this.setupEventListeners(); this.initializePWAFeatures(); this.initializeAutoSave(); this.isInitialized = true; console.log('βœ… HolyEditor initialized successfully'); this.toastManager.success('에디터가 μ€€λΉ„λ˜μ—ˆμŠ΅λ‹ˆλ‹€'); } catch (error) { console.error('❌ HolyEditor initialization failed:', error); throw new EditorError('Editor initialization failed', 'INIT_ERROR', error as Error); } } /** * Setup editor element */ private setupEditor(): void { // Make contenteditable if not already if (!this.editorElement.hasAttribute('contenteditable')) { this.editorElement.setAttribute('contenteditable', 'true'); } // Add editor class for styling this.editorElement.classList.add('holy-editor'); // Set placeholder if empty if (!this.editorElement.textContent?.trim()) { this.editorElement.innerHTML = '<p>μ„±κ²½ κ΅¬μ ˆμ„ μž…λ ₯ν•˜λ €λ©΄ /갈2:20 같은 ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•˜μ„Έμš”...</p>'; } // Ensure editor has focus capabilities if (!this.editorElement.hasAttribute('tabindex')) { this.editorElement.setAttribute('tabindex', '0'); } } /** * Setup all event listeners */ private setupEventListeners(): void { // Input events for slash command detection this.addEventListener(this.editorElement, 'input', this.handleInput.bind(this)); this.addEventListener(this.editorElement, 'keydown', this.handleKeyDown.bind(this)); this.addEventListener(this.editorElement, 'paste', this.handlePaste.bind(this)); // Focus events for PWA keyboard tracking this.addEventListener(this.editorElement, 'focus', this.handleFocus.bind(this)); this.addEventListener(this.editorElement, 'blur', this.handleBlur.bind(this)); // Selection change for formatting state this.addEventListener(document, 'selectionchange', this.handleSelectionChange.bind(this)); console.log('🎯 Event listeners setup complete'); } /** * Initialize PWA features */ private initializePWAFeatures(): void { if (this.config.enablePWAKeyboard && this.keyboardTracker) { this.keyboardTracker.initialize(this.editorElement); } } /** * Initialize auto-save features */ private initializeAutoSave(): void { if (!this.autoSaveManager || !this.config.enableAutoSave) return; // Check for saved content const savedContent = this.autoSaveManager.restore(); if (savedContent && savedContent.trim()) { // Ask user if they want to restore const saveInfo = this.autoSaveManager.getSaveInfo(); if (saveInfo) { const ageMinutes = Math.floor((Date.now() - saveInfo.timestamp) / 60000); const shouldRestore = confirm( `이전에 μž‘μ„±ν•˜λ˜ λ‚΄μš©μ΄ μžˆμŠ΅λ‹ˆλ‹€ (${ageMinutes}λΆ„ μ „).\nλ³΅μ›ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?` ); if (shouldRestore) { this.setContent(savedContent); this.toastManager.success('μ €μž₯된 λ‚΄μš©μ„ λ³΅μ›ν–ˆμŠ΅λ‹ˆλ‹€'); } else { // Clear the saved content if user declines this.autoSaveManager.clear(); } } } // Start auto-saving this.autoSaveManager.start(() => this.getContent()); } /** * Handle input events */ private handleInput(event: Event): void { if (this.isProcessingSlashCommand) return; const now = Date.now(); this.lastInputTime = now; // Debounced slash command processing setTimeout(() => { if (this.lastInputTime === now && this.config.enableBibleVerses) { this.processSlashCommands(); } }, this.inputDebounceMs); // Trigger auto-save on input (auto-save manager handles its own debouncing) if (this.autoSaveManager && this.autoSaveManager.isRunning()) { // Content will be saved at the next interval } } /** * Handle keydown events */ private handleKeyDown(event: KeyboardEvent): void { // Handle formatting shortcuts if (event.ctrlKey || event.metaKey) { switch (event.key.toLowerCase()) { case 'b': event.preventDefault(); this.toggleFormat('bold'); break; case 'u': event.preventDefault(); this.toggleFormat('underline'); break; case 'h': event.preventDefault(); this.toggleFormat('heading1'); break; case 'q': event.preventDefault(); this.toggleFormat('quote'); break; } } // Handle Enter key in quotes if (event.key === 'Enter') { const selection = window.getSelection(); if (selection && selection.anchorNode) { const quoteParent = this.findParentQuote(selection.anchorNode); if (quoteParent) { event.preventDefault(); this.handleEnterInQuote(); } } } } /** * Handle paste events */ private handlePaste(event: ClipboardEvent): void { event.preventDefault(); const text = event.clipboardData?.getData('text/plain') || ''; if (text) { // Insert as plain text document.execCommand('insertText', false, text); // Process any slash commands in pasted text setTimeout(() => { if (this.config.enableBibleVerses) { this.processSlashCommands(); } }, 100); } } /** * Handle focus events */ private handleFocus(): void { console.log('πŸ“ Editor focused'); this.editorElement.classList.add('holy-editor-focused'); } /** * Handle blur events */ private handleBlur(): void { console.log('πŸ“ Editor blurred'); this.editorElement.classList.remove('holy-editor-focused'); } /** * Handle selection change */ private handleSelectionChange(): void { // Update formatting state indicators if needed if (this.config.enableTextFormatting && this.textFormatter) { const formatState = this.textFormatter.getFormatState(); this.updateFormatButtons(formatState); } } /** * Process slash commands in editor content */ private async processSlashCommands(): Promise<void> { if (!this.bibleEngine || this.isProcessingSlashCommand) return; this.isProcessingSlashCommand = true; try { const content = this.editorElement.textContent || ''; const matches = this.bibleEngine.parseSlashCommands(content); for (const match of matches) { await this.processSlashCommand(match); } } catch (error) { console.error('❌ Error processing slash commands:', error); if (error instanceof BibleApiError) { this.toastManager.handleApiError(error); } else { this.toastManager.error('μŠ¬λž˜μ‹œ λͺ…λ Ήμ–΄ 처리 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€'); } } finally { this.isProcessingSlashCommand = false; } } /** * Process individual slash command */ private async processSlashCommand(match: SlashCommandMatch): Promise<void> { const { ref, position, fullMatch } = match; console.log('πŸ“– Processing slash command:', ref); // Show loading toast const hideLoading = this.toastManager.showLoading(`${ref} ꡬ절 검색 쀑...`); try { const verseData = await this.bibleEngine.loadVerse(ref); hideLoading(); if (verseData) { this.insertVerseAtPosition(verseData, position, fullMatch.length); this.bibleEngine.updateInsertionState(ref); this.toastManager.success(`${ref} ꡬ절이 μ‚½μž…λ˜μ—ˆμŠ΅λ‹ˆλ‹€`); } else { this.toastManager.error(`${ref} κ΅¬μ ˆμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€`); } } catch (error) { hideLoading(); console.error('❌ Failed to load verse:', ref, error); if (error instanceof BibleApiError) { this.toastManager.handleApiError(error, ref); } else { this.toastManager.error(`${ref} ꡬ절 λ‘œλ“œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€`); } } } /** * Insert verse at specific position */ private insertVerseAtPosition(verseData: BibleVerseData, position: number, commandLength: number): void { const content = this.editorElement.textContent || ''; const beforeText = content.substring(0, position); const afterText = content.substring(position + commandLength); // Create verse HTML const verseHtml = this.createVerseHtml(verseData); // Replace content const newContent = beforeText + verseHtml + afterText; this.editorElement.innerHTML = newContent; // Position cursor after inserted verse this.positionCursorAfterVerse(position + verseHtml.length); } /** * Create HTML for verse display */ private createVerseHtml(verseData: BibleVerseData): string { if (verseData.isRange && verseData.verses.length > 1) { // Multiple verses (range) const versesHtml = verseData.verses.map(verse => `<span class="verse-text">${verse.text}</span>` ).join(' '); const firstVerse = verseData.verses[0]; const lastVerse = verseData.verses[verseData.verses.length - 1]; const reference = `${firstVerse.book} ${firstVerse.chapter}:${firstVerse.verse}-${lastVerse.verse}`; return `<blockquote class="bible-verse-range" data-reference="${reference}"> ${versesHtml} <cite class="verse-reference">${reference}</cite> </blockquote>`; } else { // Single verse const verse = verseData.verses[0]; const reference = `${verse.book} ${verse.chapter}:${verse.verse}`; return `<blockquote class="bible-verse" data-reference="${reference}"> <span class="verse-text">${verse.text}</span> <cite class="verse-reference">${reference}</cite> </blockquote>`; } } /** * Position cursor after inserted content */ private positionCursorAfterVerse(position: number): void { const range = document.createRange(); const selection = window.getSelection(); if (selection) { try { const textNode = this.findTextNodeAtPosition(position); if (textNode) { range.setStart(textNode, Math.min(position, textNode.textContent?.length || 0)); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } } catch (error) { console.warn('⚠️ Could not position cursor:', error); } } } /** * Find text node at specific position */ private findTextNodeAtPosition(position: number): Text | null { const walker = document.createTreeWalker( this.editorElement, NodeFilter.SHOW_TEXT, null ); let currentPosition = 0; let node = walker.nextNode() as Text; while (node) { const nodeLength = node.textContent?.length || 0; if (currentPosition + nodeLength >= position) { return node; } currentPosition += nodeLength; node = walker.nextNode() as Text; } return null; } /** * Toggle text formatting */ public toggleFormat(action: FormatAction): void { if (!this.config.enableTextFormatting || !this.textFormatter) { console.warn('⚠️ Text formatting is disabled'); return; } try { this.textFormatter.toggleStyle(action); // Update button states const formatState = this.textFormatter.getFormatState(); this.updateFormatButtons(formatState); } catch (error) { console.error('❌ Format toggle failed:', error); this.toastManager.error('ν…μŠ€νŠΈ ν¬λ§·νŒ… 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€'); } } /** * Apply text color */ public applyTextColor(color: string): void { if (!this.config.enableTextFormatting || !this.textFormatter) { console.warn('⚠️ Text formatting is disabled'); return; } try { this.textFormatter.applyTextColor(color); } catch (error) { console.error('❌ Color application failed:', error); this.toastManager.error('색상 적용 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€'); } } /** * Show color picker */ public showColorPicker(): void { if (!this.config.enableColorPicker || !this.colorPicker) { console.warn('⚠️ Color picker is disabled'); return; } this.colorPicker.show((color: string) => { this.applyTextColor(color); }); } /** * Get current editor content */ public getContent(): string { return this.editorElement.innerHTML; } /** * Set editor content */ public setContent(html: string): void { this.editorElement.innerHTML = html; } /** * Get plain text content */ public getTextContent(): string { return this.editorElement.textContent || ''; } /** * Clear editor content */ public clear(): void { this.editorElement.innerHTML = ''; } /** * Focus the editor */ public focus(): void { this.editorElement.focus(); } /** * Check if editor has focus */ public isFocused(): boolean { return document.activeElement === this.editorElement; } /** * Get current format state */ public getFormatState(): FormatState | null { if (!this.config.enableTextFormatting || !this.textFormatter) { return null; } return this.textFormatter.getFormatState(); } /** * Insert verse programmatically */ public async insertVerse(ref: string): Promise<boolean> { if (!this.config.enableBibleVerses || !this.bibleEngine) { console.warn('⚠️ Bible verses are disabled'); return false; } try { if (!this.bibleEngine.isValidBibleRef(ref)) { this.toastManager.error(`μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ μ„±κ²½ μ°Έμ‘°: ${ref}`); return false; } const hideLoading = this.toastManager.showLoading(`${ref} ꡬ절 검색 쀑...`); const verseData = await this.bibleEngine.loadVerse(ref); hideLoading(); if (verseData) { const verseHtml = this.createVerseHtml(verseData); // Insert at current cursor position const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); range.deleteContents(); const tempDiv = document.createElement('div'); tempDiv.innerHTML = verseHtml; const fragment = document.createDocumentFragment(); while (tempDiv.firstChild) { fragment.appendChild(tempDiv.firstChild); } range.insertNode(fragment); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } else { // No selection, append to end this.editorElement.insertAdjacentHTML('beforeend', verseHtml); } this.toastManager.success(`${ref} ꡬ절이 μ‚½μž…λ˜μ—ˆμŠ΅λ‹ˆλ‹€`); return true; } else { this.toastManager.error(`${ref} κ΅¬μ ˆμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€`); return false; } } catch (error) { console.error('❌ Failed to insert verse:', error); if (error instanceof BibleApiError) { this.toastManager.handleApiError(error, ref); } else { this.toastManager.error('ꡬ절 μ‚½μž… 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€'); } return false; } } /** * Destroy the editor and cleanup */ public destroy(): void { if (!this.isInitialized) return; try { // Remove all event listeners this.eventListeners.forEach(({ element, event, handler }) => { element.removeEventListener(event, handler); }); this.eventListeners = []; // Destroy PWA keyboard tracker if (this.keyboardTracker) { this.keyboardTracker.destroy(); } // Hide color picker if open if (this.colorPicker && this.colorPicker.isOpen()) { this.colorPicker.hide(); } // Stop auto-save if (this.autoSaveManager) { this.autoSaveManager.stop(); } // Clear editor classes this.editorElement.classList.remove('holy-editor', 'holy-editor-focused'); this.isInitialized = false; console.log('πŸ—‘οΈ HolyEditor destroyed'); } catch (error) { console.error('❌ Error during editor destruction:', error); } } // Private utility methods private addEventListener(element: EventTarget, event: string, handler: EventListener): void { element.addEventListener(event, handler); this.eventListeners.push({ element, event, handler }); } private findParentQuote(node: Node): Element | null { let current = node.nodeType === Node.TEXT_NODE ? node.parentNode : node; while (current && current !== this.editorElement) { if (current instanceof Element && (current.tagName === 'BLOCKQUOTE' || current.classList.contains('inline-quote'))) { return current; } current = current.parentNode; } return null; } private handleEnterInQuote(): void { // Create new paragraph after quote const p = document.createElement('p'); p.innerHTML = '&#8203;'; // Zero-width space const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const quote = this.findParentQuote(range.startContainer); if (quote && quote.parentNode) { quote.parentNode.insertBefore(p, quote.nextSibling); // Move cursor to new paragraph range.selectNodeContents(p); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } } } private updateFormatButtons(formatState: FormatState): void { // This would update external format buttons if they exist // Can be overridden by implementing applications const event = new CustomEvent('holyeditor:formatstatechange', { detail: formatState }); this.editorElement.dispatchEvent(event); } /** * Manually save content (auto-save) */ public saveContent(): boolean { if (!this.autoSaveManager || !this.config.enableAutoSave) { console.warn('⚠️ Auto-save is disabled'); return false; } return this.autoSaveManager.save(this.getContent()); } /** * Clear saved content */ public clearSavedContent(): boolean { if (!this.autoSaveManager) return false; return this.autoSaveManager.clear(); } /** * Check if there's saved content */ public hasSavedContent(): boolean { if (!this.autoSaveManager) return false; return this.autoSaveManager.hasSavedContent(); } /** * Get auto-save info */ public getAutoSaveInfo(): { isRunning: boolean; lastSave?: number; hasContent: boolean } { if (!this.autoSaveManager) { return { isRunning: false, hasContent: false }; } const saveInfo = this.autoSaveManager.getSaveInfo(); return { isRunning: this.autoSaveManager.isRunning(), lastSave: saveInfo?.timestamp, hasContent: this.autoSaveManager.hasSavedContent() }; } /** * Update auto-save interval */ public updateAutoSaveInterval(intervalMs: number): void { if (!this.autoSaveManager) return; this.autoSaveManager.updateInterval(intervalMs, () => this.getContent()); this.config.autoSaveInterval = intervalMs; } /** * Toggle auto-save */ public toggleAutoSave(enable?: boolean): void { const shouldEnable = enable !== undefined ? enable : !this.config.enableAutoSave; this.config.enableAutoSave = shouldEnable; if (!this.autoSaveManager) return; if (shouldEnable) { this.autoSaveManager.start(() => this.getContent()); this.toastManager.success('μžλ™ μ €μž₯이 ν™œμ„±ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€'); } else { this.autoSaveManager.stop(); this.toastManager.info('μžλ™ μ €μž₯이 λΉ„ν™œμ„±ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€'); } } }