refactor(BlockEditor): add BlockInsertMenu component and unify block type icon styling

- introduce `BlockInsertMenu` dropdown to insert a new block after the current one
- extract `TYPE_ICON_BOX_CLASS` constant shared between insert menu and transform menu
- align `BlockActionsMenu` transform list item padding/gap to match new insert menu style
- update README to document the new insert menu behaviour and enabled blocks filtering
This commit is contained in:
2026-04-25 20:46:45 -04:00
parent 56767cff0f
commit e928e5317c
3 changed files with 129 additions and 36 deletions
+103 -14
View File
@@ -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 (
<div ref={containerRef} className="relative">
<button
type="button"
tabIndex={-1}
title="Insérer un bloc"
onClick={handleButtonClick}
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-sm leading-none outline-none"
>
<Add01Icon width={14} height={14} />
</button>
{open && (
<div className="absolute left-0 mt-1 w-64 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 max-h-80 overflow-y-auto">
<div className="px-2 pt-1 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
Insérer un bloc
</div>
{insertOptions.map((d) => (
<button
key={d.type}
type="button"
onClick={selectAndClose(d.type)}
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"
>
<span className={TYPE_ICON_BOX_CLASS}>
{d.icon}
</span>
{d.label}
</button>
))}
</div>
</div>
)}
</div>
);
}
// 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"
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
<span className={TYPE_ICON_BOX_CLASS}>
{d.icon}
</span>
{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(
)}
<div
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)}
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered || menuOpen || insertOpen ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
aria-hidden={!(hovered || menuOpen || insertOpen)}
>
<button
type="button"
tabIndex={-1}
title="Insérer un bloc"
onClick={() => onPlusClick?.(block.id)}
<BlockInsertMenu
open={insertOpen}
setOpen={setInsertOpen}
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-sm leading-none"
>
<Add01Icon width={14} height={14} />
</button>
insertOptions={insertOptions}
onSelectBlock={() => onSelectBlock?.(block.id)}
onInsert={(type) => onInsertBlock?.(block.id, type)}
/>
<BlockActionsMenu
open={menuOpen}
setOpen={setMenuOpen}
@@ -508,16 +508,18 @@ export default function BlockEditor({
removeBlock(blockId, true);
}
function handlePlusClick(blockId) {
function handleInsertBlock(blockId, type) {
const idx = blocks.findIndex(b => 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}
/>
))}
+16 -14
View File
@@ -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