refactor(BlockEditor): replace drag handle button with headless ui menu

- add context menu on drag handle with transform, duplicate and delete actions
- introduce `MenuOpenSync` helper to keep handle visible while menu is open
- pass `onTransformBlock`, `onDuplicateBlock`, `onDeleteBlock` and `enabledBlocks` props to Block
- compute `transformOptions` via `useMemo` filtering allowed text block types
- update BlockEditor to wire new block action handlers down to each Block
- update README to document new block action props and menu behavior
This commit is contained in:
2026-04-25 20:29:18 -04:00
parent 515b95c8d3
commit 53ace7fc1f
3 changed files with 155 additions and 17 deletions
@@ -1,8 +1,9 @@
'use client';
import React, { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
import { Add01Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
import { getBlockDef } from './blockRegistry.js';
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 { getBlockDef, listBlocks } from './blockRegistry.js';
import { inlineLength } from './inline/types.js';
import { inlineToDom, domToInline } from './inline/serialize.js';
import { htmlToBlocks } from './inline/clipboard.js';
@@ -15,6 +16,14 @@ 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;
}
// Wrapper d'un bloc unique. Gère :
// - le contentEditable pour les blocs texte (sync uncontrolled ↔ value)
// - les handles à gauche (drag, +)
@@ -53,13 +62,23 @@ const Block = forwardRef(function Block(
onDragLeave,
onDrop,
onPlusClick,
onTransformBlock,
onDuplicateBlock,
onDeleteBlock,
enabledBlocks,
},
ref,
) {
const def = getBlockDef(block.type);
const transformOptions = useMemo(() => {
const all = listBlocks();
const allowed = enabledBlocks ? all.filter(b => enabledBlocks.includes(b.type)) : all;
return allowed.filter(d => d.isText && d.type !== block.type);
}, [enabledBlocks, block.type]);
const editableRef = useRef(null);
const [hovered, setHovered] = useState(false);
const [draggable, setDraggable] = useState(false);
const [menuOpen, setMenuOpen] = useState(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.
@@ -324,8 +343,8 @@ const Block = forwardRef(function Block(
)}
<div
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
aria-hidden={!hovered}
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered || menuOpen ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
aria-hidden={!(hovered || menuOpen)}
>
<button
type="button"
@@ -337,17 +356,85 @@ const Block = forwardRef(function Block(
>
<Add01Icon width={14} height={14} />
</button>
<button
<Menu as="div" className="relative">
{({ open }) => (
<>
<MenuOpenSync open={open} onChange={setMenuOpen} />
<MenuButton
type="button"
tabIndex={-1}
title="Glisser pour réordonner"
title="Glisser pour réordonner ou cliquer pour les actions"
onMouseDown={handleHandleMouseDown}
onClick={() => 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"
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"
>
<DragDropVerticalIcon width={14} height={14} />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-150"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-100"
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">
<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" />
</>
)}
<MenuItem>
<button
type="button"
onClick={() => onDuplicateBlock?.(block.id)}
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"
>
<Copy01Icon className="w-4 h-4 shrink-0" />
Dupliquer
</button>
</MenuItem>
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
<MenuItem>
<button
type="button"
onClick={() => onDeleteBlock?.(block.id)}
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-red-700 dark:text-red-600 transition-colors duration-150 text-left data-focus:bg-red-700/10 dark:data-focus:bg-red-700/20"
>
<Delete02Icon className="w-4 h-4 shrink-0" />
Supprimer
</button>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)}
</Menu>
</div>
<div className="flex-1 min-w-0">
@@ -479,6 +479,35 @@ export default function BlockEditor({
setSlashState(null);
}
function handleTransformBlock(blockId, newType) {
const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return;
const current = blocks[idx];
const def = getBlockDef(newType);
if (!def) return;
const created = def.create(def.isText ? { content: current.content ?? [] } : {});
const next = blocks.map(b => (b.id === blockId ? created : b));
commitChange(next, { immediate: true });
if (def.isText) {
setFocusBlockId(created.id);
setFocusOffset(inlineLength(current.content ?? []));
}
}
function handleDuplicateBlock(blockId) {
const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return;
const copy = { ...blocks[idx], id: newBlockId() };
const next = [...blocks.slice(0, idx + 1), copy, ...blocks.slice(idx + 1)];
commitChange(next, { immediate: true });
setFocusBlockId(copy.id);
setFocusOffset(inlineLength(copy.content ?? []));
}
function handleDeleteBlock(blockId) {
removeBlock(blockId, true);
}
function handlePlusClick(blockId) {
const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return;
@@ -1014,6 +1043,10 @@ export default function BlockEditor({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onPlusClick={handlePlusClick}
onTransformBlock={handleTransformBlock}
onDuplicateBlock={handleDuplicateBlock}
onDeleteBlock={handleDeleteBlock}
enabledBlocks={enabledBlocks}
/>
))}
</div>
+19 -1
View File
@@ -152,12 +152,30 @@ En mode sélection multi-blocs :
Chaque bloc affiche au survol :
- une poignée `Add01Icon` pour insérer un bloc en dessous (ouvre le slash menu)
- une poignée `DragDropVerticalIcon` pour glisser-déposer (réordonner)
- une poignée `DragDropVerticalIcon` à double rôle : presser-glisser pour réordonner, simple clic pour ouvrir le menu d'actions du bloc
Les icônes proviennent de [`src/shared/icons/index.js`](../../icons/index.js).
Le drag-and-drop utilise l'API HTML5 native, pas de dépendance externe.
## Menu d'actions du bloc
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`.
- **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é.
## Étendre — enregistrer un bloc custom
```js