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 { side } = useDropdownPlacement(open, triggerRef);
const [submenuOpen, setSubmenuOpen] = useState(false);
const submenuTimerRef = useRef(null);
const submenuTriggerRef = useRef(null);
const submenuPanelRef = useRef(null);
const [submenuSide, setSubmenuSide] = useState('below');
@@ -181,21 +180,6 @@ function BlockActionsMenu({
}
}, [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.
useEffect(() => {
if (!open) return;
@@ -256,14 +240,11 @@ function BlockActionsMenu({
>
<div className="p-1.5 flex flex-col gap-0.5">
{transformOptions.length > 0 && (
<div
ref={submenuTriggerRef}
className="relative"
onMouseEnter={() => { cancelSubmenuClose(); setSubmenuOpen(true); }}
onMouseLeave={scheduleSubmenuClose}
>
<div ref={submenuTriggerRef} className="relative">
<div
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' : ''}`}
>
<RepeatIcon className="w-4 h-4 shrink-0" />
@@ -274,8 +255,6 @@ function BlockActionsMenu({
<div
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`}
onMouseEnter={cancelSubmenuClose}
onMouseLeave={scheduleSubmenuClose}
>
{transformOptions.map((d) => (
<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
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
fermeture de 120 ms — même idiome que le sous-menu *Transformer ▸* du menu
d'actions, mais en drop-down (vers le bas) plutôt qu'à droite. Une petite
flèche `▾` après le glyphe principal indique la présence du sous-menu.
(couleur, surlignage, lien) s'ouvrent **au clic** sur leur trigger et restent
ouverts jusqu'à un clic extérieur ou `Escape` — même idiome que le sous-menu
*Transformer ▸* du menu d'actions, mais en drop-down (vers le bas) plutôt qu'à
droite. Une petite flèche `▾` après le glyphe principal indique la présence du
sous-menu.
## 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
- **Transformer ▸** — sous-menu qui s'ouvre au survol, listant les types de
blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code)
- **Transformer ▸** — sous-menu qui s'ouvre au clic (et se ferme sur clic
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
conservant son contenu inline. L'item est masqué pour les blocs non-texte
(image, séparateur). Le filtrage respecte la prop `enabledBlocks`.
@@ -27,7 +27,6 @@ import {
const TOOLBAR_HEIGHT = 36;
const TOOLBAR_GAP = 8;
const VIEWPORT_MARGIN = 8;
const SUBMENU_CLOSE_DELAY = 120;
const SIMPLE_BUTTONS = [
{ 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 [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
const [openSubmenu, setOpenSubmenu] = useState(null);
const submenuTimerRef = useRef(null);
const [linkUrl, setLinkUrl] = useState('');
const [linkNewTab, setLinkNewTab] = useState(false);
@@ -50,6 +48,25 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
return () => { onPinChange?.(false); };
}, [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(() => {
if (!rect || typeof window === 'undefined') return;
const el = ref.current;
@@ -70,32 +87,19 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
setPos({ top, left, flipped: flipBelow });
}, [rect]);
function scheduleClose() {
if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current);
submenuTimerRef.current = setTimeout(() => setOpenSubmenu(null), SUBMENU_CLOSE_DELAY);
}
function cancelClose() {
if (submenuTimerRef.current) {
clearTimeout(submenuTimerRef.current);
submenuTimerRef.current = null;
}
}
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 toggleSubmenu(key) {
setOpenSubmenu(prev => {
if (prev === key) return null;
if (key === 'link') {
const link = activeMarks.find(m => m.type === 'link');
setLinkUrl(link?.href ?? '');
setLinkNewTab(link ? !!link.newTab : false);
}
return key;
});
}
function closeSubmenu() {
cancelClose();
setOpenSubmenu(null);
}
@@ -160,10 +164,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
active={isActive('color')}
open={openSubmenu === 'color'}
flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('color')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
onToggle={() => toggleSubmenu('color')}
icon={<TextColorIcon width={16} height={16} />}
>
<ColorGrid
@@ -179,10 +180,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
active={isActive('highlight')}
open={openSubmenu === 'highlight'}
flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('highlight')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
onToggle={() => toggleSubmenu('highlight')}
icon={<HighlighterIcon width={16} height={16} />}
>
<ColorGrid
@@ -198,10 +196,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
active={isActive('link')}
open={openSubmenu === 'link'}
flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('link')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
onToggle={() => toggleSubmenu('link')}
icon={<Link02Icon width={16} height={16} />}
>
<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
// fenêtre de tolérance (120 ms) entre `mouseleave` du trigger et
// `mouseenter` du panneau permet de traverser le gap visuel.
function SubmenuTrigger({ title, active, open, flipUp, icon, children, onMouseEnter, onMouseLeave, onPanelEnter, onPanelLeave }) {
// Wrapper click-to-open pour les boutons de la toolbar avec sous-menu. Le
// panneau reste ouvert tant qu'on n'a pas cliqué à l'extérieur ou appuyé
// sur Escape (logique gérée par le parent `InlineToolbar`).
function SubmenuTrigger({ title, active, open, flipUp, icon, children, onToggle }) {
const panelPosition = flipUp ? 'bottom-full mb-1' : 'top-full mt-1';
return (
<div
className="relative"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div className="relative">
<button
type="button"
title={title}
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 : ''}`}
>
{icon}
<ArrowDown01Icon width={10} height={10} className="opacity-60" />
</button>
{open && (
<div
className={`absolute ${panelPosition} left-0 ${BOX_CLASS} p-1.5 z-50`}
onMouseEnter={onPanelEnter}
onMouseLeave={onPanelLeave}
>
<div className={`absolute ${panelPosition} left-0 ${BOX_CLASS} p-1.5 z-50`}>
{children}
</div>
)}