From bde634d16901066100e816612515408f20d2e5cb Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 20:35:51 -0400 Subject: [PATCH] feat(ui): add block transform submenu with hover panel and drag fix - add `BlockMenuTransformItem` component with hover-triggered submenu panel - import `ArrowRight01Icon` and `TextIcon` icons for transform UI - track drag state via `justDraggedRef` to prevent menu opening after drag - expose `close` from `` render prop to allow manual close on transform select - wire transform options from block registry into the new submenu item --- .../components/BlockEditor/Block.client.js | 109 ++++++++++++++---- src/shared/components/BlockEditor/README.md | 15 +-- 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js index 64ad330..dc8a940 100644 --- a/src/shared/components/BlockEditor/Block.client.js +++ b/src/shared/components/BlockEditor/Block.client.js @@ -2,7 +2,7 @@ import React, { Fragment, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react'; import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; -import { Add01Icon, 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 { inlineLength } from './inline/types.js'; import { inlineToDom, domToInline } from './inline/serialize.js'; @@ -24,6 +24,65 @@ function MenuOpenSync({ open, onChange }) { return 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 cancelClose() { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + } + useEffect(() => () => { + if (closeTimerRef.current) clearTimeout(closeTimerRef.current); + }, []); + + return ( +
{ cancelClose(); setOpen(true); }} + onMouseLeave={scheduleClose} + > +
+ Transformer + +
+ {open && ( +
+ {options.map((d) => ( + + ))} +
+ )} +
+ ); +} + // Wrapper d'un bloc unique. Gère : // - le contentEditable pour les blocs texte (sync uncontrolled ↔ value) // - les handles à gauche (drag, +) @@ -79,6 +138,9 @@ const Block = forwardRef(function Block( const [hovered, setHovered] = useState(false); const [draggable, setDraggable] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + // Drapeau de drag réel : passe à true au premier dragstart, consommé par + // le onClick du MenuButton pour ne pas ouvrir le menu après un drag. + const justDraggedRef = useRef(false); // Référence vers le dernier contenu qu'on a écrit dans le DOM. Permet de // détecter si un changement de `block.content` provient d'un évènement // externe (undo, transform, toolbar) plutôt que de la frappe utilisateur. @@ -280,6 +342,7 @@ const Block = forwardRef(function Block( e.preventDefault(); return; } + justDraggedRef.current = true; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', block.id); onDragStart?.(block.id); @@ -357,7 +420,7 @@ const Block = forwardRef(function Block( - {({ open }) => ( + {({ open, close }) => ( <> onSelectBlock?.(block.id)} + onClick={(e) => { + 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" > @@ -381,29 +452,19 @@ const Block = forwardRef(function Block( leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - +
{transformOptions.length > 0 && ( - <> -
- Transformer en -
- {transformOptions.map((d) => ( - - - - ))} -
- + { + onTransformBlock?.(block.id, type); + close(); + }} + /> )} diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md index c9f6052..6bcd79a 100644 --- a/src/shared/components/BlockEditor/README.md +++ b/src/shared/components/BlockEditor/README.md @@ -163,18 +163,19 @@ Le drag-and-drop utilise l'API HTML5 native, pas de dépendance externe. Un clic sur la poignée `DragDropVerticalIcon` ouvre un menu déroulant (`@headlessui/react`) contenant : -- **Transformer en** — section listant les types de blocs texte disponibles - (paragraphe, titres 1 à 6, listes, citation, code). Cliquer sur un type - remplace le bloc courant en conservant son contenu inline. La section est - masquée pour les blocs non-texte (image, séparateur). Le filtrage respecte - la prop `enabledBlocks`. +- **Transformer ▸** — sous-menu qui s'ouvre au survol, listant les types de + blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code). + Cliquer sur un type remplace le bloc courant en conservant son contenu + inline et ferme le menu. L'item est masqué pour les blocs non-texte (image, + séparateur). Le filtrage respecte la prop `enabledBlocks`. - **Dupliquer** — insère une copie du bloc juste en dessous (nouvel `id`). - **Supprimer** — retire le bloc (équivalent à `Backspace` au début d'un bloc vide), avec focus replacé sur le bloc précédent. Le drag (`mousedown` + déplacement) et le clic (ouverture du menu) cohabitent -sur le même bouton : si le pointeur bouge entre `mousedown` et `mouseup`, le -navigateur n'émet pas de `click` et le menu reste fermé. +sur le même bouton : si un `dragstart` réel se produit, un drapeau interne +(`justDraggedRef`) supprime l'ouverture du menu lors du `click` qui suit. +Sinon (clic sans déplacement), le menu s'ouvre normalement. ## Étendre — enregistrer un bloc custom