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';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
|
import React, { Fragment, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
|
||||||
import { Add01Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
|
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||||
import { getBlockDef } from './blockRegistry.js';
|
import { Add01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
|
||||||
|
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';
|
||||||
import { htmlToBlocks } from './inline/clipboard.js';
|
import { htmlToBlocks } from './inline/clipboard.js';
|
||||||
@@ -15,6 +16,14 @@ import {
|
|||||||
isCaretAtStart,
|
isCaretAtStart,
|
||||||
} from './utils/caret.js';
|
} 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 :
|
// Wrapper d'un bloc unique. Gère :
|
||||||
// - le contentEditable pour les blocs texte (sync uncontrolled ↔ value)
|
// - le contentEditable pour les blocs texte (sync uncontrolled ↔ value)
|
||||||
// - les handles à gauche (drag, +)
|
// - les handles à gauche (drag, +)
|
||||||
@@ -53,13 +62,23 @@ const Block = forwardRef(function Block(
|
|||||||
onDragLeave,
|
onDragLeave,
|
||||||
onDrop,
|
onDrop,
|
||||||
onPlusClick,
|
onPlusClick,
|
||||||
|
onTransformBlock,
|
||||||
|
onDuplicateBlock,
|
||||||
|
onDeleteBlock,
|
||||||
|
enabledBlocks,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const def = getBlockDef(block.type);
|
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 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);
|
||||||
// Référence vers le dernier contenu qu'on a écrit dans le DOM. Permet de
|
// 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
|
// détecter si un changement de `block.content` provient d'un évènement
|
||||||
// externe (undo, transform, toolbar) plutôt que de la frappe utilisateur.
|
// externe (undo, transform, toolbar) plutôt que de la frappe utilisateur.
|
||||||
@@ -324,8 +343,8 @@ const Block = forwardRef(function Block(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
|
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered || menuOpen ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
|
||||||
aria-hidden={!hovered}
|
aria-hidden={!(hovered || menuOpen)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -337,17 +356,85 @@ const Block = forwardRef(function Block(
|
|||||||
>
|
>
|
||||||
<Add01Icon width={14} height={14} />
|
<Add01Icon width={14} height={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Menu as="div" className="relative">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<MenuOpenSync open={open} onChange={setMenuOpen} />
|
||||||
|
<MenuButton
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
title="Glisser pour réordonner"
|
title="Glisser pour réordonner ou cliquer pour les actions"
|
||||||
onMouseDown={handleHandleMouseDown}
|
onMouseDown={handleHandleMouseDown}
|
||||||
onClick={() => onSelectBlock?.(block.id)}
|
onClick={() => onSelectBlock?.(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-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} />
|
<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>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -479,6 +479,35 @@ export default function BlockEditor({
|
|||||||
setSlashState(null);
|
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) {
|
function handlePlusClick(blockId) {
|
||||||
const idx = blocks.findIndex(b => b.id === blockId);
|
const idx = blocks.findIndex(b => b.id === blockId);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
@@ -1014,6 +1043,10 @@ export default function BlockEditor({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onPlusClick={handlePlusClick}
|
onPlusClick={handlePlusClick}
|
||||||
|
onTransformBlock={handleTransformBlock}
|
||||||
|
onDuplicateBlock={handleDuplicateBlock}
|
||||||
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
enabledBlocks={enabledBlocks}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,12 +152,30 @@ En mode sélection multi-blocs :
|
|||||||
|
|
||||||
Chaque bloc affiche 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 `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).
|
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.
|
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
|
## Étendre — enregistrer un bloc custom
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|||||||
Reference in New Issue
Block a user