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:
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { Fragment, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
|
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
|
||||||
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon, TextIcon } from '@zen/core/shared/icons';
|
|
||||||
import { getBlockDef, listBlocks } from './blockRegistry.js';
|
import { getBlockDef, listBlocks } from './blockRegistry.js';
|
||||||
import { inlineLength } from './inline/types.js';
|
import { inlineLength } from './inline/types.js';
|
||||||
import { inlineToDom, domToInline } from './inline/serialize.js';
|
import { inlineToDom, domToInline } from './inline/serialize.js';
|
||||||
@@ -16,59 +15,119 @@ import {
|
|||||||
isCaretAtStart,
|
isCaretAtStart,
|
||||||
} from './utils/caret.js';
|
} from './utils/caret.js';
|
||||||
|
|
||||||
// Petit helper pour propager l'état `open` d'un Menu Headless UI vers le
|
// Dropdown manuel pour le menu d'actions du bloc. Headless UI Menu ouvrait
|
||||||
// state local du parent. Permet de garder la poignée visible tant que le
|
// le panneau dès le pointerdown, ce qui empêchait de démarrer un drag avec
|
||||||
// menu est ouvert (sinon `onMouseLeave` masque le wrapper en opacity-0).
|
// un clic-maintenu. Ici on n'ouvre que sur le `click` (= mouseup sans drag),
|
||||||
function MenuOpenSync({ open, onChange }) {
|
// après consultation du drapeau `getJustDragged()` exposé par le parent.
|
||||||
useEffect(() => { onChange(open); }, [open, onChange]);
|
function BlockActionsMenu({
|
||||||
return null;
|
open,
|
||||||
|
setOpen,
|
||||||
|
disabled,
|
||||||
|
transformOptions,
|
||||||
|
onMouseDownHandle,
|
||||||
|
getJustDragged,
|
||||||
|
onSelectBlock,
|
||||||
|
onTransform,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||||
|
const submenuTimerRef = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 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(() => () => {
|
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 (
|
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
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
onMouseEnter={() => { cancelClose(); setOpen(true); }}
|
onMouseEnter={() => { cancelSubmenuClose(); setSubmenuOpen(true); }}
|
||||||
onMouseLeave={scheduleClose}
|
onMouseLeave={scheduleSubmenuClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="button"
|
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>
|
<span className="flex-1">Transformer</span>
|
||||||
<ArrowRight01Icon className="w-3.5 h-3.5 shrink-0" />
|
<ArrowRight01Icon className="w-3.5 h-3.5 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{submenuOpen && (
|
||||||
<div
|
<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"
|
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}
|
onMouseEnter={cancelSubmenuClose}
|
||||||
onMouseLeave={scheduleClose}
|
onMouseLeave={scheduleSubmenuClose}
|
||||||
>
|
>
|
||||||
{options.map((d) => (
|
{transformOptions.map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d.type}
|
key={d.type}
|
||||||
type="button"
|
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"
|
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">
|
<span className="w-4 h-4 flex items-center justify-center shrink-0">
|
||||||
@@ -80,6 +139,31 @@ function BlockMenuTransformItem({ options, onSelect }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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} />
|
<Add01Icon width={14} height={14} />
|
||||||
</button>
|
</button>
|
||||||
<Menu as="div" className="relative">
|
<BlockActionsMenu
|
||||||
{({ open, close }) => (
|
open={menuOpen}
|
||||||
<>
|
setOpen={setMenuOpen}
|
||||||
<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);
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
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"
|
transformOptions={transformOptions}
|
||||||
>
|
onMouseDownHandle={handleHandleMouseDown}
|
||||||
<DragDropVerticalIcon width={14} height={14} />
|
getJustDragged={() => {
|
||||||
</MenuButton>
|
const v = justDraggedRef.current;
|
||||||
|
justDraggedRef.current = false;
|
||||||
<Transition
|
return v;
|
||||||
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();
|
|
||||||
}}
|
}}
|
||||||
|
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>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user