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:
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user