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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user