feat(BlockEditor): add image alignment, link, and replace/delete controls

- add align (left/center/right/full), href, newTab fields to image block
- render floating toolbar on image hover with alignment buttons and link popover
- add replace and delete actions to image toolbar
- wrap image in <a> in disabled mode and HTML export when href is set
- update htmlToBlocks/blocksToHtml to serialize/parse align, href, newTab
- guard handleContainerMouseDown to prevent multi-block selection on input/textarea focus
- add alignment and link icons to shared icons index
- update README with image block spec and toolbar behaviour
This commit is contained in:
2026-04-26 16:26:41 -04:00
parent 83490de15d
commit d66b107636
5 changed files with 425 additions and 63 deletions
@@ -947,6 +947,22 @@ export default function BlockEditor({
function handleContainerMouseDown(e) {
if (e.button !== 0) return;
const target = e.target;
// Tout contrôle de formulaire (input/textarea/select) à l'intérieur d'un
// bloc non-texte doit recevoir le focus normalement et ne PAS déclencher
// la sélection multi-blocs. Sans ce garde, le clic sur la légende ou le
// texte alternatif d'une image sélectionne le bloc image entier ; le
// listener clavier global remplace alors chaque frappe par un nouveau
// paragraphe (cf. effet ligne ~931).
const formControl = target instanceof Element
? target.closest('input, textarea, select, [contenteditable="true"]')
: null;
if (formControl && !(target instanceof Element && target.closest('[data-inline-toolbar], [data-link-popover]'))) {
// Les inputs des popovers d'inline (lien) sont gérés par leur propre
// logique — on ne change pas leur comportement ici. Pour les autres
// inputs, on annule juste la sélection bloc en cours.
if (selectedBlockIds.size > 0) clearBlockSelection();
return;
}
const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
const onHandle = target instanceof Element ? target.closest('button') : null;
+31 -3
View File
@@ -30,7 +30,7 @@ Chaque bloc a un `id` (UUID) et un `type`. Selon le type :
| `quote` | `content` | citation |
| `code` | `content` | bloc de code (monospace) |
| `divider` | — | séparateur horizontal |
| `image` | `src`, `alt`, `caption` | image (URL uniquement) |
| `image` | `src`, `alt`, `caption`, `align`, `href`, `newTab` | image (URL uniquement) — `align``left\|center\|right\|full`, `href` optionnel |
`content` est un **tableau `InlineNode[]`** depuis Phase 2 — voir ci-dessous.
@@ -285,9 +285,37 @@ page) sont traversés pour atteindre les éléments block-level
descendants. Les styles CSS inline (`font-weight`, `font-style`,
`text-decoration`) sont également lus en plus des tags sémantiques.
## Bloc image
Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante**
(coin haut-droit) reprenant le style `BOX_CLASS` partagé :
- **Alignement** : 4 boutons (gauche / centre / droite / pleine largeur).
Persisté dans `block.align`. Le wrapper applique `justify-content` selon
la valeur ; `'full'` passe l'image à `width: 100%` (l'image étire la
largeur du conteneur de bloc, pas du viewport).
- **Lien** : ouvre un mini-popover (input URL + case « nouvel onglet »).
Persisté dans `block.href` et `block.newTab`. Côté éditeur l'image n'est
jamais wrappée dans `<a>` pour ne pas piéger les clics ; en mode `disabled`
et à l'export HTML (`blocksToHtml`), l'image est wrappée dans `<a>` avec
`target="_blank" rel="noopener noreferrer"` quand `newTab` est vrai.
- **Remplacer** : remet le formulaire URL avec l'URL courante préremplie.
Conserve `alt`, `caption`, `align`, `href`. Échap pour annuler.
- **Supprimer** : vide tous les champs ; le formulaire URL réapparaît.
Les inputs *Légende* et *Texte alternatif* sous l'image sont des `<input>`
HTML standards. Le clic dans ces inputs ne déclenche pas la sélection
multi-blocs (cf. garde dans `handleContainerMouseDown` : tout `input`,
`textarea`, `select` ou `[contenteditable="true"]` reçoit son focus normal
et n'entre pas en mode sélection).
Sérialisation HTML : `<figure data-align="…"><a href><img></a><figcaption></figcaption></figure>`.
Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `<a>`
quand l'`<img>` y est imbriqué pour récupérer `href` / `target`.
## Limitations connues
- Pas d'imbrication de listes.
- Image : URL uniquement, pas d'upload de fichier (Phase 2). La caption est
une string plate (pas de formatage inline pour l'instant).
- Image : URL uniquement, pas d'upload de fichier. La caption est une
string plate (pas de formatage inline pour l'instant).
- Tables : Phase 3.
@@ -1,35 +1,256 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { Image01Icon } from '@zen/core/shared/icons';
import {
AlignCenterIcon,
AlignFullWidthIcon,
AlignLeftIcon,
AlignRightIcon,
Delete02Icon,
Image01Icon,
Link02Icon,
PencilEdit01Icon,
} from '@zen/core/shared/icons';
import {
BOX_CLASS,
ICON_BTN_ACTIVE_CLASS,
ICON_BTN_CLASS,
SEPARATOR_VERTICAL_CLASS,
} from '../inline/menuStyles.js';
import { newBlockId } from '../utils/ids.js';
// Bloc image. Phase 2 : URL uniquement (pas d'upload). État vide = formulaire
// d'insertion d'URL. État rempli = image rendue + caption optionnelle.
// Bloc image. État vide = formulaire d'insertion d'URL. État rempli = image
// rendue + toolbar flottante (alignement, lien, remplacer, supprimer) +
// inputs caption / alt en dessous. Champs persistés : `src`, `alt`,
// `caption`, `align` (`'left' | 'center' | 'right' | 'full'`), `href`.
function ImageBlock({ block, onChange, disabled }) {
const [url, setUrl] = useState(block.src ?? '');
const ALIGN_OPTIONS = [
{ value: 'left', label: 'Aligner à gauche', Icon: AlignLeftIcon },
{ value: 'center', label: 'Centrer', Icon: AlignCenterIcon },
{ value: 'right', label: 'Aligner à droite', Icon: AlignRightIcon },
{ value: 'full', label: 'Pleine largeur', Icon: AlignFullWidthIcon },
];
const ALIGN_TO_JUSTIFY = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
full: 'stretch',
};
function getAlign(block) {
return ALIGN_TO_JUSTIFY[block.align] ? block.align : 'center';
}
function ImageUrlForm({ initialUrl, onSubmit, onCancel, disabled }) {
const [url, setUrl] = useState(initialUrl ?? '');
const inputRef = useRef(null);
useEffect(() => {
if (!block.src && !disabled) {
// Au montage initial du bloc vide, on focus l'input automatiquement.
inputRef.current?.focus();
}
}, [block.src, disabled]);
if (!disabled) inputRef.current?.focus();
}, [disabled]);
function submit() {
if (!url.trim()) return;
onChange?.({ src: url.trim() });
const trimmed = url.trim();
if (!trimmed) return;
onSubmit?.(trimmed);
}
function handleKeyDown(e) {
if (e.key === 'Enter') {
e.preventDefault();
submit();
} else if (e.key === 'Escape' && onCancel) {
e.preventDefault();
onCancel();
}
}
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/40 px-3 py-3">
<Image01Icon width={18} height={18} className="text-neutral-500" />
<input
ref={inputRef}
type="url"
placeholder="URL de l'image (https://…)"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
className="flex-1 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500"
/>
<button
type="button"
onClick={submit}
disabled={disabled || !url.trim()}
className="px-3 py-1 text-sm rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:hover:bg-blue-600 text-white"
>
Insérer
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-2 py-1 text-sm rounded text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700/60"
>
Annuler
</button>
)}
</div>
);
}
function LinkSubmenu({ initialHref, initialNewTab, onApply, onClose }) {
const [href, setHref] = useState(initialHref ?? '');
const [newTab, setNewTab] = useState(!!initialNewTab);
const ref = useRef(null);
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
function onDoc(e) {
if (ref.current && !ref.current.contains(e.target)) onClose?.();
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); onClose?.(); }
}
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
function submit() {
onApply?.(href.trim(), newTab);
onClose?.();
}
function remove() {
onApply?.('', false);
onClose?.();
}
return (
<div
ref={ref}
className={`absolute top-full mt-1 right-0 z-50 flex flex-col gap-1.5 p-2 ${BOX_CLASS}`}
>
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="url"
placeholder="https://..."
value={href}
onChange={(e) => setHref(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
className="w-56 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500"
/>
<button
type="button"
onClick={submit}
className="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white"
>
OK
</button>
{initialHref && (
<button
type="button"
onClick={remove}
title="Retirer le lien"
className="px-2 py-1 text-xs rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
>
</button>
)}
</div>
<label className="flex items-center gap-2 text-xs text-neutral-700 dark:text-neutral-300 select-none cursor-pointer">
<input
type="checkbox"
checked={newTab}
onChange={(e) => setNewTab(e.target.checked)}
className="cursor-pointer"
/>
Ouvrir dans un nouvel onglet
</label>
</div>
);
}
function ImageToolbar({ block, onPatch, onReplace, onRemove }) {
const [linkOpen, setLinkOpen] = useState(false);
const align = getAlign(block);
return (
<div
className={`absolute top-2 right-2 z-10 flex items-center gap-0.5 px-1 py-1 ${BOX_CLASS} opacity-0 group-hover/image:opacity-100 focus-within:opacity-100 transition-opacity`}
>
{ALIGN_OPTIONS.map(({ value, label, Icon }) => (
<button
key={value}
type="button"
title={label}
onClick={() => onPatch({ align: value })}
className={`${ICON_BTN_CLASS} ${align === value ? ICON_BTN_ACTIVE_CLASS : ''}`}
>
<Icon width={16} height={16} />
</button>
))}
<div className={SEPARATOR_VERTICAL_CLASS} />
<div className="relative">
<button
type="button"
title={block.href ? `Modifier le lien (${block.href})` : 'Ajouter un lien'}
onClick={() => setLinkOpen(o => !o)}
className={`${ICON_BTN_CLASS} ${block.href ? ICON_BTN_ACTIVE_CLASS : ''}`}
>
<Link02Icon width={16} height={16} />
</button>
{linkOpen && (
<LinkSubmenu
initialHref={block.href ?? ''}
initialNewTab={block.newTab ?? true}
onApply={(href, newTab) => onPatch({ href, newTab: href ? !!newTab : false })}
onClose={() => setLinkOpen(false)}
/>
)}
</div>
<button
type="button"
title="Remplacer l'image"
onClick={onReplace}
className={ICON_BTN_CLASS}
>
<PencilEdit01Icon width={16} height={16} />
</button>
<button
type="button"
title="Supprimer l'image"
onClick={onRemove}
className={ICON_BTN_CLASS}
>
<Delete02Icon width={16} height={16} />
</button>
</div>
);
}
function ImageBlock({ block, onChange, disabled }) {
// `replacing` permet de revenir au formulaire URL sans perdre alt/caption/
// align/href. Tant qu'il est `true`, on rend `ImageUrlForm` même si
// `block.src` est non vide.
const [replacing, setReplacing] = useState(false);
const showForm = !block.src || replacing;
function handleSetSrc(src) {
onChange?.({ src });
setReplacing(false);
}
function handleAltChange(e) {
onChange?.({ alt: e.target.value });
}
@@ -38,64 +259,84 @@ function ImageBlock({ block, onChange, disabled }) {
onChange?.({ caption: e.target.value });
}
function reset() {
setUrl('');
onChange?.({ src: '', alt: '', caption: '' });
function handleRemove() {
// Vide tous les champs : la prochaine frappe ouvre à nouveau le formulaire.
onChange?.({ src: '', alt: '', caption: '', href: '', newTab: false, align: 'center' });
setReplacing(false);
}
if (!block.src) {
if (showForm) {
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/40 px-3 py-3">
<Image01Icon width={18} height={18} className="text-neutral-500" />
<input
ref={inputRef}
type="url"
placeholder="URL de l'image (https://…)"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
className="flex-1 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500"
/>
<button
type="button"
onClick={submit}
disabled={disabled || !url.trim()}
className="px-3 py-1 text-sm rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:hover:bg-blue-600 text-white"
>
Insérer
</button>
</div>
<ImageUrlForm
initialUrl={replacing ? (block.src ?? '') : ''}
onSubmit={handleSetSrc}
onCancel={replacing ? () => setReplacing(false) : null}
disabled={disabled}
/>
);
}
const align = getAlign(block);
const justify = ALIGN_TO_JUSTIFY[align];
const imgClass = align === 'full'
? 'rounded-lg w-full block'
: 'rounded-lg max-w-full block';
// Mode lecture seule : si href défini, on wrappe l'image dans un <a>.
// En édition on n'ajoute jamais le <a> — il piégerait les clics dans
// l'éditeur. Le href est appliqué via la sérialisation HTML / l'export.
const imgEl = (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={block.src}
alt={block.alt ?? ''}
className={imgClass}
draggable={false}
/>
);
const wrappedImg = disabled && block.href
? (
<a
href={block.href}
target={block.newTab ? '_blank' : undefined}
rel={block.newTab ? 'noopener noreferrer' : undefined}
className="block"
>
{imgEl}
</a>
)
: imgEl;
return (
<div className="group/image relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={block.src}
alt={block.alt ?? ''}
className="rounded-lg max-w-full block"
draggable={false}
/>
{!disabled && (
<button
type="button"
onClick={reset}
title="Retirer l'image"
className="absolute top-2 right-2 opacity-0 group-hover/image:opacity-100 transition-opacity w-7 h-7 flex items-center justify-center rounded-full bg-black/60 hover:bg-black/80 text-white text-sm"
>
</button>
)}
<div className="mt-1 flex flex-col gap-1">
<div className="flex flex-col gap-1">
<div
className="group/image relative flex"
style={{ justifyContent: justify }}
>
<div className={align === 'full' ? 'relative w-full' : 'relative'}>
{wrappedImg}
{!disabled && (
<ImageToolbar
block={block}
onPatch={(patch) => onChange?.(patch)}
onReplace={() => setReplacing(true)}
onRemove={handleRemove}
/>
)}
</div>
</div>
<div
className="flex flex-col gap-1"
style={{ alignItems: align === 'full' ? 'stretch' : justify === 'flex-start' ? 'flex-start' : justify === 'flex-end' ? 'flex-end' : 'center' }}
>
<input
type="text"
placeholder="Légende (optionnelle)"
value={block.caption ?? ''}
onChange={handleCaptionChange}
disabled={disabled}
className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
className="px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
style={{ minWidth: '12rem', maxWidth: '100%' }}
/>
{!disabled && (
<input
@@ -103,7 +344,8 @@ function ImageBlock({ block, onChange, disabled }) {
placeholder="Texte alternatif (accessibilité)"
value={block.alt ?? ''}
onChange={handleAltChange}
className="w-full px-1 py-0.5 text-xs text-neutral-500 dark:text-neutral-500 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
className="px-1 py-0.5 text-xs text-neutral-500 dark:text-neutral-500 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
style={{ minWidth: '12rem', maxWidth: '100%' }}
/>
)}
</div>
@@ -118,7 +360,17 @@ const Image = {
keywords: ['image', 'photo', 'picture', 'img'],
isText: false,
create(init = {}) {
return { id: newBlockId(), type: 'image', src: '', alt: '', caption: '', ...init };
return {
id: newBlockId(),
type: 'image',
src: '',
alt: '',
caption: '',
align: 'center',
href: '',
newTab: false,
...init,
};
},
Component: ImageBlock,
};
@@ -89,10 +89,31 @@ function blockToElement(block) {
}
if (block.type === 'image') {
const fig = document.createElement('figure');
const align = block.align || 'center';
fig.setAttribute('data-align', align);
if (align === 'left' || align === 'right' || align === 'center') {
// CSS inline minimal pour les destinations qui ignorent data-align.
fig.setAttribute('style',
align === 'center' ? 'text-align:center'
: align === 'left' ? 'text-align:left'
: 'text-align:right');
}
const img = document.createElement('img');
img.setAttribute('src', block.src || '');
if (block.alt) img.setAttribute('alt', block.alt);
fig.appendChild(img);
if (align === 'full') img.setAttribute('width', '100%');
let imgHost = img;
if (block.href) {
const a = document.createElement('a');
a.setAttribute('href', block.href);
if (block.newTab) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
}
a.appendChild(img);
imgHost = a;
}
fig.appendChild(imgHost);
if (block.caption) {
const cap = document.createElement('figcaption');
cap.textContent = block.caption;
@@ -270,12 +291,20 @@ function parseChildren(node, out) {
const img = child.querySelector('img');
if (img) {
const cap = child.querySelector('figcaption');
const linkEl = img.parentElement?.tagName === 'A' ? img.parentElement : null;
const dataAlign = child.getAttribute('data-align');
const align = ['left', 'center', 'right', 'full'].includes(dataAlign) ? dataAlign : 'center';
const href = linkEl?.getAttribute('href') || '';
const newTab = !!href && linkEl?.getAttribute('target') === '_blank';
out.push({
id: newBlockId(),
type: 'image',
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
caption: cap?.textContent?.trim() || '',
align,
href,
newTab,
});
}
continue;
@@ -289,6 +318,9 @@ function parseChildren(node, out) {
src: child.getAttribute('src') || '',
alt: child.getAttribute('alt') || '',
caption: '',
align: 'center',
href: '',
newTab: false,
});
continue;
}