diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js
index 390f960..64ad330 100644
--- a/src/shared/components/BlockEditor/Block.client.js
+++ b/src/shared/components/BlockEditor/Block.client.js
@@ -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(
)}
-
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"
- >
-
-
+
+ {({ open }) => (
+ <>
+
+ 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"
+ >
+
+
+
+
+
+
+ {transformOptions.length > 0 && (
+ <>
+
+ Transformer en
+
+ {transformOptions.map((d) => (
+
+ 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"
+ >
+
+ {d.icon}
+
+ {d.label}
+
+
+ ))}
+
+ >
+ )}
+
+
+ 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"
+ >
+
+ Dupliquer
+
+
+
+
+
+
+ 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"
+ >
+
+ Supprimer
+
+
+
+
+
+ >
+ )}
+
diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js
index c722cf2..bea4292 100644
--- a/src/shared/components/BlockEditor/BlockEditor.client.js
+++ b/src/shared/components/BlockEditor/BlockEditor.client.js
@@ -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}
/>
))}
diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md
index 61a85ff..c9f6052 100644
--- a/src/shared/components/BlockEditor/README.md
+++ b/src/shared/components/BlockEditor/README.md
@@ -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