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 React, { useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons'; 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 { getBlockDef, listBlocks } from './blockRegistry.js';
import { inlineLength } from './inline/types.js'; import { inlineLength } from './inline/types.js';
import { inlineToDom, domToInline } from './inline/serialize.js'; import { inlineToDom, domToInline } from './inline/serialize.js';
@@ -15,6 +18,89 @@ import {
isCaretAtStart, isCaretAtStart,
} from './utils/caret.js'; } 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 // 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 // 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), // un clic-maintenu. Ici on n'ouvre que sur le `click` (= mouseup sans drag),
@@ -128,9 +214,9 @@ function BlockActionsMenu({
key={d.type} key={d.type}
type="button" type="button"
onClick={selectAndClose(() => onTransform?.(d.type))} 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} {d.icon}
</span> </span>
{d.label} {d.label}
@@ -204,10 +290,10 @@ const Block = forwardRef(function Block(
onDragOver, onDragOver,
onDragLeave, onDragLeave,
onDrop, onDrop,
onPlusClick,
onTransformBlock, onTransformBlock,
onDuplicateBlock, onDuplicateBlock,
onDeleteBlock, onDeleteBlock,
onInsertBlock,
enabledBlocks, enabledBlocks,
}, },
ref, ref,
@@ -218,10 +304,15 @@ const Block = forwardRef(function Block(
const allowed = enabledBlocks ? all.filter(b => enabledBlocks.includes(b.type)) : all; const allowed = enabledBlocks ? all.filter(b => enabledBlocks.includes(b.type)) : all;
return allowed.filter(d => d.isText && d.type !== block.type); return allowed.filter(d => d.isText && d.type !== block.type);
}, [enabledBlocks, 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 editableRef = useRef(null);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [draggable, setDraggable] = useState(false); const [draggable, setDraggable] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [insertOpen, setInsertOpen] = useState(false);
// Drapeau de drag réel : passe à true au premier dragstart, consommé par // 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. // le onClick du MenuButton pour ne pas ouvrir le menu après un drag.
const justDraggedRef = useRef(false); const justDraggedRef = useRef(false);
@@ -490,19 +581,17 @@ const Block = forwardRef(function Block(
)} )}
<div <div
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered || menuOpen ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`} 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)} aria-hidden={!(hovered || menuOpen || insertOpen)}
> >
<button <BlockInsertMenu
type="button" open={insertOpen}
tabIndex={-1} setOpen={setInsertOpen}
title="Insérer un bloc"
onClick={() => onPlusClick?.(block.id)}
disabled={disabled} 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" insertOptions={insertOptions}
> onSelectBlock={() => onSelectBlock?.(block.id)}
<Add01Icon width={14} height={14} /> onInsert={(type) => onInsertBlock?.(block.id, type)}
</button> />
<BlockActionsMenu <BlockActionsMenu
open={menuOpen} open={menuOpen}
setOpen={setMenuOpen} setOpen={setMenuOpen}
@@ -508,16 +508,18 @@ export default function BlockEditor({
removeBlock(blockId, true); removeBlock(blockId, true);
} }
function handlePlusClick(blockId) { function handleInsertBlock(blockId, type) {
const idx = blocks.findIndex(b => b.id === blockId); const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return; if (idx < 0) return;
const newBlock = makeBlock(DEFAULT_BLOCK_TYPE); const def = getBlockDef(type);
const next = [...blocks.slice(0, idx + 1), newBlock, ...blocks.slice(idx + 1)]; if (!def) return;
const inserted = def.create();
const next = [...blocks.slice(0, idx + 1), inserted, ...blocks.slice(idx + 1)];
commitChange(next, { immediate: true }); commitChange(next, { immediate: true });
setFocusBlockId(newBlock.id); if (def.isText) {
setFocusBlockId(inserted.id);
setFocusOffset(0); setFocusOffset(0);
// Ouvre le slash menu après la mise à jour }
setTimeout(() => openSlashFor(newBlock.id, ''), 0);
} }
// --- Drag & drop --- // --- Drag & drop ---
@@ -1042,10 +1044,10 @@ export default function BlockEditor({
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onPlusClick={handlePlusClick}
onTransformBlock={handleTransformBlock} onTransformBlock={handleTransformBlock}
onDuplicateBlock={handleDuplicateBlock} onDuplicateBlock={handleDuplicateBlock}
onDeleteBlock={handleDeleteBlock} onDeleteBlock={handleDeleteBlock}
onInsertBlock={handleInsertBlock}
enabledBlocks={enabledBlocks} 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 - 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 - clic dans l'éditeur → quitte la sélection
## Drag and drop ## Poignées au survol
Chaque bloc affiche au survol : Chaque bloc affiche au survol deux boutons à gauche, chacun ouvrant un
- une poignée `Add01Icon` pour insérer un bloc en dessous (ouvre le slash menu) dropdown manuel (state local + fermeture sur clic extérieur / `Escape`) :
- une poignée `DragDropVerticalIcon` à double rôle : presser-glisser pour réordonner, simple clic pour ouvrir le menu d'actions du bloc
- `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). 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 ## 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 - **Transformer ▸** — sous-menu qui s'ouvre au survol, listant les types de
blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code). blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code)
Cliquer sur un type remplace le bloc courant en conservant son contenu avec une icône en boîte. Cliquer sur un type remplace le bloc courant en
inline et ferme le menu. L'item est masqué pour les blocs non-texte (image, conservant son contenu inline. L'item est masqué pour les blocs non-texte
séparateur). Le filtrage respecte la prop `enabledBlocks`. (image, séparateur). Le filtrage respecte la prop `enabledBlocks`.
- **Dupliquer** — insère une copie du bloc juste en dessous (nouvel `id`). - **Dupliquer** — insère une copie du bloc juste en dessous (nouvel `id`).
- **Supprimer** — retire le bloc (équivalent à `Backspace` au début d'un bloc - **Supprimer** — retire le bloc (équivalent à `Backspace` au début d'un bloc
vide), avec focus replacé sur le bloc précédent. 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 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 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. (`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 ## Étendre — enregistrer un bloc custom