feat: integrate EmojiPickerProvider and add emoji selector shortcut

- Wrapped the layout with EmojiPickerProvider to enable emoji selection functionality.
- Added a new keyboard shortcut (Ctrl/Cmd + Space) for opening the emoji selector, enhancing user experience.
This commit is contained in:
Julien Froidefond
2025-10-09 22:05:20 +02:00
parent 0b17934ca1
commit ab4a7b3b3e
3 changed files with 218 additions and 25 deletions

View File

@@ -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({
>
<AuthProvider>
<ToastProvider>
<ThemeProvider
initialTheme={
initialPreferences?.viewPreferences.theme || 'light'
}
userPreferredTheme={
initialPreferences?.viewPreferences.theme === 'light'
? 'dark'
: initialPreferences?.viewPreferences.theme || 'light'
}
>
<KeyboardShortcutsProvider>
<KeyboardShortcuts />
<JiraConfigProvider
config={initialPreferences?.jiraConfig || { enabled: false }}
>
<UserPreferencesProvider
initialPreferences={initialPreferences}
<EmojiPickerProvider>
<ThemeProvider
initialTheme={
initialPreferences?.viewPreferences.theme || 'light'
}
userPreferredTheme={
initialPreferences?.viewPreferences.theme === 'light'
? 'dark'
: initialPreferences?.viewPreferences.theme || 'light'
}
>
<KeyboardShortcutsProvider>
<KeyboardShortcuts />
<JiraConfigProvider
config={
initialPreferences?.jiraConfig || { enabled: false }
}
>
<BackgroundProvider>
<GlobalKeyboardShortcuts />
{children}
</BackgroundProvider>
</UserPreferencesProvider>
</JiraConfigProvider>
</KeyboardShortcutsProvider>
</ThemeProvider>
<UserPreferencesProvider
initialPreferences={initialPreferences}
>
<BackgroundProvider>
<GlobalKeyboardShortcuts />
{children}
</BackgroundProvider>
</UserPreferencesProvider>
</JiraConfigProvider>
</KeyboardShortcutsProvider>
</ThemeProvider>
</EmojiPickerProvider>
</ToastProvider>
</AuthProvider>
</body>

View File

@@ -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<EmojiPickerContextType | undefined>(
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 (
<EmojiPickerContext.Provider
value={{
isOpen,
position,
targetInput,
openEmojiPicker,
closeEmojiPicker,
insertEmoji,
}}
>
{children}
{/* Emoji Picker Portal */}
{isOpen &&
typeof window !== 'undefined' &&
createPortal(
<div
data-emoji-picker
className="fixed z-[99999] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-2xl"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
<EmojiPicker onEmojiSelect={insertEmoji} />
</div>,
document.body
)}
</EmojiPickerContext.Provider>
);
}
export function useEmojiPicker() {
const context = useContext(EmojiPickerContext);
if (context === undefined) {
throw new Error(
'useEmojiPicker must be used within an EmojiPickerProvider'
);
}
return context;
}

View File

@@ -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