refactor(ui): replace headless ui menu with custom dropdown in BlockActionsMenu

- remove Headless UI Menu and Fragment imports, unused TextIcon import
- implement manual BlockActionsMenu component to avoid pointerdown-triggered open interfering with drag start
- open dropdown only on click after checking justDragged flag
- handle outside click and Escape key for closing
- migrate submenu hover logic from BlockMenuTransformItem into new component
This commit is contained in:
2026-04-25 20:42:12 -04:00
parent 52d22e4171
commit 9d8133c7f5
+132 -109
View File
@@ -1,8 +1,7 @@
'use client';
import React, { Fragment, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon, TextIcon } from '@zen/core/shared/icons';
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
import { getBlockDef, listBlocks } from './blockRegistry.js';
import { inlineLength } from './inline/types.js';
import { inlineToDom, domToInline } from './inline/serialize.js';
@@ -16,59 +15,119 @@ import {
isCaretAtStart,
} from './utils/caret.js';
// Petit helper pour propager l'état `open` d'un Menu Headless UI vers le
// state local du parent. Permet de garder la poignée visible tant que le
// menu est ouvert (sinon `onMouseLeave` masque le wrapper en opacity-0).
function MenuOpenSync({ open, onChange }) {
useEffect(() => { onChange(open); }, [open, onChange]);
return null;
}
// Dropdown manuel pour le menu d'actions du bloc. Headless UI Menu ouvrait
// le panneau dès le pointerdown, ce qui empêchait de démarrer un drag avec
// un clic-maintenu. Ici on n'ouvre que sur le `click` (= mouseup sans drag),
// après consultation du drapeau `getJustDragged()` exposé par le parent.
function BlockActionsMenu({
open,
setOpen,
disabled,
transformOptions,
onMouseDownHandle,
getJustDragged,
onSelectBlock,
onTransform,
onDuplicate,
onDelete,
}) {
const containerRef = useRef(null);
const [submenuOpen, setSubmenuOpen] = useState(false);
const submenuTimerRef = useRef(null);
// Item « Transformer » : ouvre un sous-panneau au survol. On n'utilise pas
// `MenuItem` (qui fermerait le menu parent au clic) : sélectionner une
// option ferme manuellement le menu via `close()` exposé par <Menu>.
function BlockMenuTransformItem({ options, onSelect }) {
const [open, setOpen] = useState(false);
const closeTimerRef = useRef(null);
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;
}
}
function scheduleClose() {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
closeTimerRef.current = setTimeout(() => setOpen(false), 120);
}
function cancelClose() {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}
useEffect(() => () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current);
}, []);
// Fermeture sur clic extérieur ou Escape.
useEffect(() => {
if (!open) return;
function handleDocMouseDown(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
setSubmenuOpen(false);
}
}
function handleKeyDown(e) {
if (e.key === 'Escape') {
setOpen(false);
setSubmenuOpen(false);
}
}
document.addEventListener('mousedown', handleDocMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleDocMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open, setOpen]);
function handleButtonClick() {
if (getJustDragged()) return; // un drag vient de se terminer → pas de menu
onSelectBlock?.();
setOpen(!open);
}
function selectAndClose(fn) {
return () => {
fn?.();
setOpen(false);
setSubmenuOpen(false);
};
}
return (
<div ref={containerRef} className="relative">
<button
type="button"
tabIndex={-1}
title="Glisser pour réordonner ou cliquer pour les actions"
onMouseDown={onMouseDownHandle}
onClick={handleButtonClick}
disabled={disabled}
className="w-5 h-5 flex items-center justify-center rounded text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700/60 hover:text-neutral-900 dark:hover:text-white text-xs cursor-grab active:cursor-grabbing leading-none outline-none"
>
<DragDropVerticalIcon width={14} height={14} />
</button>
{open && (
<div className="absolute left-0 mt-1 w-56 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50">
<div className="p-1.5 flex flex-col gap-0.5">
{transformOptions.length > 0 && (
<div
className="relative"
onMouseEnter={() => { cancelClose(); setOpen(true); }}
onMouseLeave={scheduleClose}
onMouseEnter={() => { cancelSubmenuClose(); setSubmenuOpen(true); }}
onMouseLeave={scheduleSubmenuClose}
>
<div
role="button"
className={`cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 transition-colors duration-[120ms] ease-out ${open ? 'bg-neutral-100 dark:bg-white/5 text-neutral-900 dark:text-white' : ''}`}
className={`cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 transition-colors duration-[120ms] ease-out ${submenuOpen ? 'bg-neutral-100 dark:bg-white/5 text-neutral-900 dark:text-white' : ''}`}
>
<span className="flex-1">Transformer</span>
<ArrowRight01Icon className="w-3.5 h-3.5 shrink-0" />
</div>
{open && (
{submenuOpen && (
<div
className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg p-1.5 flex flex-col gap-0.5 z-50"
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onMouseEnter={cancelSubmenuClose}
onMouseLeave={scheduleSubmenuClose}
>
{options.map((d) => (
{transformOptions.map((d) => (
<button
key={d.type}
type="button"
onClick={() => onSelect?.(d.type)}
onClick={selectAndClose(() => onTransform?.(d.type))}
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] 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 text-left"
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
@@ -80,6 +139,31 @@ function BlockMenuTransformItem({ options, onSelect }) {
</div>
)}
</div>
)}
<button
type="button"
onClick={selectAndClose(onDuplicate)}
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] 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 text-left"
>
<Copy01Icon className="w-4 h-4 shrink-0" />
Dupliquer
</button>
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
<button
type="button"
onClick={selectAndClose(onDelete)}
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-red-700 dark:text-red-600 hover:bg-red-700/10 dark:hover:bg-red-700/20 transition-colors duration-150 text-left"
>
<Delete02Icon className="w-4 h-4 shrink-0" />
Supprimer
</button>
</div>
</div>
)}
</div>
);
}
@@ -419,83 +503,22 @@ const Block = forwardRef(function Block(
>
<Add01Icon width={14} height={14} />
</button>
<Menu as="div" className="relative">
{({ open, close }) => (
<>
<MenuOpenSync open={open} onChange={setMenuOpen} />
<MenuButton
type="button"
tabIndex={-1}
title="Glisser pour réordonner ou cliquer pour les actions"
onMouseDown={handleHandleMouseDown}
onClick={(e) => {
if (justDraggedRef.current) {
justDraggedRef.current = false;
e.preventDefault();
e.stopPropagation();
return;
}
onSelectBlock?.(block.id);
}}
<BlockActionsMenu
open={menuOpen}
setOpen={setMenuOpen}
disabled={disabled}
className="w-5 h-5 flex items-center justify-center rounded text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700/60 hover:text-neutral-900 dark:hover:text-white text-xs cursor-grab active:cursor-grabbing leading-none outline-none"
>
<DragDropVerticalIcon width={14} height={14} />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-150"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<MenuItems
static={false}
className="absolute left-0 mt-1 w-56 outline-none rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50"
>
<div className="p-1.5 flex flex-col gap-0.5">
{transformOptions.length > 0 && (
<BlockMenuTransformItem
options={transformOptions}
onSelect={(type) => {
onTransformBlock?.(block.id, type);
close();
transformOptions={transformOptions}
onMouseDownHandle={handleHandleMouseDown}
getJustDragged={() => {
const v = justDraggedRef.current;
justDraggedRef.current = false;
return v;
}}
onSelectBlock={() => onSelectBlock?.(block.id)}
onTransform={(type) => onTransformBlock?.(block.id, type)}
onDuplicate={() => onDuplicateBlock?.(block.id)}
onDelete={() => onDeleteBlock?.(block.id)}
/>
)}
<MenuItem>
<button
type="button"
onClick={() => onDuplicateBlock?.(block.id)}
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 transition-colors duration-[120ms] ease-out data-focus:bg-neutral-100 dark:data-focus:bg-white/5 data-focus:text-neutral-900 dark:data-focus:text-white"
>
<Copy01Icon className="w-4 h-4 shrink-0" />
Dupliquer
</button>
</MenuItem>
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
<MenuItem>
<button
type="button"
onClick={() => onDeleteBlock?.(block.id)}
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-red-700 dark:text-red-600 transition-colors duration-150 text-left data-focus:bg-red-700/10 dark:data-focus:bg-red-700/20"
>
<Delete02Icon className="w-4 h-4 shrink-0" />
Supprimer
</button>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)}
</Menu>
</div>
<div className="flex-1 min-w-0">