refactor(BlockEditor): replace drag handle button with headless ui menu
- add context menu on drag handle with transform, duplicate and delete actions - introduce `MenuOpenSync` helper to keep handle visible while menu is open - pass `onTransformBlock`, `onDuplicateBlock`, `onDeleteBlock` and `enabledBlocks` props to Block - compute `transformOptions` via `useMemo` filtering allowed text block types - update BlockEditor to wire new block action handlers down to each Block - update README to document new block action props and menu behavior
This commit is contained in:
@@ -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(
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
|
||||
aria-hidden={!hovered}
|
||||
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)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -337,17 +356,85 @@ const Block = forwardRef(function Block(
|
||||
>
|
||||
<Add01Icon width={14} height={14} />
|
||||
</button>
|
||||
<button
|
||||
<Menu as="div" className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<MenuOpenSync open={open} onChange={setMenuOpen} />
|
||||
<MenuButton
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
title="Glisser pour réordonner"
|
||||
title="Glisser pour réordonner ou cliquer pour les actions"
|
||||
onMouseDown={handleHandleMouseDown}
|
||||
onClick={() => 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"
|
||||
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"
|
||||
>
|
||||
<DragDropVerticalIcon width={14} height={14} />
|
||||
</MenuButton>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<MenuItems className="absolute left-0 mt-1 w-56 outline-none rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg overflow-hidden z-50">
|
||||
<div className="p-1.5 flex flex-col gap-0.5">
|
||||
{transformOptions.length > 0 && (
|
||||
<>
|
||||
<div className="px-[7px] pt-1 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
|
||||
Transformer en
|
||||
</div>
|
||||
{transformOptions.map((d) => (
|
||||
<MenuItem key={d.type}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTransformBlock?.(block.id, 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 transition-colors duration-[120ms] ease-out data-focus:bg-neutral-100 dark:data-focus:bg-white/5 data-focus:text-neutral-900 dark:data-focus:text-white"
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center shrink-0">
|
||||
{d.icon}
|
||||
</span>
|
||||
{d.label}
|
||||
</button>
|
||||
</MenuItem>
|
||||
))}
|
||||
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDuplicateBlock?.(block.id)}
|
||||
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 transition-colors duration-[120ms] ease-out data-focus:bg-neutral-100 dark:data-focus:bg-white/5 data-focus:text-neutral-900 dark:data-focus:text-white"
|
||||
>
|
||||
<Copy01Icon className="w-4 h-4 shrink-0" />
|
||||
Dupliquer
|
||||
</button>
|
||||
</MenuItem>
|
||||
|
||||
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
|
||||
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteBlock?.(block.id)}
|
||||
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-red-700 dark:text-red-600 transition-colors duration-150 text-left data-focus:bg-red-700/10 dark:data-focus:bg-red-700/20"
|
||||
>
|
||||
<Delete02Icon className="w-4 h-4 shrink-0" />
|
||||
Supprimer
|
||||
</button>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user