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