@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
text/typescript
/**
* 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';
}
}
}