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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user