feat(ui): add MediaPicker integration to BlockEditor image block

- import MediaPicker from features/media to avoid circular dependency with @zen/core
- add "Choisir un média" button in ImageUrlForm alongside existing URL input
- insert image block with `/zen/api/media/file/<slug>` src on media selection
- update README to document dual-source form (external URL + media library) and revise known limitations
This commit is contained in:
2026-04-26 17:40:58 -04:00
parent 9723f40df2
commit 56c334684f
2 changed files with 63 additions and 28 deletions
+12 -2
View File
@@ -287,6 +287,15 @@ descendants. Les styles CSS inline (`font-weight`, `font-style`,
## Bloc image ## Bloc image
Le formulaire d'insertion accepte deux sources :
- **URL externe** : champ libre + bouton « Insérer ».
- **Bibliothèque media** : bouton « Choisir un média » qui ouvre le `MediaPicker`
de `@zen/core/features/media`. À la sélection, le bloc est inséré avec
`block.src = "/zen/api/media/file/<slug>"`. Les nouveaux uploads passent par
ce picker en visibilité `public` (les blocs image vivent dans du contenu
publié — un média privé serait illisible côté frontal).
Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante** Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante**
(coin haut-droit) reprenant le style `BOX_CLASS` partagé : (coin haut-droit) reprenant le style `BOX_CLASS` partagé :
@@ -316,6 +325,7 @@ quand l'`<img>` y est imbriqué pour récupérer `href` / `target`.
## Limitations connues ## Limitations connues
- Pas d'imbrication de listes. - Pas d'imbrication de listes.
- Image : URL uniquement, pas d'upload de fichier. La caption est une - Image : insertion par URL externe ou via le `MediaPicker` du module
string plate (pas de formatage inline pour l'instant). `media` (les uploads y sont gérés). La caption est une string plate
(pas de formatage inline pour l'instant).
- Tables : Phase 3. - Tables : Phase 3.
@@ -18,6 +18,9 @@ import {
SEPARATOR_VERTICAL_CLASS, SEPARATOR_VERTICAL_CLASS,
} from '../inline/menuStyles.js'; } from '../inline/menuStyles.js';
import { newBlockId } from '../utils/ids.js'; import { newBlockId } from '../utils/ids.js';
// Import relatif pour éviter une dépendance circulaire avec @zen/core (le bloc
// image vit dans `shared` et tire MediaPicker depuis `features/media`).
import MediaPicker from '../../../../features/media/components/MediaPicker.client.js';
// Bloc image. État vide = formulaire d'insertion d'URL. État rempli = image // Bloc image. État vide = formulaire d'insertion d'URL. État rempli = image
// rendue + toolbar flottante (alignement, lien, remplacer, supprimer) + // rendue + toolbar flottante (alignement, lien, remplacer, supprimer) +
@@ -44,6 +47,7 @@ function getAlign(block) {
function ImageUrlForm({ initialUrl, onSubmit, onCancel, disabled }) { function ImageUrlForm({ initialUrl, onSubmit, onCancel, disabled }) {
const [url, setUrl] = useState(initialUrl ?? ''); const [url, setUrl] = useState(initialUrl ?? '');
const [pickerOpen, setPickerOpen] = useState(false);
const inputRef = useRef(null); const inputRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -67,36 +71,57 @@ function ImageUrlForm({ initialUrl, onSubmit, onCancel, disabled }) {
} }
return ( 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" /> <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">
<input <Image01Icon width={18} height={18} className="text-neutral-500" />
ref={inputRef} <input
type="url" ref={inputRef}
placeholder="URL de l'image (https://…)" type="url"
value={url} placeholder="URL de l'image (https://…)"
onChange={(e) => setUrl(e.target.value)} value={url}
onKeyDown={handleKeyDown} onChange={(e) => setUrl(e.target.value)}
disabled={disabled} onKeyDown={handleKeyDown}
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" 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 <button
type="button" type="button"
onClick={onCancel} onClick={submit}
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" 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"
> >
Annuler Insérer
</button> </button>
)} <button
</div> type="button"
onClick={() => setPickerOpen(true)}
disabled={disabled}
className="px-3 py-1 text-sm rounded border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700/60 disabled:opacity-50"
>
Choisir un média
</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>
<MediaPicker
isOpen={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={(media) => {
if (media?.slug) onSubmit?.(`/zen/api/media/file/${media.slug}`);
}}
accept="image/*"
visibility="any"
uploadVisibility="public"
title="Insérer une image depuis la bibliothèque"
/>
</>
); );
} }