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 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) {
|
||||||
setFocusOffset(0);
|
setFocusBlockId(inserted.id);
|
||||||
// Ouvre le slash menu après la mise à jour
|
setFocusOffset(0);
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user