UNPKG

@jager-ai/holy-editor

Version:

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

431 lines (372 loc) 11 kB
/** * Color Picker * * Color selection dialog for text formatting * Extracted from Holy Habit holy-editor-pro.js */ import { ColorOption } from '../types/Editor'; import { ToastManager } from './ToastManager'; export class ColorPicker { private static instance: ColorPicker; private isVisible = false; private onColorSelect: ((color: string) => void) | null = null; // Default 8-color palette private static readonly DEFAULT_COLORS: ColorOption[] = [ { color: '#ff0000', name: '빨간색' }, { color: '#ff8c00', name: '주황색' }, { color: '#ffd700', name: '노란색' }, { color: '#008000', name: '초록색' }, { color: '#0000ff', name: '파란색' }, { color: '#000080', name: '남색' }, { color: '#800080', name: '보라색' }, { color: '#ff1493', name: '분홍색' } ]; private colors: ColorOption[] = ColorPicker.DEFAULT_COLORS; private toastManager: ToastManager; private constructor() { this.toastManager = ToastManager.getInstance(); } /** * Get singleton instance */ public static getInstance(): ColorPicker { if (!ColorPicker.instance) { ColorPicker.instance = new ColorPicker(); } return ColorPicker.instance; } /** * Show color picker dialog */ public show(onColorSelect?: (color: string) => void): void { // Close existing picker if (this.isVisible) { this.hide(); return; } this.onColorSelect = onColorSelect || null; this.createColorPickerDialog(); this.isVisible = true; } /** * Hide color picker dialog */ public hide(): void { const existingPicker = document.querySelector('.holy-color-picker'); if (existingPicker) { existingPicker.remove(); } this.isVisible = false; this.onColorSelect = null; } /** * Set custom color palette */ public setColors(colors: ColorOption[]): void { this.colors = colors; } /** * Get current color palette */ public getColors(): ColorOption[] { return [...this.colors]; } /** * Add color to palette */ public addColor(color: ColorOption): void { this.colors.push(color); } /** * Remove color from palette */ public removeColor(colorValue: string): void { this.colors = this.colors.filter(c => c.color !== colorValue); } /** * Reset to default colors */ public resetToDefaults(): void { this.colors = [...ColorPicker.DEFAULT_COLORS]; } /** * Create color picker dialog */ private createColorPickerDialog(): void { // Remove existing picker const existingPicker = document.querySelector('.holy-color-picker'); if (existingPicker) { existingPicker.remove(); } // Create dialog container const pickerContainer = document.createElement('div'); pickerContainer.className = 'holy-color-picker'; this.applyContainerStyles(pickerContainer); // Create title const title = this.createTitle(); pickerContainer.appendChild(title); // Create color grid const colorGrid = this.createColorGrid(); pickerContainer.appendChild(colorGrid); // Create close button const closeButton = this.createCloseButton(); pickerContainer.appendChild(closeButton); // Add to document document.body.appendChild(pickerContainer); // Setup outside click handler this.setupOutsideClickHandler(pickerContainer); console.log('🎨 Color picker dialog shown'); } /** * Apply container styles */ private applyContainerStyles(container: HTMLElement): void { container.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: white; border: 1px solid #ccc; border-radius: 8px; padding: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); z-index: 10000; min-width: 280px; max-width: 90vw; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; `; } /** * Create title element */ private createTitle(): HTMLElement { const title = document.createElement('h3'); title.textContent = '텍스트 색상 선택'; title.style.cssText = ` margin: 0 0 15px 0; font-size: 16px; text-align: center; color: #333; font-weight: 600; `; return title; } /** * Create color grid */ private createColorGrid(): HTMLElement { const colorGrid = document.createElement('div'); colorGrid.style.cssText = ` display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 15px; `; // Create color buttons this.colors.forEach((colorOption) => { const colorButton = this.createColorButton(colorOption); colorGrid.appendChild(colorButton); }); return colorGrid; } /** * Create individual color button */ private createColorButton(colorOption: ColorOption): HTMLElement { const colorButton = document.createElement('button'); colorButton.style.cssText = ` width: 50px; height: 50px; background-color: ${colorOption.color}; border: 2px solid #ddd; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; position: relative; overflow: hidden; `; colorButton.title = colorOption.name; colorButton.setAttribute('aria-label', `색상 선택: ${colorOption.name}`); // Add hover effects this.addButtonHoverEffects(colorButton); // Add click handler colorButton.addEventListener('click', () => { this.handleColorSelection(colorOption); }); return colorButton; } /** * Add hover effects to color button */ private addButtonHoverEffects(button: HTMLElement): void { button.addEventListener('mouseenter', () => { button.style.transform = 'scale(1.1)'; button.style.borderColor = '#333'; button.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)'; }); button.addEventListener('mouseleave', () => { button.style.transform = 'scale(1)'; button.style.borderColor = '#ddd'; button.style.boxShadow = 'none'; }); // Add ripple effect on click button.addEventListener('click', (e) => { const ripple = document.createElement('div'); const rect = button.getBoundingClientRect(); const size = Math.max(rect.width, rect.height); const x = e.clientX - rect.left - size / 2; const y = e.clientY - rect.top - size / 2; ripple.style.cssText = ` position: absolute; width: ${size}px; height: ${size}px; left: ${x}px; top: ${y}px; background: rgba(255,255,255,0.6); border-radius: 50%; transform: scale(0); animation: ripple 0.6s linear; pointer-events: none; `; // Add ripple animation if not exists this.addRippleAnimation(); button.appendChild(ripple); setTimeout(() => { ripple.remove(); }, 600); }); } /** * Create close button */ private createCloseButton(): HTMLElement { const closeButton = document.createElement('button'); closeButton.textContent = '닫기'; closeButton.style.cssText = ` width: 100%; padding: 8px 16px; background-color: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; font-size: 14px; transition: background-color 0.2s ease; font-family: inherit; `; closeButton.addEventListener('mouseenter', () => { closeButton.style.backgroundColor = '#e0e0e0'; }); closeButton.addEventListener('mouseleave', () => { closeButton.style.backgroundColor = '#f0f0f0'; }); closeButton.addEventListener('click', () => { this.hide(); }); return closeButton; } /** * Handle color selection */ private handleColorSelection(colorOption: ColorOption): void { console.log('🎨 Color selected:', colorOption); // Call callback if provided if (this.onColorSelect) { this.onColorSelect(colorOption.color); } // Show success toast this.toastManager.success(`${colorOption.name} 적용`); // Hide picker this.hide(); } /** * Setup outside click handler */ private setupOutsideClickHandler(container: HTMLElement): void { const handleOutsideClick = (e: MouseEvent) => { if (!container.contains(e.target as Node)) { this.hide(); document.removeEventListener('click', handleOutsideClick); } }; // Add handler with slight delay to prevent immediate closure setTimeout(() => { document.addEventListener('click', handleOutsideClick); }, 100); // Prevent clicks inside container from bubbling container.addEventListener('click', (e) => { e.stopPropagation(); }); } /** * Add ripple animation CSS */ private addRippleAnimation(): void { if (!document.querySelector('#holy-color-picker-ripple-styles')) { const style = document.createElement('style'); style.id = 'holy-color-picker-ripple-styles'; style.textContent = ` @keyframes ripple { to { transform: scale(2); opacity: 0; } } `; document.head.appendChild(style); } } /** * Check if picker is currently visible */ public isOpen(): boolean { return this.isVisible; } /** * Get color name by value */ public getColorName(colorValue: string): string | undefined { const colorOption = this.colors.find(c => c.color.toLowerCase() === colorValue.toLowerCase()); return colorOption?.name; } /** * Show color picker with custom position */ public showAt(x: number, y: number, onColorSelect?: (color: string) => void): void { this.show(onColorSelect); // Adjust position after creation setTimeout(() => { const picker = document.querySelector('.holy-color-picker') as HTMLElement; if (picker) { picker.style.position = 'fixed'; picker.style.top = `${y}px`; picker.style.left = `${x}px`; picker.style.transform = 'none'; // Ensure picker stays within viewport this.adjustPositionToViewport(picker); } }, 0); } /** * Adjust picker position to stay within viewport */ private adjustPositionToViewport(picker: HTMLElement): void { const rect = picker.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // Adjust horizontal position if (rect.right > viewportWidth) { picker.style.left = `${viewportWidth - rect.width - 10}px`; } if (rect.left < 0) { picker.style.left = '10px'; } // Adjust vertical position if (rect.bottom > viewportHeight) { picker.style.top = `${viewportHeight - rect.height - 10}px`; } if (rect.top < 0) { picker.style.top = '10px'; } } }