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;
}
+34
View File
@@ -643,3 +643,37 @@ export const RepeatIcon = (props) => (
<path d="M3.87389 16.8127C3.42502 17.1345 2.8003 17.0314 2.47852 16.5826C1.5491 15.2861 1.00175 13.7049 1.00175 12C1.00175 7.55605 4.69641 3.99995 9.19597 3.99995H15.3902V3.00003C15.3902 2.74864 15.4843 2.497 15.6735 2.30259C16.0586 1.90676 16.6917 1.89809 17.0876 2.28324L18.0904 3.25898C18.2813 3.4446 18.5316 3.68784 18.6783 3.87761C18.8072 4.04432 19.179 4.56577 18.9063 5.20645C18.6396 5.83295 18.0216 5.93917 17.8159 5.96747C17.5777 6.00024 17.2787 6.0001 17.0075 5.99997H17.0074L16.957 5.99995H9.19597C5.74902 5.99995 3.00175 8.71187 3.00175 12C3.00175 13.2695 3.40777 14.4461 4.10399 15.4173C4.42577 15.8662 4.32275 16.4909 3.87389 16.8127Z" fill="currentColor"></path>
</svg>
);
// Icônes d'alignement pour le bloc image. Le rectangle représente l'image,
// les barres représentent le texte/contexte autour.
export const AlignLeftIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
<rect x="3" y="5" width="10" height="8" rx="1.5" fill="currentColor" stroke="none"></rect>
<line x1="3" y1="17" x2="21" y2="17"></line>
<line x1="3" y1="20" x2="14" y2="20"></line>
</svg>
);
export const AlignCenterIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
<rect x="7" y="5" width="10" height="8" rx="1.5" fill="currentColor" stroke="none"></rect>
<line x1="3" y1="17" x2="21" y2="17"></line>
<line x1="6" y1="20" x2="18" y2="20"></line>
</svg>
);
export const AlignRightIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
<rect x="11" y="5" width="10" height="8" rx="1.5" fill="currentColor" stroke="none"></rect>
<line x1="3" y1="17" x2="21" y2="17"></line>
<line x1="10" y1="20" x2="21" y2="20"></line>
</svg>
);
export const AlignFullWidthIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
<rect x="3" y="5" width="18" height="8" rx="1.5" fill="currentColor" stroke="none"></rect>
<line x1="3" y1="17" x2="21" y2="17"></line>
<line x1="3" y1="20" x2="21" y2="20"></line>
</svg>
);