Files
core/src/shared/components/BlockEditor
hykocx e5b21c0d54 feat(media): extract media details into reusable modal component
- add `MediaDetailsModal.client.js` with support for `media={…}` or `slug="…"` props
- add `GET /zen/api/media/by-slug/:slug` route for slug-based lookup
- refactor `MediaPage.client.js` to use the new modal instead of inline details panel
- export `MediaDetailsModal` as `./features/media/details-modal` in package.json
- update `BlockEditor` image block to open `MediaDetailsModal` for media editing
- update media feature README to document new component and route
2026-04-26 20:38:29 -04:00
..

BlockEditor

Éditeur WYSIWYG par blocs, style Notion. Construit en interne (pas de ProseMirror/Lexical/Tiptap). Un contentEditable par bloc, pas un seul contentEditable global — c'est plus robuste et plus simple à étendre.

Utilisation

import { BlockEditor } from '@zen/core/shared/components';

const [blocks, setBlocks] = useState([]);
<BlockEditor value={blocks} onChange={setBlocks} label="Contenu" />

value est un tableau de blocs JSON. C'est la source de vérité. MarkdownEditor reste disponible en parallèle pour les usages markdown.

Format des blocs

Chaque bloc a un id (UUID) et un type. Selon le type :

type champs description
paragraph content texte
heading_1..6 content titre niveau 1 à 6
bullet_item content élément de liste à puces
numbered_item content élément de liste numérotée
checklist content, checked case à cocher + texte
quote content citation
code content bloc de code (monospace)
divider séparateur horizontal
image src, mediaSlug?, alt?, caption?, align, href, newTab image — voir Bloc image ; alignleft|center|right|full

content est un tableau InlineNode[] depuis Phase 2 — voir ci-dessous.

Phase 3 (à venir) : table.

Format InlineNode

Le contenu inline d'un bloc texte est un tableau plat de nœuds text. Chaque nœud porte optionnellement des marks (formatage).

[
  { type: 'text', text: 'Salut ' },
  { type: 'text', text: 'monde', marks: [{ type: 'bold' }] },
  { type: 'text', text: ' ' },
  { type: 'text', text: 'lien', marks: [{ type: 'link', href: 'https://…' }] },
]

Marks supportées

type payload rendu
bold <strong>
italic <em>
underline <u>
strike <s>
code <code> (monospace, fond gris)
link href: string <a href> (target="_blank")
color color: <key> | '#rrggbb' couleur du texte
highlight color: <key> | '#rrggbb' surlignage de fond

Le champ color accepte soit une clé de palette (blue, green, amber, red) — résolue vers les classes Tailwind du DESIGN system —, soit une string hex #rrggbb choisie par l'utilisateur, appliquée via style inline. Voir inline/types.js:INLINE_COLORS et isHexColor.

Le contenu vide est [] (jamais [{type:'text', text:''}]).

Helpers exportés

import {
  inlineLength, inlineToPlainText, inlineFromText,
  sliceInline, concatInline, applyMark, toggleMark, removeAllMarks,
  marksAtOffset, marksInRange, INLINE_COLORS,
  isHexColor, collectUsedColors,
  blocksToHtml, blocksToPlainText, htmlToBlocks,
} from '@zen/core/shared/components/BlockEditor';

Tous les helpers sont purs : ils retournent un nouveau tableau normalisé (nœuds adjacents identiques fusionnés, vides supprimés).

Props

<BlockEditor
  value={blocks}             // Block[]
  onChange={setBlocks}       // (Block[]) => void
  label, error, placeholder, disabled, className
  enabledBlocks={[...]}      // optionnel : restreindre les types disponibles
  minHeight={240}            // optionnel : hauteur minimum du conteneur (number = px, ou string CSS ex. '12rem')
/>

Interactions clavier

  • / → ouvre le menu de commandes (filtrable, ↑ ↓ Entrée pour valider, Échap pour fermer)
  • # → titre 1, ## → 2, …, ###### → 6
  • - → liste à puces
  • 1. → liste numérotée
  • [] ou [ ] → case à cocher
  • > → citation
  • ``` → bloc de code
  • --- → séparateur
  • Ctrl/Cmd + B → gras (sur sélection non vide)
  • Ctrl/Cmd + I → italique
  • Ctrl/Cmd + U → soulignement
  • Ctrl/Cmd + E → code inline
  • Ctrl/Cmd + K → lien (prompt pour l'URL)
  • Backspace au début d'un bloc typé → repasse en paragraphe ; au début d'un paragraphe, fusionne avec le bloc précédent (uniquement si la sélection est repliée — sinon le navigateur supprime le texte sélectionné, ex. après Ctrl+A)
  • Entrée sur un item de liste vide → sort de la liste
  • Ctrl/Cmd + Z / Ctrl/Cmd + Shift + Z → undo / redo
  • Ctrl/Cmd + A → 1er appui : sélectionne le contenu du bloc courant ; 2e appui : sélectionne tous les blocs (mode sélection multi-blocs)

Toolbar de formatage

Quand une sélection non-vide existe dans un bloc, un toolbar flottant apparaît au-dessus. Il propose :

  • B I U S </> — marks simples (toggle)
  • A — couleur du texte (sous-menu drop-down : palette par défaut + couleurs déjà utilisées dans le document + bouton + pour une couleur libre via <input type="color">)
  • — surlignage (même structure de sous-menu)
  • 🔗 — lien (sous-menu drop-down avec input URL ; ✕ pour retirer)
  • T/ — effacer tout le formatage de la sélection (supprime toutes les marks)

L'état actif est calculé à partir des marks communes à toute la plage (via marksInRange). Toggle off si toute la plage est déjà marquée.

La toolbar est rendue en position: absolute à l'intérieur du container relatif de l'éditeur et suit la sélection lors du scroll. Les sous-menus (couleur, surlignage, lien) s'ouvrent au clic sur leur trigger et restent ouverts jusqu'à un clic extérieur ou Escape — même idiome que le sous-menu Transformer ▸ du menu d'actions, mais en drop-down (vers le bas) plutôt qu'à droite. Une petite flèche après le glyphe principal indique la présence du sous-menu.

Popover de lien

Au simple clic dans un lien existant, un popover s'ouvre sous le <a> avec l'URL et la case « Ouvrir dans un nouvel onglet ». Il est rendu en position: absolute dans le container de l'éditeur et suit le lien lors du scroll.

Sélection multi-blocs

Deux façons d'entrer en mode sélection multi-blocs :

  • Souris : un drag qui traverse plusieurs blocs bascule automatiquement en sélection bloc (les contenteditables sont défocus, surlignage bleu transparent sur les blocs sélectionnés). Évite la fusion accidentelle de texte entre blocs lors d'un Backspace/Delete.
  • Clavier : double Ctrl/Cmd + A (cf. ci-dessus).

En mode sélection multi-blocs :

  • Backspace / Delete → supprime tous les blocs sélectionnés
  • Escape → quitte la sélection
  • Ctrl/Cmd + A → étend à tous les blocs (no-op si déjà tous sélectionnés)
  • Ctrl/Cmd + C / Ctrl/Cmd + X → copie/coupe les blocs sélectionnés au format HTML structuré (titres, gras, listes, citations, …) + fallback text/plain. Collable dans Word, Google Docs, Slack, ou un autre BlockEditor.
  • Ctrl/Cmd + V → remplace les blocs sélectionnés par le contenu HTML du presse-papier (parsé par htmlToBlocks).
  • frappe d'un caractère imprimable → remplace les blocs sélectionnés par un nouveau paragraphe contenant ce caractère
  • clic dans l'éditeur → quitte la sélection

Poignées au survol

Chaque bloc affiche au survol deux boutons à gauche, chacun ouvrant un dropdown manuel (state local + fermeture sur clic extérieur / Escape) :

  • Add01Icon (« + ») — ouvre le menu d'insertion : liste tous les types disponibles (filtrés par enabledBlocks) avec une icône en boîte. Le clic sur un type insère le bloc juste en dessous du bloc courant.
  • DragDropVerticalIcon — double rôle : presser-glisser pour réordonner (drag-and-drop HTML5 natif), simple clic pour ouvrir le menu d'actions.

Les icônes proviennent de src/shared/icons/index.js.

Menu d'actions du bloc

  • Transformer ▸ — sous-menu qui s'ouvre au clic (et se ferme sur clic extérieur ou Escape), listant les types de blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code) avec une icône en boîte. Cliquer sur un type remplace le bloc courant en conservant son contenu inline. L'item est masqué pour les blocs non-texte (image, séparateur). Le filtrage respecte la prop enabledBlocks.
  • Dupliquer — insère une copie du bloc juste en dessous (nouvel id).
  • Supprimer — retire le bloc (équivalent à Backspace au début d'un bloc vide), avec focus replacé sur le bloc précédent.

Le drag (mousedown + déplacement) et le clic (ouverture du menu) cohabitent sur le même bouton : si un dragstart réel se produit, un drapeau interne (justDraggedRef) supprime l'ouverture du menu lors du click qui suit. Sinon (clic sans déplacement), le menu s'ouvre normalement. Les dropdowns sont des composants maison (pas de Headless UI ici) car MenuButton ouvrait sur pointerdown, ce qui empêchait le clic-maintenu nécessaire au drag.

Identité visuelle partagée

Les quatre dropdowns (menu d'insertion, menu d'actions du bloc, toolbar de formatage, popover de lien) partagent les classes BOX_CLASS, ITEM_CLASS, ITEM_DANGER_CLASS, ICON_BTN_CLASS et SEPARATOR_CLASS exportées par inline/menuStyles.js. Pas de wrapper de composant — juste des constantes Tailwind, parce que les trois formes (liste verticale, barre horizontale, formulaire) sont trop différentes pour qu'une abstraction soit rentable.

Étendre — enregistrer un bloc custom

import { registerBlock, newBlockId } from '@zen/core/shared/components/BlockEditor';

registerBlock({
  type: 'kpi',
  label: 'KPI',
  icon: <ChartIcon width={18} height={18} />,
  keywords: ['kpi', 'metric', 'stat'],
  isText: false,
  create: () => ({ id: newBlockId(), type: 'kpi', value: 0 }),
  Component: ({ block, onChange, disabled }) => (
    <input
      type="number"
      value={block.value}
      disabled={disabled}
      onChange={(e) => onChange({ value: Number(e.target.value) })}
    />
  ),
});

Le nouveau type apparaît automatiquement dans le slash menu de tout <BlockEditor /> rendu après l'enregistrement. Pour les blocs texte, on fournit à la place isText: true, textTag, textClassName, et optionnellement renderPrefix({ block, onPatch, index, numberedIndex, disabled }) pour un préfixe (puce, numéro, case à cocher). Le content initial doit être [] (un InlineNode[] vide).

Architecture interne

BlockEditor.client.js     orchestrateur : value/onChange, undo, slash menu, drag-drop, toolbar
Block.client.js           wrapper d'un bloc : handles, contentEditable, paste sanitize
SlashMenu.client.js       menu flottant filtrable
blockRegistry.js          map type → définition, API publique d'extension
blockTypes/               un fichier par type built-in
inline/types.js           InlineNode[] : palette, helpers purs (slice, concat, marks)
inline/serialize.js       DOM ↔ InlineNode[] (inlineToDom / domToInline)
inline/Toolbar.client.js  barre flottante de formatage
utils/ids.js              UUID pour les blocs
utils/caret.js            gestion du caret (multi-Text-nodes)

Copier-coller avec formatage

Le presse-papier transporte deux MIME en parallèle : text/html (structure

  • formatage) et text/plain (fallback). Côté éditeur :
  • Copy / Cut : blocksToHtml(selected) produit un HTML standard (<h1>…<h6>, <p>, <ul>/<ol><li>, <blockquote>, <pre><code>, <hr>, <figure><img>). Pour les sélections multi-blocs (focus défocus), on passe par l'API async navigator.clipboard.write avec un ClipboardItem qui inclut les deux MIME.
  • Paste dans un bloc : Block.handlePaste lit d'abord la MIME propriétaire text/_notion-blocks-v3-production (parsée par notionJsonToBlocks) — Notion conserve ses types natifs (to_do, sub_sub_header, …) là où le HTML aplatit to_do en <ul><li>. À défaut, retombe sur text/html parsé par htmlToBlocks. Si un seul paragraphe est produit, son contenu inline est splicé au caret. Sinon le bloc courant est coupé en deux et les blocs collés sont insérés entre les deux moitiés (avec fusion tête/queue si extrémités = paragraphe).
  • Paste en sélection multi-blocs : navigator.clipboard.read() lit le HTML du presse-papier, le convertit, et remplace les blocs sélectionnés.

Tags HTML reconnus en entrée : <h1><h6>, <p>, <ul>/<ol><li>, <ul data-checklist>, <ul class="to-do-list"> (Notion), <ul class="task-list"> (GitHub-flavored markdown), ou <li> contenant <input type="checkbox"> ou <div class="checkbox checkbox-on/off"> (Notion), <blockquote>, <pre>, <code>, <hr>, <figure>, <img>, plus toutes les marks de domToInline (<strong>/<b>, <em>/<i>, <u>, <s>/<strike>/<del>, <code>, <a href>, <span data-color> / <span data-highlight>).

Les wrappers de Word / Google Docs (<b id=...>, <div> de mise en 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

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 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. Toute mise à jour 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), alt et caption vivent sur le bloc — il n'y a pas d'autre source possible.

Affichage et édition des métadonnées

Sous l'image, on n'affiche que la légende, et uniquement si elle existe. Pas de placeholder « Aucune légende », pas de bandeau gris, pas d'alt visible (l'alt vit sur l'attribut <img alt>). L'objectif est que le rendu en édition reflète exactement ce que verra le lecteur final.

L'édition des métadonnées passe par un bouton dédié dans la toolbar flottante de l'image (icône Settings02Icon) :

  • Image liée à la médiathèque → ouvre MediaDetailsModal, le même composant que celui de la page admin Médias (visibilité, alt, caption, URL, suppression). Toute édition met à jour zen_media directement.
  • Image URL externe → ouvre une modale légère avec deux champs (alt, caption) qui patchent le bloc local.

MediaDetailsModal est exposé en entry public @zen/core/features/media/details-modal et accepte soit media={media} (objet déjà chargé), soit slug="…" (le bloc image l'utilise — résolution via GET /zen/api/media/by-slug/:slug).

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 :

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é :

  • 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.
  • Métadonnées (Settings02Icon) : ouvre la modale d'édition alt/caption. Pour une image liée à la médiathèque, c'est MediaDetailsModal (édition directe du média en BD). Pour une URL externe, c'est une modale légère qui patche block.alt / block.caption.
  • Remplacer : remet le formulaire URL avec l'URL courante préremplie. Conserve mediaSlug, align, href. Échap pour annuler.
  • Supprimer : vide tous les champs ; le formulaire URL réapparaît.

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. 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

  • Pas d'imbrication de listes.
  • Image : insertion par URL externe ou via le MediaPicker du module media (les uploads y sont gérés). La caption est une string plate (pas de formatage inline pour l'instant).
  • Tables : Phase 3.