docs(BlockEditor): document mediaSlug media library link and add server helpers
- update image block schema in README table to include `mediaSlug?` and clarify fields - add "Liaison avec la médiathèque" section documenting mediaSlug behavior, read-only alt/caption, and internal `_` fields - document new server helpers (`normalizeImageBlocks`, `enrichBlocksWithMedia`, `syncBlockImageReferences`) with usage examples - add `block_image` field convention to media feature README with cross-references - implement `mediaLink.server.js` with the three server-side helpers - store `mediaSlug` on image block at insertion time in `Image.client.js` - persist `mediaSlug` through clipboard paste/duplicate in `clipboard.js` - export `mediaLink` entry point in `package.json` exports map
This commit is contained in:
@@ -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`, `align`, `href`, `newTab` | image (URL uniquement) — `align` ∈ `left\|center\|right\|full`, `href` optionnel |
|
||||
| `image` | `src`, `mediaSlug?`, `alt?`, `caption?`, `align`, `href`, `newTab` | image — voir [Bloc image](#bloc-image) ; `align` ∈ `left\|center\|right\|full` |
|
||||
|
||||
`content` est un **tableau `InlineNode[]`** depuis Phase 2 — voir ci-dessous.
|
||||
|
||||
@@ -291,10 +291,75 @@ 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).
|
||||
de `@zen/core/features/media`. À la sélection, le bloc reçoit
|
||||
`block.src = "/zen/api/media/file/<slug>"` **et** `block.mediaSlug = "<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).
|
||||
|
||||
### Liaison avec la médiathèque (`mediaSlug`)
|
||||
|
||||
Quand un bloc image est lié à un média (`mediaSlug` présent), le **média est
|
||||
la source unique de vérité** pour `alt` et `caption`. Le bloc ne stocke ni l'un
|
||||
ni l'autre — l'éditeur affiche les valeurs du média en lecture seule avec un
|
||||
lien « Modifier dans la médiathèque ». Toute mise à jour de l'alt ou de la
|
||||
légende côté médiathèque est immédiatement répercutée sur tous les blocs qui
|
||||
référencent ce média, sans toucher aux contenus.
|
||||
|
||||
Pour les **URL externes** (pas de `mediaSlug`), les inputs alt/caption locaux
|
||||
restent disponibles sur le bloc — il n'y a pas d'autre source possible.
|
||||
|
||||
Champs internes au composant (préfixe `_`, jamais persistés) :
|
||||
|
||||
- `_mediaAlt` / `_mediaCaption` : snapshot capté à l'insertion via le
|
||||
`MediaPicker`, pour affichage immédiat dans le bandeau lecture-seule sans
|
||||
appel réseau.
|
||||
- `_resolvedAlt` / `_resolvedCaption` : injectés au rendu serveur par
|
||||
`enrichBlocksWithMedia` (cf. ci-dessous).
|
||||
|
||||
### Helpers serveur — `mediaLink.server.js`
|
||||
|
||||
Trois helpers à appeler depuis les actions/routes serveur du module qui
|
||||
héberge le contenu BlockEditor :
|
||||
|
||||
```js
|
||||
import {
|
||||
normalizeImageBlocks,
|
||||
enrichBlocksWithMedia,
|
||||
syncBlockImageReferences,
|
||||
} from '@zen/core/shared/components/BlockEditor/mediaLink';
|
||||
|
||||
// Au save :
|
||||
const normalized = await normalizeImageBlocks(blocks);
|
||||
await persistDocument(documentId, normalized);
|
||||
await syncBlockImageReferences({
|
||||
sourceType: 'post', // ou 'page', '@zen/module-shop:product', …
|
||||
sourceId: documentId,
|
||||
blocks: normalized,
|
||||
});
|
||||
|
||||
// Au render (page SSR) :
|
||||
const blocks = await loadDocument(slug);
|
||||
const enriched = await enrichBlocksWithMedia(blocks);
|
||||
return <BlockEditor value={enriched} disabled />;
|
||||
```
|
||||
|
||||
`normalizeImageBlocks` :
|
||||
- Lazy upgrade : extrait `mediaSlug` depuis `src` quand l'URL matche
|
||||
`/zen/api/media/file/<slug>`.
|
||||
- Back-fill : si le média n'a pas encore d'`alt_text` / `caption` mais que le
|
||||
bloc en porte (legacy), remonte la valeur côté `zen_media`.
|
||||
- Strict : nettoie `alt` et `caption` du bloc dès qu'un `mediaSlug` est résolu.
|
||||
|
||||
`enrichBlocksWithMedia` : une seule requête batch pour tous les `mediaSlug`
|
||||
du document, attache `_resolvedAlt` / `_resolvedCaption`. Le composant de
|
||||
rendu lit ces champs en priorité.
|
||||
|
||||
`syncBlockImageReferences` : synchronise `zen_media_references` avec
|
||||
`field = 'block_image'`. La contrainte FK `ON DELETE RESTRICT` empêche
|
||||
ensuite la suppression d'un média référencé par un bloc image tant que le
|
||||
document hôte existe. Pensez à appeler `detachAllForSource` (de
|
||||
`@zen/core/features/media`) à la suppression du document hôte.
|
||||
|
||||
Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante**
|
||||
(coin haut-droit) reprenant le style `BOX_CLASS` partagé :
|
||||
@@ -313,14 +378,18 @@ Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante**
|
||||
- **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).
|
||||
HTML standards (uniquement pour les images sans `mediaSlug` — cf.
|
||||
[Liaison avec la médiathèque](#liaison-avec-la-médiathèque-mediaslug)).
|
||||
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`.
|
||||
quand l'`<img>` y est imbriqué pour récupérer `href` / `target`. Si le `src`
|
||||
de l'image matche `/zen/api/media/file/<slug>`, il reconstitue `mediaSlug`
|
||||
pour préserver la liaison média lors d'un copier-coller interne.
|
||||
|
||||
## Limitations connues
|
||||
|
||||
|
||||
@@ -23,9 +23,42 @@ import { newBlockId } from '../utils/ids.js';
|
||||
import MediaPicker from '../../../../features/media/components/MediaPicker.client.js';
|
||||
|
||||
// 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`.
|
||||
// rendue + toolbar flottante (alignement, lien, remplacer, supprimer).
|
||||
//
|
||||
// Champs persistés :
|
||||
// - `src` : URL (interne `/zen/api/media/file/<slug>` ou externe)
|
||||
// - `mediaSlug` : slug du média lié, présent si l'image vient de la
|
||||
// médiathèque. Sert de référence vers `zen_media` et
|
||||
// fait du média la source de vérité pour alt/caption.
|
||||
// - `alt`/`caption` : utilisés UNIQUEMENT pour les URL externes (pas de
|
||||
// `mediaSlug`). Quand un mediaSlug existe, alt et
|
||||
// caption sont lus depuis le média via l'enrichissement
|
||||
// serveur (`enrichBlocksWithMedia`) et exposés sur le
|
||||
// bloc dans `_resolvedAlt` / `_resolvedCaption`.
|
||||
// - `align` : 'left' | 'center' | 'right' | 'full'
|
||||
// - `href`/`newTab` : lien optionnel
|
||||
//
|
||||
// Snapshot d'édition : `_mediaAlt` / `_mediaCaption` portent la copie des
|
||||
// méta du média captées au moment de l'insertion, pour affichage immédiat
|
||||
// dans le bandeau lecture-seule sans round-trip réseau. Champs internes
|
||||
// préfixés `_` — non persistés ; ré-hydratés par l'enrichissement serveur
|
||||
// au prochain rendu.
|
||||
|
||||
const MEDIA_FILE_URL_RE = /^\/zen\/api\/media\/file\/([^/?#]+)$/;
|
||||
|
||||
function extractMediaSlug(src) {
|
||||
if (typeof src !== 'string') return null;
|
||||
const m = MEDIA_FILE_URL_RE.exec(src);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function resolvedAlt(block) {
|
||||
return block._resolvedAlt ?? block._mediaAlt ?? block.alt ?? '';
|
||||
}
|
||||
|
||||
function resolvedCaption(block) {
|
||||
return block._resolvedCaption ?? block._mediaCaption ?? block.caption ?? '';
|
||||
}
|
||||
|
||||
const ALIGN_OPTIONS = [
|
||||
{ value: 'left', label: 'Aligner à gauche', Icon: AlignLeftIcon },
|
||||
@@ -114,7 +147,14 @@ function ImageUrlForm({ initialUrl, onSubmit, onCancel, disabled }) {
|
||||
isOpen={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={(media) => {
|
||||
if (media?.slug) onSubmit?.(`/zen/api/media/file/${media.slug}`);
|
||||
if (media?.slug) {
|
||||
onSubmit?.({
|
||||
src: `/zen/api/media/file/${media.slug}`,
|
||||
mediaSlug: media.slug,
|
||||
mediaAlt: media.alt_text ?? '',
|
||||
mediaCaption: media.caption ?? '',
|
||||
});
|
||||
}
|
||||
}}
|
||||
accept="image/*"
|
||||
visibility="any"
|
||||
@@ -270,13 +310,36 @@ function ImageBlock({ block, onChange, disabled }) {
|
||||
// `block.src` est non vide.
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const showForm = !block.src || replacing;
|
||||
const linkedSlug = block.mediaSlug || extractMediaSlug(block.src);
|
||||
|
||||
function handleSetSrc(src) {
|
||||
onChange?.({ src });
|
||||
// Le payload reçu peut être :
|
||||
// - une string (URL libre saisie / Entrée dans le champ),
|
||||
// - un objet { src, mediaSlug, mediaAlt, mediaCaption } (issu du picker).
|
||||
// Quand on lie un média, alt/caption locaux sont nettoyés (mode strict :
|
||||
// le média est la source de vérité). Pour les URL externes, on garde le
|
||||
// alt/caption existant et on efface tout snapshot média précédent.
|
||||
function handleSetSrc(payload) {
|
||||
const isObj = payload && typeof payload === 'object';
|
||||
const src = isObj ? payload.src : payload;
|
||||
const explicitSlug = isObj ? payload.mediaSlug : null;
|
||||
const slug = explicitSlug ?? extractMediaSlug(src);
|
||||
const patch = { src: src ?? '', mediaSlug: slug || '' };
|
||||
if (slug) {
|
||||
patch.alt = '';
|
||||
patch.caption = '';
|
||||
patch._mediaAlt = isObj ? (payload.mediaAlt ?? '') : '';
|
||||
patch._mediaCaption = isObj ? (payload.mediaCaption ?? '') : '';
|
||||
} else {
|
||||
patch._mediaAlt = '';
|
||||
patch._mediaCaption = '';
|
||||
}
|
||||
onChange?.(patch);
|
||||
setReplacing(false);
|
||||
}
|
||||
|
||||
function handleAltChange(e) {
|
||||
// Sans mediaSlug uniquement : si un média est lié, l'alt vit côté
|
||||
// médiathèque (édition impossible depuis le bloc).
|
||||
onChange?.({ alt: e.target.value });
|
||||
}
|
||||
|
||||
@@ -286,7 +349,17 @@ function ImageBlock({ block, onChange, disabled }) {
|
||||
|
||||
function handleRemove() {
|
||||
// Vide tous les champs : la prochaine frappe ouvre à nouveau le formulaire.
|
||||
onChange?.({ src: '', alt: '', caption: '', href: '', newTab: false, align: 'center' });
|
||||
onChange?.({
|
||||
src: '',
|
||||
mediaSlug: '',
|
||||
alt: '',
|
||||
caption: '',
|
||||
href: '',
|
||||
newTab: false,
|
||||
align: 'center',
|
||||
_mediaAlt: '',
|
||||
_mediaCaption: '',
|
||||
});
|
||||
setReplacing(false);
|
||||
}
|
||||
|
||||
@@ -307,6 +380,9 @@ function ImageBlock({ block, onChange, disabled }) {
|
||||
? 'rounded-lg w-full block'
|
||||
: 'rounded-lg max-w-full block';
|
||||
|
||||
const altForRender = resolvedAlt(block);
|
||||
const captionForRender = resolvedCaption(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.
|
||||
@@ -314,7 +390,7 @@ function ImageBlock({ block, onChange, disabled }) {
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={block.src}
|
||||
alt={block.alt ?? ''}
|
||||
alt={altForRender}
|
||||
className={imgClass}
|
||||
draggable={false}
|
||||
/>
|
||||
@@ -351,22 +427,54 @@ function ImageBlock({ block, onChange, disabled }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<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"
|
||||
/>
|
||||
{!disabled && (
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{linkedSlug ? (
|
||||
// Image liée à un média : alt et caption viennent du média. Pas
|
||||
// d'inputs locaux. En lecture seule on affiche juste la légende.
|
||||
// En édition on montre un bandeau avec un lien vers la médiathèque.
|
||||
disabled ? (
|
||||
captionForRender ? (
|
||||
<div className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400">
|
||||
{captionForRender}
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 px-1 py-1 rounded border border-dashed border-neutral-200 dark:border-neutral-700/60 bg-neutral-50/50 dark:bg-neutral-800/30">
|
||||
<div className="text-sm italic text-neutral-600 dark:text-neutral-400 truncate">
|
||||
{captionForRender || <span className="not-italic text-neutral-400 dark:text-neutral-500">Aucune légende</span>}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-500 truncate">
|
||||
{altForRender || <span className="text-neutral-400 dark:text-neutral-500">Aucun texte alternatif</span>}
|
||||
</div>
|
||||
<a
|
||||
href={`/admin/media?slug=${encodeURIComponent(linkedSlug)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="self-start text-[11px] text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Modifier dans la médiathèque ↗
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<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"
|
||||
/>
|
||||
{!disabled && (
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,6 +492,7 @@ const Image = {
|
||||
id: newBlockId(),
|
||||
type: 'image',
|
||||
src: '',
|
||||
mediaSlug: '',
|
||||
alt: '',
|
||||
caption: '',
|
||||
align: 'center',
|
||||
|
||||
@@ -15,6 +15,16 @@ import { inlineFromText, inlineToPlainText, normalize } from './types.js';
|
||||
|
||||
const HEADING_RE = /^heading_([1-6])$/;
|
||||
const BLOCK_TAG_RE = /^(P|H[1-6]|UL|OL|BLOCKQUOTE|PRE|HR|FIGURE|DIV|TABLE)$/;
|
||||
const MEDIA_FILE_URL_RE = /^\/zen\/api\/media\/file\/([^/?#]+)$/;
|
||||
|
||||
// Quand une image collée pointe sur la médiathèque interne, on dérive le
|
||||
// slug pour reconstituer le lien `mediaSlug` (un copier-coller ne traverse
|
||||
// pas les champs cachés). Pour les URL externes, retourne null.
|
||||
function imageSlugFromSrc(src) {
|
||||
if (typeof src !== 'string') return null;
|
||||
const m = MEDIA_FILE_URL_RE.exec(src);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Block[] → HTML string. Regroupe les listes consécutives sous un seul
|
||||
// <ul>/<ol>. Les blocs inconnus deviennent un <p> au texte aplati.
|
||||
@@ -98,9 +108,15 @@ function blockToElement(block) {
|
||||
: align === 'left' ? 'text-align:left'
|
||||
: 'text-align:right');
|
||||
}
|
||||
// Quand le bloc est lié à un média (`mediaSlug`), alt/caption sont
|
||||
// résolus côté serveur (`_resolvedAlt` / `_resolvedCaption`) ou
|
||||
// captés à l'insertion (`_mediaAlt` / `_mediaCaption`). Pour les URL
|
||||
// externes, on retombe sur les champs locaux du bloc.
|
||||
const altText = block._resolvedAlt ?? block._mediaAlt ?? block.alt ?? '';
|
||||
const captionText = block._resolvedCaption ?? block._mediaCaption ?? block.caption ?? '';
|
||||
const img = document.createElement('img');
|
||||
img.setAttribute('src', block.src || '');
|
||||
if (block.alt) img.setAttribute('alt', block.alt);
|
||||
if (altText) img.setAttribute('alt', altText);
|
||||
if (align === 'full') img.setAttribute('width', '100%');
|
||||
let imgHost = img;
|
||||
if (block.href) {
|
||||
@@ -114,9 +130,9 @@ function blockToElement(block) {
|
||||
imgHost = a;
|
||||
}
|
||||
fig.appendChild(imgHost);
|
||||
if (block.caption) {
|
||||
if (captionText) {
|
||||
const cap = document.createElement('figcaption');
|
||||
cap.textContent = block.caption;
|
||||
cap.textContent = captionText;
|
||||
fig.appendChild(cap);
|
||||
}
|
||||
return fig;
|
||||
@@ -133,7 +149,11 @@ export function blocksToPlainText(blocks) {
|
||||
return blocks
|
||||
.map(b => {
|
||||
if (b.type === 'divider') return '---';
|
||||
if (b.type === 'image') return b.alt || b.caption || '';
|
||||
if (b.type === 'image') {
|
||||
const alt = b._resolvedAlt ?? b._mediaAlt ?? b.alt ?? '';
|
||||
const cap = b._resolvedCaption ?? b._mediaCaption ?? b.caption ?? '';
|
||||
return alt || cap || '';
|
||||
}
|
||||
return inlineToPlainText(b.content ?? []);
|
||||
})
|
||||
.join('\n');
|
||||
@@ -296,15 +316,21 @@ function parseChildren(node, out) {
|
||||
const align = ['left', 'center', 'right', 'full'].includes(dataAlign) ? dataAlign : 'center';
|
||||
const href = linkEl?.getAttribute('href') || '';
|
||||
const newTab = !!href && linkEl?.getAttribute('target') === '_blank';
|
||||
const src = img.getAttribute('src') || '';
|
||||
const slug = imageSlugFromSrc(src);
|
||||
const altText = img.getAttribute('alt') || '';
|
||||
const captionText = cap?.textContent?.trim() || '';
|
||||
out.push({
|
||||
id: newBlockId(),
|
||||
type: 'image',
|
||||
src: img.getAttribute('src') || '',
|
||||
alt: img.getAttribute('alt') || '',
|
||||
caption: cap?.textContent?.trim() || '',
|
||||
src,
|
||||
mediaSlug: slug || '',
|
||||
alt: slug ? '' : altText,
|
||||
caption: slug ? '' : captionText,
|
||||
align,
|
||||
href,
|
||||
newTab,
|
||||
...(slug ? { _mediaAlt: altText, _mediaCaption: captionText } : null),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -312,15 +338,20 @@ function parseChildren(node, out) {
|
||||
|
||||
if (tag === 'IMG') {
|
||||
flush();
|
||||
const src = child.getAttribute('src') || '';
|
||||
const slug = imageSlugFromSrc(src);
|
||||
const altText = child.getAttribute('alt') || '';
|
||||
out.push({
|
||||
id: newBlockId(),
|
||||
type: 'image',
|
||||
src: child.getAttribute('src') || '',
|
||||
alt: child.getAttribute('alt') || '',
|
||||
src,
|
||||
mediaSlug: slug || '',
|
||||
alt: slug ? '' : altText,
|
||||
caption: '',
|
||||
align: 'center',
|
||||
href: '',
|
||||
newTab: false,
|
||||
...(slug ? { _mediaAlt: altText } : null),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* BlockEditor — liaison serveur avec la médiathèque pour les blocs image.
|
||||
*
|
||||
* Trois helpers, à appeler depuis les actions/routes serveur des modules
|
||||
* consommateurs qui hébergent du contenu BlockEditor :
|
||||
*
|
||||
* - `normalizeImageBlocks(blocks)` : prépare les blocs pour la persistance.
|
||||
* Lazy migration des blocs legacy (extraction du slug depuis `src`),
|
||||
* back-fill de l'alt/caption côté média si manquants, suppression des
|
||||
* champs `alt`/`caption` du bloc en mode strict (média = source unique).
|
||||
*
|
||||
* - `enrichBlocksWithMedia(blocks)` : prépare les blocs pour le rendu.
|
||||
* Une seule requête batch à `zen_media`, attache `_resolvedAlt` /
|
||||
* `_resolvedCaption` aux blocs liés. Champs internes (préfixe `_`),
|
||||
* non persistés.
|
||||
*
|
||||
* - `syncBlockImageReferences({ sourceType, sourceId, blocks })` :
|
||||
* synchronise `zen_media_references` pour la liste courante des médias
|
||||
* référencés par les blocs image du document. À appeler dans la même
|
||||
* transaction que le save.
|
||||
*
|
||||
* Module **serveur** : importe `@zen/core/database`. Les consommateurs
|
||||
* client n'ont pas besoin de ces helpers — l'éditeur client persiste les
|
||||
* blocs tels quels, c'est au moment du save côté serveur qu'on normalise.
|
||||
*/
|
||||
|
||||
import { query } from '@zen/core/database';
|
||||
|
||||
const MEDIA_FILE_URL_RE = /^\/zen\/api\/media\/file\/([^/?#]+)$/;
|
||||
const REFERENCE_FIELD = 'block_image';
|
||||
|
||||
function extractSlugFromSrc(src) {
|
||||
if (typeof src !== 'string') return null;
|
||||
const m = MEDIA_FILE_URL_RE.exec(src);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function collectImageBlocks(blocks) {
|
||||
const out = [];
|
||||
if (!Array.isArray(blocks)) return out;
|
||||
for (const b of blocks) {
|
||||
if (b && b.type === 'image') out.push(b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSlugs(blocks) {
|
||||
const slugs = new Set();
|
||||
for (const b of collectImageBlocks(blocks)) {
|
||||
const slug = b.mediaSlug || extractSlugFromSrc(b.src);
|
||||
if (slug) slugs.add(slug);
|
||||
}
|
||||
return [...slugs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge `id`, `slug`, `alt_text`, `caption` pour un lot de slugs en une seule
|
||||
* requête. Retourne une `Map<slug, row>`.
|
||||
*/
|
||||
async function fetchMediaBySlugs(slugs) {
|
||||
if (slugs.length === 0) return new Map();
|
||||
const result = await query(
|
||||
`SELECT id, slug, alt_text, caption FROM zen_media WHERE slug = ANY($1::text[])`,
|
||||
[slugs]
|
||||
);
|
||||
const map = new Map();
|
||||
for (const row of result.rows) map.set(row.slug, row);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise les blocs image avant persistance.
|
||||
*
|
||||
* - Si `mediaSlug` est absent mais que `src` matche `/zen/api/media/file/<slug>`,
|
||||
* extrait et persiste le slug (lazy upgrade des blocs legacy).
|
||||
* - Si le bloc est lié à un média existant et que ce média n'a pas encore
|
||||
* d'`alt_text` / `caption` mais que le bloc en porte (legacy ou saisie
|
||||
* utilisateur), back-fill côté média (la valeur la plus récente vit
|
||||
* côté bloc en cas de migration).
|
||||
* - Quand un `mediaSlug` est résolu (existant en BD), supprime `alt` et
|
||||
* `caption` du bloc en mode strict.
|
||||
* - Quand `mediaSlug` est posé mais ne correspond à aucun média en BD
|
||||
* (média supprimé ou slug invalide), on retire `mediaSlug` et on garde
|
||||
* `alt`/`caption` côté bloc — l'image devient une URL externe.
|
||||
* - Les champs internes préfixés `_` (snapshots d'édition) sont toujours
|
||||
* strippés.
|
||||
*
|
||||
* @param {object[]} blocks
|
||||
* @returns {Promise<object[]>} nouveau tableau de blocs (input non muté)
|
||||
*/
|
||||
export async function normalizeImageBlocks(blocks) {
|
||||
if (!Array.isArray(blocks) || blocks.length === 0) return blocks ?? [];
|
||||
|
||||
const slugs = collectSlugs(blocks);
|
||||
const mediaBySlug = await fetchMediaBySlugs(slugs);
|
||||
|
||||
// Back-fill : pour chaque média sans alt/caption où le bloc en porte, on
|
||||
// remonte la valeur. Une seule UPDATE par champ manquant ; on agrège les
|
||||
// candidats par slug pour ne back-filler qu'une fois (premier bloc gagne).
|
||||
const altBackfill = new Map(); // slug → alt_text
|
||||
const captionBackfill = new Map(); // slug → caption
|
||||
|
||||
for (const b of collectImageBlocks(blocks)) {
|
||||
const slug = b.mediaSlug || extractSlugFromSrc(b.src);
|
||||
if (!slug) continue;
|
||||
const media = mediaBySlug.get(slug);
|
||||
if (!media) continue;
|
||||
if ((!media.alt_text || media.alt_text === '') && b.alt && !altBackfill.has(slug)) {
|
||||
altBackfill.set(slug, b.alt);
|
||||
}
|
||||
if ((!media.caption || media.caption === '') && b.caption && !captionBackfill.has(slug)) {
|
||||
captionBackfill.set(slug, b.caption);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [slug, altText] of altBackfill) {
|
||||
await query(
|
||||
`UPDATE zen_media SET alt_text = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE slug = $2 AND (alt_text IS NULL OR alt_text = '')`,
|
||||
[altText, slug]
|
||||
);
|
||||
const media = mediaBySlug.get(slug);
|
||||
if (media) media.alt_text = altText;
|
||||
}
|
||||
for (const [slug, caption] of captionBackfill) {
|
||||
await query(
|
||||
`UPDATE zen_media SET caption = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE slug = $2 AND (caption IS NULL OR caption = '')`,
|
||||
[caption, slug]
|
||||
);
|
||||
const media = mediaBySlug.get(slug);
|
||||
if (media) media.caption = caption;
|
||||
}
|
||||
|
||||
return blocks.map(b => {
|
||||
if (!b || b.type !== 'image') return b;
|
||||
const slug = b.mediaSlug || extractSlugFromSrc(b.src);
|
||||
const media = slug ? mediaBySlug.get(slug) : null;
|
||||
// Champs internes toujours strippés.
|
||||
const {
|
||||
_mediaAlt: _ma, _mediaCaption: _mc,
|
||||
_resolvedAlt: _ra, _resolvedCaption: _rc,
|
||||
...rest
|
||||
} = b;
|
||||
void _ma; void _mc; void _ra; void _rc;
|
||||
if (media) {
|
||||
// Mode strict : alt/caption vivent côté média.
|
||||
return { ...rest, mediaSlug: slug, alt: '', caption: '' };
|
||||
}
|
||||
// Pas de média correspondant → URL externe ou média supprimé. On
|
||||
// nettoie `mediaSlug` pour ne pas créer de référence orpheline.
|
||||
return { ...rest, mediaSlug: '' };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attache `_resolvedAlt` et `_resolvedCaption` aux blocs image liés à un
|
||||
* média. Le composant de rendu lit ces champs en priorité (cf. helpers
|
||||
* `resolvedAlt` / `resolvedCaption` dans `Image.client.js`). Les blocs URL
|
||||
* libres ne sont pas modifiés.
|
||||
*
|
||||
* @param {object[]} blocks
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
export async function enrichBlocksWithMedia(blocks) {
|
||||
if (!Array.isArray(blocks) || blocks.length === 0) return blocks ?? [];
|
||||
|
||||
const slugs = collectSlugs(blocks);
|
||||
if (slugs.length === 0) return blocks;
|
||||
const mediaBySlug = await fetchMediaBySlugs(slugs);
|
||||
|
||||
return blocks.map(b => {
|
||||
if (!b || b.type !== 'image') return b;
|
||||
const slug = b.mediaSlug || extractSlugFromSrc(b.src);
|
||||
if (!slug) return b;
|
||||
const media = mediaBySlug.get(slug);
|
||||
if (!media) return b;
|
||||
return {
|
||||
...b,
|
||||
_resolvedAlt: media.alt_text ?? '',
|
||||
_resolvedCaption: media.caption ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise `zen_media_references` pour les blocs image d'un document.
|
||||
*
|
||||
* Utilise le `field = 'block_image'` pour distinguer ces références des
|
||||
* autres usages (`featured_image`, `gallery`, …). À chaque save :
|
||||
* supprime les anciennes références `block_image` du document, puis
|
||||
* insère celles correspondant aux blocs courants.
|
||||
*
|
||||
* À appeler dans la même transaction que le save du contenu hôte. La
|
||||
* contrainte FK `ON DELETE RESTRICT` empêche ensuite la suppression d'un
|
||||
* média référencé via la médiathèque tant que le document existe.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.sourceType Ex: 'post', 'page', '@zen/module-shop:product'
|
||||
* @param {string} args.sourceId ID du document hôte
|
||||
* @param {object[]} args.blocks Blocs (peuvent être déjà normalisés ou non)
|
||||
*/
|
||||
export async function syncBlockImageReferences({ sourceType, sourceId, blocks }) {
|
||||
if (!sourceType || !sourceId) {
|
||||
return { success: false, error: 'sourceType and sourceId are required' };
|
||||
}
|
||||
|
||||
const slugs = collectSlugs(blocks);
|
||||
// Résoudre les slugs en `media_id` (la table de jointure indexe l'id, pas le slug).
|
||||
const mediaBySlug = await fetchMediaBySlugs(slugs);
|
||||
const mediaIds = [...new Set(slugs.map(s => mediaBySlug.get(s)?.id).filter(Boolean))];
|
||||
|
||||
// Supprime toutes les anciennes références block_image du document. Cette
|
||||
// approche « replace » est plus simple qu'un diff et reste efficace : le
|
||||
// nombre d'images par document est petit, et la clé primaire compose
|
||||
// déjà (media_id, source_type, source_id, field).
|
||||
await query(
|
||||
`DELETE FROM zen_media_references
|
||||
WHERE source_type = $1 AND source_id = $2 AND field = $3`,
|
||||
[sourceType, sourceId, REFERENCE_FIELD]
|
||||
);
|
||||
|
||||
if (mediaIds.length > 0) {
|
||||
// Insère en une seule requête multi-rows.
|
||||
const values = [];
|
||||
const params = [sourceType, sourceId, REFERENCE_FIELD];
|
||||
for (const mediaId of mediaIds) {
|
||||
params.push(mediaId);
|
||||
values.push(`($${params.length}, $1, $2, $3)`);
|
||||
}
|
||||
await query(
|
||||
`INSERT INTO zen_media_references (media_id, source_type, source_id, field)
|
||||
VALUES ${values.join(', ')}
|
||||
ON CONFLICT DO NOTHING`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true, count: mediaIds.length };
|
||||
}
|
||||
Reference in New Issue
Block a user