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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user