refactor(BlockEditor): replace hover-based submenu open/close with click-toggle

- remove timer-based submenu close logic (scheduleSubmenuClose, cancelSubmenuClose, submenuTimerRef) from BlockActionsMenu
- replace onMouseEnter/onMouseLeave handlers with onClick toggle on submenu trigger
- remove SUBMENU_CLOSE_DELAY constant and hover handlers from inline Toolbar submenus
- update README to reflect click-to-open/close-on-outside-click behavior for all submenus
This commit is contained in:
2026-04-26 15:53:21 -04:00
parent d7e723770f
commit 543c4f5029
3 changed files with 50 additions and 81 deletions
@@ -159,7 +159,6 @@ function BlockActionsMenu({
const triggerRef = useRef(null); const triggerRef = useRef(null);
const { side } = useDropdownPlacement(open, triggerRef); const { side } = useDropdownPlacement(open, triggerRef);
const [submenuOpen, setSubmenuOpen] = useState(false); const [submenuOpen, setSubmenuOpen] = useState(false);
const submenuTimerRef = useRef(null);
const submenuTriggerRef = useRef(null); const submenuTriggerRef = useRef(null);
const submenuPanelRef = useRef(null); const submenuPanelRef = useRef(null);
const [submenuSide, setSubmenuSide] = useState('below'); const [submenuSide, setSubmenuSide] = useState('below');
@@ -181,21 +180,6 @@ function BlockActionsMenu({
} }
}, [submenuOpen, transformOptions]); }, [submenuOpen, transformOptions]);
function scheduleSubmenuClose() {
if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current);
submenuTimerRef.current = setTimeout(() => setSubmenuOpen(false), 120);
}
function cancelSubmenuClose() {
if (submenuTimerRef.current) {
clearTimeout(submenuTimerRef.current);
submenuTimerRef.current = null;
}
}
useEffect(() => () => {
if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current);
}, []);
// Fermeture sur clic extérieur ou Escape. // Fermeture sur clic extérieur ou Escape.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -256,14 +240,11 @@ function BlockActionsMenu({
> >
<div className="p-1.5 flex flex-col gap-0.5"> <div className="p-1.5 flex flex-col gap-0.5">
{transformOptions.length > 0 && ( {transformOptions.length > 0 && (
<div <div ref={submenuTriggerRef} className="relative">
ref={submenuTriggerRef}
className="relative"
onMouseEnter={() => { cancelSubmenuClose(); setSubmenuOpen(true); }}
onMouseLeave={scheduleSubmenuClose}
>
<div <div
role="button" role="button"
tabIndex={0}
onClick={() => setSubmenuOpen(prev => !prev)}
className={`${ITEM_CLASS} ${submenuOpen ? 'bg-neutral-100 dark:bg-white/5 text-neutral-900 dark:text-white' : ''}`} className={`${ITEM_CLASS} ${submenuOpen ? 'bg-neutral-100 dark:bg-white/5 text-neutral-900 dark:text-white' : ''}`}
> >
<RepeatIcon className="w-4 h-4 shrink-0" /> <RepeatIcon className="w-4 h-4 shrink-0" />
@@ -274,8 +255,6 @@ function BlockActionsMenu({
<div <div
ref={submenuPanelRef} ref={submenuPanelRef}
className={`absolute left-full ${submenuSide === 'above' ? 'bottom-0' : 'top-0'} ml-1 w-56 ${BOX_CLASS} p-1.5 flex flex-col gap-0.5 z-50`} className={`absolute left-full ${submenuSide === 'above' ? 'bottom-0' : 'top-0'} ml-1 w-56 ${BOX_CLASS} p-1.5 flex flex-col gap-0.5 z-50`}
onMouseEnter={cancelSubmenuClose}
onMouseLeave={scheduleSubmenuClose}
> >
{transformOptions.map((d) => ( {transformOptions.map((d) => (
<button <button
+8 -6
View File
@@ -135,10 +135,11 @@ L'état actif est calculé à partir des marks **communes à toute la plage**
La toolbar est rendue en `position: absolute` à l'intérieur du container La toolbar est rendue en `position: absolute` à l'intérieur du container
relatif de l'éditeur et **suit la sélection lors du scroll**. Les sous-menus relatif de l'éditeur et **suit la sélection lors du scroll**. Les sous-menus
(couleur, surlignage, lien) s'ouvrent **au survol** avec une fenêtre de (couleur, surlignage, lien) s'ouvrent **au clic** sur leur trigger et restent
fermeture de 120 ms — même idiome que le sous-menu *Transformer ▸* du menu ouverts jusqu'à un clic extérieur ou `Escape` — même idiome que le sous-menu
d'actions, mais en drop-down (vers le bas) plutôt qu'à droite. Une petite *Transformer ▸* du menu d'actions, mais en drop-down (vers le bas) plutôt qu'à
flèche `▾` après le glyphe principal indique la présence du sous-menu. droite. Une petite flèche `▾` après le glyphe principal indique la présence du
sous-menu.
## Popover de lien ## Popover de lien
@@ -178,8 +179,9 @@ Les icônes proviennent de [`src/shared/icons/index.js`](../../icons/index.js).
## Menu d'actions du bloc ## Menu d'actions du bloc
- **Transformer ▸** — sous-menu qui s'ouvre au survol, listant les types de - **Transformer ▸** — sous-menu qui s'ouvre au clic (et se ferme sur clic
blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code) extérieur ou `Escape`), listant les types de blocs texte disponibles
(paragraphe, titres 1 à 6, listes, citation, code)
avec une icône en boîte. Cliquer sur un type remplace le bloc courant en avec une icône en boîte. Cliquer sur un type remplace le bloc courant en
conservant son contenu inline. L'item est masqué pour les blocs non-texte conservant son contenu inline. L'item est masqué pour les blocs non-texte
(image, séparateur). Le filtrage respecte la prop `enabledBlocks`. (image, séparateur). Le filtrage respecte la prop `enabledBlocks`.
@@ -27,7 +27,6 @@ import {
const TOOLBAR_HEIGHT = 36; const TOOLBAR_HEIGHT = 36;
const TOOLBAR_GAP = 8; const TOOLBAR_GAP = 8;
const VIEWPORT_MARGIN = 8; const VIEWPORT_MARGIN = 8;
const SUBMENU_CLOSE_DELAY = 120;
const SIMPLE_BUTTONS = [ const SIMPLE_BUTTONS = [
{ type: 'bold', label: 'B', title: 'Gras (Ctrl+B)', className: 'font-bold' }, { type: 'bold', label: 'B', title: 'Gras (Ctrl+B)', className: 'font-bold' },
@@ -41,7 +40,6 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
const ref = useRef(null); const ref = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false }); const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
const [openSubmenu, setOpenSubmenu] = useState(null); const [openSubmenu, setOpenSubmenu] = useState(null);
const submenuTimerRef = useRef(null);
const [linkUrl, setLinkUrl] = useState(''); const [linkUrl, setLinkUrl] = useState('');
const [linkNewTab, setLinkNewTab] = useState(false); const [linkNewTab, setLinkNewTab] = useState(false);
@@ -50,6 +48,25 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
return () => { onPinChange?.(false); }; return () => { onPinChange?.(false); };
}, [openSubmenu, onPinChange]); }, [openSubmenu, onPinChange]);
// Sous-menus en mode clic : fermeture sur clic extérieur ou Escape.
useEffect(() => {
if (openSubmenu === null) return;
function handleDocMouseDown(e) {
if (ref.current && !ref.current.contains(e.target)) {
setOpenSubmenu(null);
}
}
function handleKeyDown(e) {
if (e.key === 'Escape') setOpenSubmenu(null);
}
document.addEventListener('mousedown', handleDocMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleDocMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [openSubmenu]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!rect || typeof window === 'undefined') return; if (!rect || typeof window === 'undefined') return;
const el = ref.current; const el = ref.current;
@@ -70,32 +87,19 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
setPos({ top, left, flipped: flipBelow }); setPos({ top, left, flipped: flipBelow });
}, [rect]); }, [rect]);
function scheduleClose() { function toggleSubmenu(key) {
if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current); setOpenSubmenu(prev => {
submenuTimerRef.current = setTimeout(() => setOpenSubmenu(null), SUBMENU_CLOSE_DELAY); if (prev === key) return null;
} if (key === 'link') {
function cancelClose() { const link = activeMarks.find(m => m.type === 'link');
if (submenuTimerRef.current) { setLinkUrl(link?.href ?? '');
clearTimeout(submenuTimerRef.current); setLinkNewTab(link ? !!link.newTab : false);
submenuTimerRef.current = null; }
} return key;
} });
useEffect(() => () => {
if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current);
}, []);
function openSubmenuFor(key) {
cancelClose();
if (openSubmenu !== key && key === 'link') {
const link = activeMarks.find(m => m.type === 'link');
setLinkUrl(link?.href ?? '');
setLinkNewTab(link ? !!link.newTab : false);
}
setOpenSubmenu(key);
} }
function closeSubmenu() { function closeSubmenu() {
cancelClose();
setOpenSubmenu(null); setOpenSubmenu(null);
} }
@@ -160,10 +164,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
active={isActive('color')} active={isActive('color')}
open={openSubmenu === 'color'} open={openSubmenu === 'color'}
flipUp={pos.flipped} flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('color')} onToggle={() => toggleSubmenu('color')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
icon={<TextColorIcon width={16} height={16} />} icon={<TextColorIcon width={16} height={16} />}
> >
<ColorGrid <ColorGrid
@@ -179,10 +180,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
active={isActive('highlight')} active={isActive('highlight')}
open={openSubmenu === 'highlight'} open={openSubmenu === 'highlight'}
flipUp={pos.flipped} flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('highlight')} onToggle={() => toggleSubmenu('highlight')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
icon={<HighlighterIcon width={16} height={16} />} icon={<HighlighterIcon width={16} height={16} />}
> >
<ColorGrid <ColorGrid
@@ -198,10 +196,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
active={isActive('link')} active={isActive('link')}
open={openSubmenu === 'link'} open={openSubmenu === 'link'}
flipUp={pos.flipped} flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('link')} onToggle={() => toggleSubmenu('link')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
icon={<Link02Icon width={16} height={16} />} icon={<Link02Icon width={16} height={16} />}
> >
<LinkForm <LinkForm
@@ -230,32 +225,25 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
); );
} }
// Wrapper hover-to-open pour les boutons de la toolbar avec sous-menu. La // Wrapper click-to-open pour les boutons de la toolbar avec sous-menu. Le
// fenêtre de tolérance (120 ms) entre `mouseleave` du trigger et // panneau reste ouvert tant qu'on n'a pas cliqué à l'extérieur ou appuyé
// `mouseenter` du panneau permet de traverser le gap visuel. // sur Escape (logique gérée par le parent `InlineToolbar`).
function SubmenuTrigger({ title, active, open, flipUp, icon, children, onMouseEnter, onMouseLeave, onPanelEnter, onPanelLeave }) { function SubmenuTrigger({ title, active, open, flipUp, icon, children, onToggle }) {
const panelPosition = flipUp ? 'bottom-full mb-1' : 'top-full mt-1'; const panelPosition = flipUp ? 'bottom-full mb-1' : 'top-full mt-1';
return ( return (
<div <div className="relative">
className="relative"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<button <button
type="button" type="button"
title={title} title={title}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={onToggle}
className={`h-7 px-1.5 flex items-center gap-0.5 rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-white/5 hover:text-neutral-900 dark:hover:text-white transition-colors duration-[120ms] ease-out ${active || open ? ICON_BTN_ACTIVE_CLASS : ''}`} className={`h-7 px-1.5 flex items-center gap-0.5 rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-white/5 hover:text-neutral-900 dark:hover:text-white transition-colors duration-[120ms] ease-out ${active || open ? ICON_BTN_ACTIVE_CLASS : ''}`}
> >
{icon} {icon}
<ArrowDown01Icon width={10} height={10} className="opacity-60" /> <ArrowDown01Icon width={10} height={10} className="opacity-60" />
</button> </button>
{open && ( {open && (
<div <div className={`absolute ${panelPosition} left-0 ${BOX_CLASS} p-1.5 z-50`}>
className={`absolute ${panelPosition} left-0 ${BOX_CLASS} p-1.5 z-50`}
onMouseEnter={onPanelEnter}
onMouseLeave={onPanelLeave}
>
{children} {children}
</div> </div>
)} )}