diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2307d62..e0d7496 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,6 +10,7 @@ import { userPreferencesService } from '@/services/core/user-preferences'; import { KeyboardShortcuts } from '@/components/KeyboardShortcuts'; import { GlobalKeyboardShortcuts } from '@/components/GlobalKeyboardShortcuts'; import { ToastProvider } from '@/components/ui/Toast'; +import { EmojiPickerProvider } from '@/contexts/EmojiPickerContext'; import { AuthProvider } from '../components/AuthProvider'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; @@ -50,32 +51,36 @@ export default async function RootLayout({ > - - - - - + + + + - - - {children} - - - - - + + + + {children} + + + + + + diff --git a/src/contexts/EmojiPickerContext.tsx b/src/contexts/EmojiPickerContext.tsx new file mode 100644 index 0000000..f6f9e24 --- /dev/null +++ b/src/contexts/EmojiPickerContext.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { + createContext, + useContext, + useState, + useEffect, + ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; +import { EmojiPicker } from '@/components/ui/Emoji'; + +interface EmojiPickerContextType { + isOpen: boolean; + position: { top: number; left: number }; + targetInput: HTMLInputElement | HTMLTextAreaElement | null; + openEmojiPicker: (input: HTMLInputElement | HTMLTextAreaElement) => void; + closeEmojiPicker: () => void; + insertEmoji: (emoji: string) => void; +} + +const EmojiPickerContext = createContext( + undefined +); + +export function EmojiPickerProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const [targetInput, setTargetInput] = useState< + HTMLInputElement | HTMLTextAreaElement | null + >(null); + + const openEmojiPicker = (input: HTMLInputElement | HTMLTextAreaElement) => { + const rect = input.getBoundingClientRect(); + + let top = rect.top + 40; // Position fixe à 40px du haut de l'input + const left = rect.left; + + // Pour les textarea, positionner près du curseur réel + if (input.tagName === 'TEXTAREA') { + const textarea = input as HTMLTextAreaElement; + + // Calculer la position du curseur dans le textarea + const selectionStart = textarea.selectionStart || 0; + const textBeforeCursor = textarea.value.substring(0, selectionStart); + + // Compter les lignes avant le curseur + const linesBeforeCursor = textBeforeCursor.split('\n').length - 1; + const lineHeight = + parseInt(window.getComputedStyle(textarea).lineHeight) || 20; + + // Calculer la position du curseur + const cursorTop = + rect.top + linesBeforeCursor * lineHeight + lineHeight * 0.5; + + // Positionner le picker près du curseur + top = Math.min( + cursorTop + 4, + rect.bottom - 200 // Ne pas dépasser le bas du textarea + ); + + // S'assurer que le picker reste dans la viewport + top = Math.max(top, 100); // Au moins 100px du haut de la page + } + + setPosition({ top, left }); + setTargetInput(input); + setIsOpen(true); + }; + + const closeEmojiPicker = () => { + setIsOpen(false); + setTargetInput(null); + }; + + const insertEmoji = (emoji: string) => { + if (targetInput) { + const start = targetInput.selectionStart || 0; + const end = targetInput.selectionEnd || 0; + const value = targetInput.value; + + const newValue = value.slice(0, start) + emoji + value.slice(end); + targetInput.value = newValue; + + // Déclencher l'événement change pour React + const event = new Event('input', { bubbles: true }); + targetInput.dispatchEvent(event); + + // Repositionner le curseur après l'emoji + const newPosition = start + emoji.length; + targetInput.setSelectionRange(newPosition, newPosition); + + closeEmojiPicker(); + } + }; + + // Gérer les raccourcis clavier globaux + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+Cmd+Space (les deux touches ensemble) + if (e.ctrlKey && e.metaKey && e.code === 'Space') { + const activeElement = document.activeElement; + + // Vérifier si l'élément actif est un input ou textarea + if ( + activeElement && + (activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA') + ) { + e.preventDefault(); + openEmojiPicker( + activeElement as HTMLInputElement | HTMLTextAreaElement + ); + } + } + + // Échapper pour fermer + if (e.key === 'Escape' && isOpen) { + closeEmojiPicker(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); + + // Fermer quand on clique ailleurs + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Element; + if (!target.closest('[data-emoji-picker]')) { + closeEmojiPicker(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + return ( + + {children} + + {/* Emoji Picker Portal */} + {isOpen && + typeof window !== 'undefined' && + createPortal( +
+ +
, + document.body + )} +
+ ); +} + +export function useEmojiPicker() { + const context = useContext(EmojiPickerContext); + if (context === undefined) { + throw new Error( + 'useEmojiPicker must be used within an EmojiPickerProvider' + ); + } + return context; +} diff --git a/src/contexts/KeyboardShortcutsContext.tsx b/src/contexts/KeyboardShortcutsContext.tsx index 60500b8..5030d1e 100644 --- a/src/contexts/KeyboardShortcutsContext.tsx +++ b/src/contexts/KeyboardShortcutsContext.tsx @@ -48,6 +48,11 @@ const PAGE_SHORTCUTS: PageShortcuts = { description: 'Fermer les modales/annuler', category: 'Navigation', }, + { + keys: ['Ctrl', 'Cmd', 'Space'], + description: "Ouvrir le sélecteur d'emoji", + category: 'Édition', + }, ], // Dashboard