From d66b1076365f660f61692aa792fd5ca70a4d99f5 Mon Sep 17 00:00:00 2001 From: Hyko Date: Sun, 26 Apr 2026 16:26:41 -0400 Subject: [PATCH] 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 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 --- .../BlockEditor/BlockEditor.client.js | 16 + src/shared/components/BlockEditor/README.md | 34 +- .../BlockEditor/blockTypes/Image.client.js | 370 +++++++++++++++--- .../BlockEditor/inline/clipboard.js | 34 +- src/shared/icons/index.js | 34 ++ 5 files changed, 425 insertions(+), 63 deletions(-) diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js index f74f1e6..5555f69 100644 --- a/src/shared/components/BlockEditor/BlockEditor.client.js +++ b/src/shared/components/BlockEditor/BlockEditor.client.js @@ -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; diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md index 6476b49..990329f 100644 --- a/src/shared/components/BlockEditor/README.md +++ b/src/shared/components/BlockEditor/README.md @@ -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 `` pour ne pas piéger les clics ; en mode `disabled` + et à l'export HTML (`blocksToHtml`), l'image est wrappée dans `` 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 `` +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 : `
`. +Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `` +quand l'`` 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. diff --git a/src/shared/components/BlockEditor/blockTypes/Image.client.js b/src/shared/components/BlockEditor/blockTypes/Image.client.js index 0227446..c6bfeff 100644 --- a/src/shared/components/BlockEditor/blockTypes/Image.client.js +++ b/src/shared/components/BlockEditor/blockTypes/Image.client.js @@ -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 ( +
+ + 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" + /> + + {onCancel && ( + + )} +
+ ); +} + +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 ( +
+
+ 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" + /> + + {initialHref && ( + + )} +
+ +
+ ); +} + +function ImageToolbar({ block, onPatch, onReplace, onRemove }) { + const [linkOpen, setLinkOpen] = useState(false); + const align = getAlign(block); + + return ( +
+ {ALIGN_OPTIONS.map(({ value, label, Icon }) => ( + + ))} +
+
+ + {linkOpen && ( + onPatch({ href, newTab: href ? !!newTab : false })} + onClose={() => setLinkOpen(false)} + /> + )} +
+ + +
+ ); +} + +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 ( -
- - 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" - /> - -
+ 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
. + // En édition on n'ajoute jamais le — 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 */ + {block.alt + ); + const wrappedImg = disabled && block.href + ? ( + + {imgEl} + + ) + : imgEl; + return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {block.alt - {!disabled && ( - - )} -
+
+
+
+ {wrappedImg} + {!disabled && ( + onChange?.(patch)} + onReplace={() => setReplacing(true)} + onRemove={handleRemove} + /> + )} +
+
+
{!disabled && ( )}
@@ -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, }; diff --git a/src/shared/components/BlockEditor/inline/clipboard.js b/src/shared/components/BlockEditor/inline/clipboard.js index bc4161a..0c826a6 100644 --- a/src/shared/components/BlockEditor/inline/clipboard.js +++ b/src/shared/components/BlockEditor/inline/clipboard.js @@ -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; } diff --git a/src/shared/icons/index.js b/src/shared/icons/index.js index 135391d..860dab6 100644 --- a/src/shared/icons/index.js +++ b/src/shared/icons/index.js @@ -642,4 +642,38 @@ export const RepeatIcon = (props) => ( +); + +// 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) => ( + + + + + +); + +export const AlignCenterIcon = (props) => ( + + + + + +); + +export const AlignRightIcon = (props) => ( + + + + + +); + +export const AlignFullWidthIcon = (props) => ( + + + + + ); \ No newline at end of file