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 `<Menu>` render prop to allow manual close on transform select
- wire transform options from block registry into the new submenu item
This commit is contained in:
2026-04-25 20:35:51 -04:00
parent 8b3baa39f8
commit bde634d169
2 changed files with 93 additions and 31 deletions
@@ -2,7 +2,7 @@
import React, { Fragment, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react'; import React, { Fragment, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/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 { 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';
@@ -24,6 +24,65 @@ function MenuOpenSync({ open, onChange }) {
return null; 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 <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(() => () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
}, []);
return (
<div
className="relative"
onMouseEnter={() => { cancelClose(); setOpen(true); }}
onMouseLeave={scheduleClose}
>
<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' : ''}`}
>
<span className="flex-1">Transformer</span>
<ArrowRight01Icon className="w-3.5 h-3.5 shrink-0" />
</div>
{open && (
<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}
>
{options.map((d) => (
<button
key={d.type}
type="button"
onClick={() => onSelect?.(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">
{d.icon}
</span>
{d.label}
</button>
))}
</div>
)}
</div>
);
}
// Wrapper d'un bloc unique. Gère : // Wrapper d'un bloc unique. Gère :
// - le contentEditable pour les blocs texte (sync uncontrolled ↔ value) // - le contentEditable pour les blocs texte (sync uncontrolled ↔ value)
// - les handles à gauche (drag, +) // - les handles à gauche (drag, +)
@@ -79,6 +138,9 @@ const Block = forwardRef(function Block(
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [draggable, setDraggable] = useState(false); const [draggable, setDraggable] = useState(false);
const [menuOpen, setMenuOpen] = 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 // 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 // détecter si un changement de `block.content` provient d'un évènement
// externe (undo, transform, toolbar) plutôt que de la frappe utilisateur. // externe (undo, transform, toolbar) plutôt que de la frappe utilisateur.
@@ -280,6 +342,7 @@ const Block = forwardRef(function Block(
e.preventDefault(); e.preventDefault();
return; return;
} }
justDraggedRef.current = true;
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', block.id); e.dataTransfer.setData('text/plain', block.id);
onDragStart?.(block.id); onDragStart?.(block.id);
@@ -357,7 +420,7 @@ const Block = forwardRef(function Block(
<Add01Icon width={14} height={14} /> <Add01Icon width={14} height={14} />
</button> </button>
<Menu as="div" className="relative"> <Menu as="div" className="relative">
{({ open }) => ( {({ open, close }) => (
<> <>
<MenuOpenSync open={open} onChange={setMenuOpen} /> <MenuOpenSync open={open} onChange={setMenuOpen} />
<MenuButton <MenuButton
@@ -365,7 +428,15 @@ const Block = forwardRef(function Block(
tabIndex={-1} tabIndex={-1}
title="Glisser pour réordonner ou cliquer pour les actions" title="Glisser pour réordonner ou cliquer pour les actions"
onMouseDown={handleHandleMouseDown} onMouseDown={handleHandleMouseDown}
onClick={() => onSelectBlock?.(block.id)} 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" 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" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<MenuItems 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 overflow-hidden z-50"> <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"> <div className="p-1.5 flex flex-col gap-0.5">
{transformOptions.length > 0 && ( {transformOptions.length > 0 && (
<> <BlockMenuTransformItem
<div className="px-[7px] pt-1 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"> options={transformOptions}
Transformer en onSelect={(type) => {
</div> onTransformBlock?.(block.id, type);
{transformOptions.map((d) => ( close();
<MenuItem key={d.type}> }}
<button />
type="button"
onClick={() => onTransformBlock?.(block.id, 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 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"
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{d.icon}
</span>
{d.label}
</button>
</MenuItem>
))}
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
</>
)} )}
<MenuItem> <MenuItem>
+8 -7
View File
@@ -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 Un clic sur la poignée `DragDropVerticalIcon` ouvre un menu déroulant
(`@headlessui/react`) contenant : (`@headlessui/react`) contenant :
- **Transformer en** — section listant les types de blocs texte disponibles - **Transformer ** — sous-menu qui s'ouvre au survol, listant les types de
(paragraphe, titres 1 à 6, listes, citation, code). Cliquer sur un type blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code).
remplace le bloc courant en conservant son contenu inline. La section est Cliquer sur un type remplace le bloc courant en conservant son contenu
masquée pour les blocs non-texte (image, séparateur). Le filtrage respecte inline et ferme le menu. L'item est masqué pour les blocs non-texte (image,
la prop `enabledBlocks`. séparateur). Le filtrage respecte la prop `enabledBlocks`.
- **Dupliquer** — insère une copie du bloc juste en dessous (nouvel `id`). - **Dupliquer** — insère une copie du bloc juste en dessous (nouvel `id`).
- **Supprimer** — retire le bloc (équivalent à `Backspace` au début d'un bloc - **Supprimer** — retire le bloc (équivalent à `Backspace` au début d'un bloc
vide), avec focus replacé sur le bloc précédent. vide), avec focus replacé sur le bloc précédent.
Le drag (`mousedown` + déplacement) et le clic (ouverture du menu) cohabitent 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 sur le même bouton : si un `dragstart` réel se produit, un drapeau interne
navigateur n'émet pas de `click` et le menu reste fermé. (`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 ## Étendre — enregistrer un bloc custom