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