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:
@@ -151,6 +151,9 @@
|
|||||||
"./shared/components": {
|
"./shared/components": {
|
||||||
"import": "./dist/shared/components/index.js"
|
"import": "./dist/shared/components/index.js"
|
||||||
},
|
},
|
||||||
|
"./shared/components/BlockEditor/mediaLink": {
|
||||||
|
"import": "./dist/shared/components/BlockEditor/mediaLink.server.js"
|
||||||
|
},
|
||||||
"./shared/icons": {
|
"./shared/icons": {
|
||||||
"import": "./dist/shared/icons/index.js"
|
"import": "./dist/shared/icons/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ L'item « Médias » apparaît comme entrée top-level de la sidebar admin (sans
|
|||||||
|
|
||||||
`zen_media_references` — table de jointure (media_id, source_type, source_id, field). FK `ON DELETE RESTRICT` empêche la suppression d'un média référencé par un contenu existant.
|
`zen_media_references` — table de jointure (media_id, source_type, source_id, field). FK `ON DELETE RESTRICT` empêche la suppression d'un média référencé par un contenu existant.
|
||||||
|
|
||||||
|
Conventions de `field` connues :
|
||||||
|
- `featured_image` / `gallery` / *etc.* — champs nommés des modules consommateurs (cf. `attachMedia`).
|
||||||
|
- `block_image` — image insérée dans du contenu BlockEditor. Synchronisée
|
||||||
|
automatiquement par [`syncBlockImageReferences`](../../shared/components/BlockEditor/mediaLink.server.js)
|
||||||
|
à chaque save du document hôte. Voir [BlockEditor README — Liaison avec la médiathèque](../../shared/components/BlockEditor/README.md#liaison-avec-la-médiathèque-mediaslug).
|
||||||
|
|
||||||
### Stockage
|
### Stockage
|
||||||
|
|
||||||
Tous les uploads sont stockés sous `media/<yyyy>/<mm>/<filename-unique>` via [`@zen/core/storage`](../../core/storage). **Tous les objets S3 sont privés** au niveau du bucket — l'accès public passe exclusivement par notre route HTTP qui valide la `visibility` en BD avant de proxyfier le contenu.
|
Tous les uploads sont stockés sous `media/<yyyy>/<mm>/<filename-unique>` via [`@zen/core/storage`](../../core/storage). **Tous les objets S3 sont privés** au niveau du bucket — l'accès public passe exclusivement par notre route HTTP qui valide la `visibility` en BD avant de proxyfier le contenu.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Chaque bloc a un `id` (UUID) et un `type`. Selon le type :
|
|||||||
| `quote` | `content` | citation |
|
| `quote` | `content` | citation |
|
||||||
| `code` | `content` | bloc de code (monospace) |
|
| `code` | `content` | bloc de code (monospace) |
|
||||||
| `divider` | — | séparateur horizontal |
|
| `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.
|
`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 ».
|
- **URL externe** : champ libre + bouton « Insérer ».
|
||||||
- **Bibliothèque media** : bouton « Choisir un média » qui ouvre le `MediaPicker`
|
- **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
|
de `@zen/core/features/media`. À la sélection, le bloc reçoit
|
||||||
`block.src = "/zen/api/media/file/<slug>"`. Les nouveaux uploads passent par
|
`block.src = "/zen/api/media/file/<slug>"` **et** `block.mediaSlug = "<slug>"`.
|
||||||
ce picker en visibilité `public` (les blocs image vivent dans du contenu
|
Les nouveaux uploads passent par ce picker en visibilité `public` (les blocs
|
||||||
publié — un média privé serait illisible côté frontal).
|
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**
|
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é :
|
||||||
@@ -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.
|
- **Supprimer** : vide tous les champs ; le formulaire URL réapparaît.
|
||||||
|
|
||||||
Les inputs *Légende* et *Texte alternatif* sous l'image sont des `<input>`
|
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
|
HTML standards (uniquement pour les images sans `mediaSlug` — cf.
|
||||||
multi-blocs (cf. garde dans `handleContainerMouseDown` : tout `input`,
|
[Liaison avec la médiathèque](#liaison-avec-la-médiathèque-mediaslug)).
|
||||||
`textarea`, `select` ou `[contenteditable="true"]` reçoit son focus normal
|
Le clic dans ces inputs ne déclenche pas la sélection multi-blocs (cf. garde
|
||||||
et n'entre pas en mode sélection).
|
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>`.
|
Sérialisation HTML : `<figure data-align="…"><a href><img></a><figcaption></figcaption></figure>`.
|
||||||
Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `<a>`
|
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
|
## Limitations connues
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,42 @@ import { newBlockId } from '../utils/ids.js';
|
|||||||
import MediaPicker from '../../../../features/media/components/MediaPicker.client.js';
|
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).
|
||||||
// inputs caption / alt en dessous. Champs persistés : `src`, `alt`,
|
//
|
||||||
// `caption`, `align` (`'left' | 'center' | 'right' | 'full'`), `href`.
|
// 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 = [
|
const ALIGN_OPTIONS = [
|
||||||
{ value: 'left', label: 'Aligner à gauche', Icon: AlignLeftIcon },
|
{ value: 'left', label: 'Aligner à gauche', Icon: AlignLeftIcon },
|
||||||
@@ -114,7 +147,14 @@ function ImageUrlForm({ initialUrl, onSubmit, onCancel, disabled }) {
|
|||||||
isOpen={pickerOpen}
|
isOpen={pickerOpen}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
onSelect={(media) => {
|
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/*"
|
accept="image/*"
|
||||||
visibility="any"
|
visibility="any"
|
||||||
@@ -270,13 +310,36 @@ function ImageBlock({ block, onChange, disabled }) {
|
|||||||
// `block.src` est non vide.
|
// `block.src` est non vide.
|
||||||
const [replacing, setReplacing] = useState(false);
|
const [replacing, setReplacing] = useState(false);
|
||||||
const showForm = !block.src || replacing;
|
const showForm = !block.src || replacing;
|
||||||
|
const linkedSlug = block.mediaSlug || extractMediaSlug(block.src);
|
||||||
|
|
||||||
function handleSetSrc(src) {
|
// Le payload reçu peut être :
|
||||||
onChange?.({ src });
|
// - 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);
|
setReplacing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAltChange(e) {
|
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 });
|
onChange?.({ alt: e.target.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +349,17 @@ function ImageBlock({ block, onChange, disabled }) {
|
|||||||
|
|
||||||
function handleRemove() {
|
function handleRemove() {
|
||||||
// Vide tous les champs : la prochaine frappe ouvre à nouveau le formulaire.
|
// 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);
|
setReplacing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +380,9 @@ function ImageBlock({ block, onChange, disabled }) {
|
|||||||
? 'rounded-lg w-full block'
|
? 'rounded-lg w-full block'
|
||||||
: 'rounded-lg max-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>.
|
// 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
|
// 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.
|
// 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 */
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<img
|
||||||
src={block.src}
|
src={block.src}
|
||||||
alt={block.alt ?? ''}
|
alt={altForRender}
|
||||||
className={imgClass}
|
className={imgClass}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
@@ -351,6 +427,36 @@ function ImageBlock({ block, onChange, disabled }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
{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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Légende (optionnelle)"
|
placeholder="Légende (optionnelle)"
|
||||||
@@ -368,6 +474,8 @@ function ImageBlock({ block, onChange, disabled }) {
|
|||||||
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="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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -384,6 +492,7 @@ const Image = {
|
|||||||
id: newBlockId(),
|
id: newBlockId(),
|
||||||
type: 'image',
|
type: 'image',
|
||||||
src: '',
|
src: '',
|
||||||
|
mediaSlug: '',
|
||||||
alt: '',
|
alt: '',
|
||||||
caption: '',
|
caption: '',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ import { inlineFromText, inlineToPlainText, normalize } from './types.js';
|
|||||||
|
|
||||||
const HEADING_RE = /^heading_([1-6])$/;
|
const HEADING_RE = /^heading_([1-6])$/;
|
||||||
const BLOCK_TAG_RE = /^(P|H[1-6]|UL|OL|BLOCKQUOTE|PRE|HR|FIGURE|DIV|TABLE)$/;
|
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
|
// Block[] → HTML string. Regroupe les listes consécutives sous un seul
|
||||||
// <ul>/<ol>. Les blocs inconnus deviennent un <p> au texte aplati.
|
// <ul>/<ol>. Les blocs inconnus deviennent un <p> au texte aplati.
|
||||||
@@ -98,9 +108,15 @@ function blockToElement(block) {
|
|||||||
: align === 'left' ? 'text-align:left'
|
: align === 'left' ? 'text-align:left'
|
||||||
: 'text-align:right');
|
: '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');
|
const img = document.createElement('img');
|
||||||
img.setAttribute('src', block.src || '');
|
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%');
|
if (align === 'full') img.setAttribute('width', '100%');
|
||||||
let imgHost = img;
|
let imgHost = img;
|
||||||
if (block.href) {
|
if (block.href) {
|
||||||
@@ -114,9 +130,9 @@ function blockToElement(block) {
|
|||||||
imgHost = a;
|
imgHost = a;
|
||||||
}
|
}
|
||||||
fig.appendChild(imgHost);
|
fig.appendChild(imgHost);
|
||||||
if (block.caption) {
|
if (captionText) {
|
||||||
const cap = document.createElement('figcaption');
|
const cap = document.createElement('figcaption');
|
||||||
cap.textContent = block.caption;
|
cap.textContent = captionText;
|
||||||
fig.appendChild(cap);
|
fig.appendChild(cap);
|
||||||
}
|
}
|
||||||
return fig;
|
return fig;
|
||||||
@@ -133,7 +149,11 @@ export function blocksToPlainText(blocks) {
|
|||||||
return blocks
|
return blocks
|
||||||
.map(b => {
|
.map(b => {
|
||||||
if (b.type === 'divider') return '---';
|
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 ?? []);
|
return inlineToPlainText(b.content ?? []);
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
@@ -296,15 +316,21 @@ function parseChildren(node, out) {
|
|||||||
const align = ['left', 'center', 'right', 'full'].includes(dataAlign) ? dataAlign : 'center';
|
const align = ['left', 'center', 'right', 'full'].includes(dataAlign) ? dataAlign : 'center';
|
||||||
const href = linkEl?.getAttribute('href') || '';
|
const href = linkEl?.getAttribute('href') || '';
|
||||||
const newTab = !!href && linkEl?.getAttribute('target') === '_blank';
|
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({
|
out.push({
|
||||||
id: newBlockId(),
|
id: newBlockId(),
|
||||||
type: 'image',
|
type: 'image',
|
||||||
src: img.getAttribute('src') || '',
|
src,
|
||||||
alt: img.getAttribute('alt') || '',
|
mediaSlug: slug || '',
|
||||||
caption: cap?.textContent?.trim() || '',
|
alt: slug ? '' : altText,
|
||||||
|
caption: slug ? '' : captionText,
|
||||||
align,
|
align,
|
||||||
href,
|
href,
|
||||||
newTab,
|
newTab,
|
||||||
|
...(slug ? { _mediaAlt: altText, _mediaCaption: captionText } : null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -312,15 +338,20 @@ function parseChildren(node, out) {
|
|||||||
|
|
||||||
if (tag === 'IMG') {
|
if (tag === 'IMG') {
|
||||||
flush();
|
flush();
|
||||||
|
const src = child.getAttribute('src') || '';
|
||||||
|
const slug = imageSlugFromSrc(src);
|
||||||
|
const altText = child.getAttribute('alt') || '';
|
||||||
out.push({
|
out.push({
|
||||||
id: newBlockId(),
|
id: newBlockId(),
|
||||||
type: 'image',
|
type: 'image',
|
||||||
src: child.getAttribute('src') || '',
|
src,
|
||||||
alt: child.getAttribute('alt') || '',
|
mediaSlug: slug || '',
|
||||||
|
alt: slug ? '' : altText,
|
||||||
caption: '',
|
caption: '',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
href: '',
|
href: '',
|
||||||
newTab: false,
|
newTab: false,
|
||||||
|
...(slug ? { _mediaAlt: altText } : null),
|
||||||
});
|
});
|
||||||
continue;
|
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