diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js index 390f960..64ad330 100644 --- a/src/shared/components/BlockEditor/Block.client.js +++ b/src/shared/components/BlockEditor/Block.client.js @@ -1,8 +1,9 @@ 'use client'; -import React, { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react'; -import { Add01Icon, DragDropVerticalIcon } from '@zen/core/shared/icons'; -import { getBlockDef } from './blockRegistry.js'; +import React, { Fragment, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react'; +import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { Add01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons'; +import { getBlockDef, listBlocks } from './blockRegistry.js'; import { inlineLength } from './inline/types.js'; import { inlineToDom, domToInline } from './inline/serialize.js'; import { htmlToBlocks } from './inline/clipboard.js'; @@ -15,6 +16,14 @@ import { isCaretAtStart, } from './utils/caret.js'; +// Petit helper pour propager l'état `open` d'un Menu Headless UI vers le +// state local du parent. Permet de garder la poignée visible tant que le +// menu est ouvert (sinon `onMouseLeave` masque le wrapper en opacity-0). +function MenuOpenSync({ open, onChange }) { + useEffect(() => { onChange(open); }, [open, onChange]); + return null; +} + // Wrapper d'un bloc unique. Gère : // - le contentEditable pour les blocs texte (sync uncontrolled ↔ value) // - les handles à gauche (drag, +) @@ -53,13 +62,23 @@ const Block = forwardRef(function Block( onDragLeave, onDrop, onPlusClick, + onTransformBlock, + onDuplicateBlock, + onDeleteBlock, + enabledBlocks, }, ref, ) { const def = getBlockDef(block.type); + const transformOptions = useMemo(() => { + const all = listBlocks(); + 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 editableRef = useRef(null); const [hovered, setHovered] = useState(false); const [draggable, setDraggable] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); // Référence vers le dernier contenu qu'on a écrit dans le DOM. Permet de // détecter si un changement de `block.content` provient d'un évènement // externe (undo, transform, toolbar) plutôt que de la frappe utilisateur. @@ -324,8 +343,8 @@ const Block = forwardRef(function Block( )}
- + + {({ open }) => ( + <> + + onSelectBlock?.(block.id)} + 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-xs cursor-grab active:cursor-grabbing leading-none outline-none" + > + + + + + +
+ {transformOptions.length > 0 && ( + <> +
+ Transformer en +
+ {transformOptions.map((d) => ( + + + + ))} +
+ + )} + + + + + +
+ + + + +
+ + + + )} +
diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js index c722cf2..bea4292 100644 --- a/src/shared/components/BlockEditor/BlockEditor.client.js +++ b/src/shared/components/BlockEditor/BlockEditor.client.js @@ -479,6 +479,35 @@ export default function BlockEditor({ setSlashState(null); } + function handleTransformBlock(blockId, newType) { + const idx = blocks.findIndex(b => b.id === blockId); + if (idx < 0) return; + const current = blocks[idx]; + const def = getBlockDef(newType); + if (!def) return; + const created = def.create(def.isText ? { content: current.content ?? [] } : {}); + const next = blocks.map(b => (b.id === blockId ? created : b)); + commitChange(next, { immediate: true }); + if (def.isText) { + setFocusBlockId(created.id); + setFocusOffset(inlineLength(current.content ?? [])); + } + } + + function handleDuplicateBlock(blockId) { + const idx = blocks.findIndex(b => b.id === blockId); + if (idx < 0) return; + const copy = { ...blocks[idx], id: newBlockId() }; + const next = [...blocks.slice(0, idx + 1), copy, ...blocks.slice(idx + 1)]; + commitChange(next, { immediate: true }); + setFocusBlockId(copy.id); + setFocusOffset(inlineLength(copy.content ?? [])); + } + + function handleDeleteBlock(blockId) { + removeBlock(blockId, true); + } + function handlePlusClick(blockId) { const idx = blocks.findIndex(b => b.id === blockId); if (idx < 0) return; @@ -1014,6 +1043,10 @@ export default function BlockEditor({ onDragLeave={handleDragLeave} onDrop={handleDrop} onPlusClick={handlePlusClick} + onTransformBlock={handleTransformBlock} + onDuplicateBlock={handleDuplicateBlock} + onDeleteBlock={handleDeleteBlock} + enabledBlocks={enabledBlocks} /> ))}
diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md index 61a85ff..c9f6052 100644 --- a/src/shared/components/BlockEditor/README.md +++ b/src/shared/components/BlockEditor/README.md @@ -152,12 +152,30 @@ En mode sélection multi-blocs : Chaque bloc affiche au survol : - une poignée `Add01Icon` pour insérer un bloc en dessous (ouvre le slash menu) -- une poignée `DragDropVerticalIcon` pour glisser-déposer (réordonner) +- une poignée `DragDropVerticalIcon` à double rôle : presser-glisser pour réordonner, simple clic pour ouvrir le menu d'actions du bloc 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 en** — section 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. La section est + masquée 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. + +Le drag (`mousedown` + déplacement) et le clic (ouverture du menu) cohabitent +sur le même bouton : si le pointeur bouge entre `mousedown` et `mouseup`, le +navigateur n'émet pas de `click` et le menu reste fermé. + ## Étendre — enregistrer un bloc custom ```js