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 { 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 <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 :
// - 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(
<Add01Icon width={14} height={14} />
</button>
<Menu as="div" className="relative">
{({ open }) => (
{({ open, close }) => (
<>
<MenuOpenSync open={open} onChange={setMenuOpen} />
<MenuButton
@@ -365,7 +428,15 @@ const Block = forwardRef(function Block(
tabIndex={-1}
title="Glisser pour réordonner ou cliquer pour les actions"
onMouseDown={handleHandleMouseDown}
onClick={() => 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"
>
<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">
{transformOptions.length > 0 && (
<>
<div className="px-[7px] pt-1 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
Transformer en
</div>
{transformOptions.map((d) => (
<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" />
</>
<BlockMenuTransformItem
options={transformOptions}
onSelect={(type) => {
onTransformBlock?.(block.id, type);
close();
}}
/>
)}
<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
(`@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