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