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