diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js
index 7188eda..3481c88 100644
--- a/src/shared/components/BlockEditor/Block.client.js
+++ b/src/shared/components/BlockEditor/Block.client.js
@@ -2,6 +2,9 @@
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
+
+// Style « boîte » pour l'icône d'un type de bloc, repris du SlashMenu.
+const TYPE_ICON_BOX_CLASS = 'w-8 h-8 flex items-center justify-center rounded-md border border-neutral-200 dark:border-neutral-700 text-xs font-medium text-neutral-700 dark:text-neutral-300 flex-shrink-0';
import { getBlockDef, listBlocks } from './blockRegistry.js';
import { inlineLength } from './inline/types.js';
import { inlineToDom, domToInline } from './inline/serialize.js';
@@ -15,6 +18,89 @@ import {
isCaretAtStart,
} from './utils/caret.js';
+// Dropdown manuel pour le bouton « + » : liste tous les types de blocs
+// disponibles (filtrés par `enabledBlocks`) et insère le type choisi juste
+// après le bloc courant. Même mécanique de fermeture que BlockActionsMenu :
+// clic extérieur + Escape.
+function BlockInsertMenu({
+ open,
+ setOpen,
+ disabled,
+ insertOptions,
+ onSelectBlock,
+ onInsert,
+}) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ function handleDocMouseDown(e) {
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
+ setOpen(false);
+ }
+ }
+ function handleKeyDown(e) {
+ if (e.key === 'Escape') setOpen(false);
+ }
+ document.addEventListener('mousedown', handleDocMouseDown);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('mousedown', handleDocMouseDown);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [open, setOpen]);
+
+ function handleButtonClick() {
+ onSelectBlock?.();
+ setOpen(!open);
+ }
+
+ function selectAndClose(type) {
+ return () => {
+ onInsert?.(type);
+ setOpen(false);
+ };
+ }
+
+ return (
+
+
+
+ {open && (
+
+
+
+ Insérer un bloc
+
+ {insertOptions.map((d) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
// Dropdown manuel pour le menu d'actions du bloc. Headless UI Menu ouvrait
// le panneau dès le pointerdown, ce qui empêchait de démarrer un drag avec
// un clic-maintenu. Ici on n'ouvre que sur le `click` (= mouseup sans drag),
@@ -128,9 +214,9 @@ function BlockActionsMenu({
key={d.type}
type="button"
onClick={selectAndClose(() => onTransform?.(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"
+ className="cursor-pointer w-full flex items-center gap-3 px-2 py-1.5 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"
>
-
+
{d.icon}
{d.label}
@@ -204,10 +290,10 @@ const Block = forwardRef(function Block(
onDragOver,
onDragLeave,
onDrop,
- onPlusClick,
onTransformBlock,
onDuplicateBlock,
onDeleteBlock,
+ onInsertBlock,
enabledBlocks,
},
ref,
@@ -218,10 +304,15 @@ const Block = forwardRef(function Block(
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 insertOptions = useMemo(() => {
+ const all = listBlocks();
+ return enabledBlocks ? all.filter(b => enabledBlocks.includes(b.type)) : all;
+ }, [enabledBlocks]);
const editableRef = useRef(null);
const [hovered, setHovered] = useState(false);
const [draggable, setDraggable] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
+ const [insertOpen, setInsertOpen] = 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);
@@ -490,19 +581,17 @@ const Block = forwardRef(function Block(
)}
-
+ insertOptions={insertOptions}
+ onSelectBlock={() => onSelectBlock?.(block.id)}
+ onInsert={(type) => onInsertBlock?.(block.id, type)}
+ />
b.id === blockId);
if (idx < 0) return;
- const newBlock = makeBlock(DEFAULT_BLOCK_TYPE);
- const next = [...blocks.slice(0, idx + 1), newBlock, ...blocks.slice(idx + 1)];
+ const def = getBlockDef(type);
+ if (!def) return;
+ const inserted = def.create();
+ const next = [...blocks.slice(0, idx + 1), inserted, ...blocks.slice(idx + 1)];
commitChange(next, { immediate: true });
- setFocusBlockId(newBlock.id);
- setFocusOffset(0);
- // Ouvre le slash menu après la mise à jour
- setTimeout(() => openSlashFor(newBlock.id, ''), 0);
+ if (def.isText) {
+ setFocusBlockId(inserted.id);
+ setFocusOffset(0);
+ }
}
// --- Drag & drop ---
@@ -1042,10 +1044,10 @@ export default function BlockEditor({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
- onPlusClick={handlePlusClick}
onTransformBlock={handleTransformBlock}
onDuplicateBlock={handleDuplicateBlock}
onDeleteBlock={handleDeleteBlock}
+ onInsertBlock={handleInsertBlock}
enabledBlocks={enabledBlocks}
/>
))}
diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md
index 6bcd79a..95e8f09 100644
--- a/src/shared/components/BlockEditor/README.md
+++ b/src/shared/components/BlockEditor/README.md
@@ -148,26 +148,26 @@ En mode sélection multi-blocs :
- frappe d'un caractère imprimable → remplace les blocs sélectionnés par un nouveau paragraphe contenant ce caractère
- clic dans l'éditeur → quitte la sélection
-## Drag and drop
+## Poignées au survol
-Chaque bloc affiche au survol :
-- une poignée `Add01Icon` pour insérer un bloc en dessous (ouvre le slash menu)
-- une poignée `DragDropVerticalIcon` à double rôle : presser-glisser pour réordonner, simple clic pour ouvrir le menu d'actions du bloc
+Chaque bloc affiche au survol deux boutons à gauche, chacun ouvrant un
+dropdown manuel (state local + fermeture sur clic extérieur / `Escape`) :
+
+- `Add01Icon` (« + ») — ouvre le **menu d'insertion** : liste tous les types
+ disponibles (filtrés par `enabledBlocks`) avec une icône en boîte. Le clic
+ sur un type insère le bloc juste en dessous du bloc courant.
+- `DragDropVerticalIcon` — double rôle : presser-glisser pour réordonner
+ (drag-and-drop HTML5 natif), simple clic pour ouvrir le **menu d'actions**.
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 ▸** — 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`.
+ blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code)
+ avec une icône en boîte. Cliquer sur un type remplace le bloc courant en
+ conservant son contenu inline. 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.
@@ -175,7 +175,9 @@ Un clic sur la poignée `DragDropVerticalIcon` ouvre un menu déroulant
Le drag (`mousedown` + déplacement) et le clic (ouverture du menu) cohabitent
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.
+Sinon (clic sans déplacement), le menu s'ouvre normalement. Les dropdowns
+sont des composants maison (pas de Headless UI ici) car `MenuButton` ouvrait
+sur `pointerdown`, ce qui empêchait le clic-maintenu nécessaire au drag.
## Étendre — enregistrer un bloc custom