From 9d8133c7f5725966008f7dc7861860c1e87baeca Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 20:42:12 -0400 Subject: [PATCH] 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 --- .../components/BlockEditor/Block.client.js | 275 ++++++++++-------- 1 file changed, 149 insertions(+), 126 deletions(-) diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js index dc8a940..7188eda 100644 --- a/src/shared/components/BlockEditor/Block.client.js +++ b/src/shared/components/BlockEditor/Block.client.js @@ -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,67 +15,152 @@ 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 . -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 scheduleSubmenuClose() { + if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current); + submenuTimerRef.current = setTimeout(() => setSubmenuOpen(false), 120); } - function cancelClose() { - if (closeTimerRef.current) { - clearTimeout(closeTimerRef.current); - closeTimerRef.current = null; + function cancelSubmenuClose() { + if (submenuTimerRef.current) { + clearTimeout(submenuTimerRef.current); + submenuTimerRef.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 ( -
{ cancelClose(); setOpen(true); }} - onMouseLeave={scheduleClose} - > -
+
+ + + {open && ( -
- {options.map((d) => ( +
+
+ {transformOptions.length > 0 && ( +
{ cancelSubmenuClose(); setSubmenuOpen(true); }} + onMouseLeave={scheduleSubmenuClose} + > +
+ Transformer + +
+ {submenuOpen && ( +
+ {transformOptions.map((d) => ( + + ))} +
+ )} +
+ )} + - ))} + +
+ + +
)}
@@ -419,83 +503,22 @@ const Block = forwardRef(function Block( > - - {({ open, close }) => ( - <> - - { - if (justDraggedRef.current) { - justDraggedRef.current = false; - e.preventDefault(); - e.stopPropagation(); - return; - } - onSelectBlock?.(block.id); - }} - 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.length > 0 && ( - { - onTransformBlock?.(block.id, type); - close(); - }} - /> - )} - - - - - -
- - - - -
- - - - )} -
+ { + 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)} + />