BlockEditor

Éditeur de contenu par blocs, format des blocs, nœuds inline, props, interactions clavier et blocs intégrés.

Éditeur WYSIWYG par blocs, construit en interne, sans ProseMirror, Lexical ni Tiptap. Un contentEditable par bloc, pas un seul global : c'est plus robuste et plus simple à étendre.

Pour ajouter un type de bloc, voir Blocs personnalisés. Pour le rendu public, voir Registre de blocs.

Import

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

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

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

Props

<BlockEditor
  value={blocks}             // Block[]
  onChange={setBlocks}       // (Block[]) => void
  label error placeholder disabled className
  enabledBlocks={[...]}      // restreint les types disponibles
  minHeight={240}            // number en px, ou chaîne CSS ('12rem')
  nested                     // interne — instance rendue dans une colonne
/>

nested est un détail d'implémentation du bloc colonnes. Les intégrations applicatives ne l'utilisent pas.

Format des blocs

Chaque bloc porte un id (UUID) et un type.

TypeChampsDescription
paragraphcontent, size?Texte. size vaut sm, md ou lg, défaut md. Voir Taille du paragraphe.
heading_1 à heading_6contentTitre de niveau 1 à 6.
bullet_itemcontentÉlément de liste à puces.
numbered_itemcontentÉlément de liste numérotée.
checklistcontent, checkedCase à cocher et texte.
quotecontentCitation.
codecontentBloc de code, monospace.
divider—Séparateur horizontal.
spacerheightEspace vertical vide, en px, borné 4 à 200, défaut 24. Voir Bloc espaceur.
imagesrc?, mediaId?, alt?, caption?, align, size, aspectRatio, href, newTabVoir Bloc image.
videourl, autoplay, mute, loop, hideControls, startTime, align, size, aspectRatio, caption?Vidéo YouTube. Voir Bloc vidéo.
columnsgap, columnsContenu sur plusieurs colonnes. Voir Bloc colonnes.

Valeurs des champs partagés :

ChampValeursDéfaut
alignleft, center, right, full—
sizes, m, l, fullfull
aspectRatio (image)auto, 1/1, 4/3, 3/2, 16/9, 3/4, 9/16auto
aspectRatio (vidéo)16/9, 4/3, 1/1, 9/1616/9
gapsm, md, lgmd

Champ transverse anchor. N'importe quel bloc peut porter un anchor, un slug comme "nos-services". Au rendu public il devient l'attribut id de l'élément racine du bloc, cible d'un lien #nos-services. Voir Ancre de bloc.

Format InlineNode

Le contenu inline d'un bloc texte est un tableau plat de nœuds text, chacun portant optionnellement des marks.

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

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

Marks

TypeCharge utileRendu
bold—<strong>
italic—<em>
underline—<u>
strike—<s>
code—<code>
linkhref: string<a href> avec target="_blank"
colorcolorCouleur du texte.
highlightcolorSurlignage de fond.

Le champ color accepte soit une clé de palette (blue, green, amber, red), résolue vers les classes du design system, soit une chaîne hexadécimale #rrggbb choisie par l'utilisateur, appliquée en style inline.

Sauts de ligne souples

Enter crée un nouveau bloc. Shift + Enter insère un saut de ligne souple dans le même bloc.

Le <br> du contentEditable est sérialisé en \n dans le text du nœud, puis re-rendu en <br> dans l'éditeur. Au rendu public, renderInline reconvertit chaque \n en <br /> : sans quoi le HTML écraserait le saut en simple espace. Un <Content> personnalisé qui réutilise renderInline hérite de ce comportement.

Helpers exportés

import {
  inlineLength, inlineToPlainText, inlineFromText,
  sliceInline, concatInline, applyMark, toggleMark, removeMark, removeAllMarks,
  marksAtOffset, marksInRange, normalizeInline, INLINE_COLORS, INLINE_COLOR_KEYS,
  isHexColor, collectUsedColors,
  inlineToDom, domToInline,
  blocksToHtml, blocksToClipboardHtml, blocksToPlainText, htmlToBlocks,
  writeBlocksToClipboard, newBlockId,
} from '@lrvo/core/shared/components/block-editor';

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

Cette sous-entrée ne tire pas le composant d'édition. C'est ce qui permet à un rendu public d'appeler blocksToHtml côté serveur sans embarquer l'éditeur dans son graphe. Pour enregistrer un type de bloc, passer par @lrvo/core/blocks-registry/client.

Interactions clavier

RaccourciEffet
/Ouvre le menu de commandes, filtrable. ↑ ↓ Entrée pour valider, Échap pour fermer.
# à ###### Titre 1 à 6.
- Liste à puces.
1. Liste numérotée.
[] ou [ ] Case à cocher.
> Citation.
```Bloc de code.
---Séparateur.
Ctrl/Cmd + B I U EGras, italique, souligné, code inline, sur sélection non vide.
Ctrl/Cmd + KLien.
Backspace en début de bloc typéRepasse en paragraphe. En début de paragraphe, fusionne avec le bloc précédent, uniquement si la sélection est repliée.
Entrée sur un item de liste videSort de la liste.
Shift + EntréeSaut de ligne souple.
Ctrl/Cmd + Z / Ctrl/Cmd + Shift + ZAnnuler, rétablir.
Ctrl/Cmd + APremier appui : sélectionne le contenu du bloc. Deuxième : sélectionne tous les blocs.

Toolbar de formatage

Sur sélection non vide, une barre flottante apparaît au-dessus du bloc.

BoutonEffet
B I U S </>Marks simples, en bascule.
ACouleur du texte. Sous-menu : palette par défaut, couleurs déjà utilisées dans le document, et un + pour une couleur libre.
◐Surlignage, même structure.
🔗Lien. Sous-menu avec champ URL, ✕ pour retirer.
T/Efface tout le formatage de la sélection.

L'état actif est calculé à partir des marks communes à toute la plage, via marksInRange. La bascule désactive quand toute la plage est déjà marquée.

La barre est en position: absolute dans le conteneur relatif de l'éditeur et suit la sélection au défilement. Les sous-menus s'ouvrent au clic et restent ouverts jusqu'à un clic extérieur ou Échap.

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

Un clic exactement à la fin d'un lien n'ouvre pas le popover : c'est le geste « placer le curseur après le lien pour continuer à taper ». marksAtOffset rattachant l'offset de fin au dernier nœud, un lien en dernière position capturerait sinon sa propre frontière.

Le focus du champ passe par focus({ preventScroll: true }) après positionnement. autoFocus aurait focalisé le champ encore à { top: 0 }, ce qui aurait fait remonter le conteneur.

Sélection multi-blocs

Deux entrées possibles :

  • Souris : un glissement qui traverse plusieurs blocs bascule automatiquement en sélection de blocs. Les contentEditable sont défocalisés et les blocs sélectionnés reçoivent un surlignage. Cela évite la fusion accidentelle de texte entre blocs sur Backspace.
  • Clavier : double Ctrl/Cmd + A.

En mode sélection multi-blocs :

ActionEffet
Backspace / DeleteSupprime les blocs sélectionnés.
ÉchapQuitte la sélection.
Ctrl/Cmd + AÉtend à tous les blocs.
Ctrl/Cmd + C / XCopie ou coupe au format HTML structuré, avec repli text/plain. Collable dans Word, Google Docs, Slack ou un autre BlockEditor.
Ctrl/Cmd + VRemplace les blocs sélectionnés par le contenu du presse-papier.
Caractère imprimableRemplace les blocs sélectionnés par un paragraphe contenant ce caractère.
Clic dans l'éditeurQuitte la sélection.

L'ouverture du menu d'actions d'un bloc sélectionne visuellement ce seul bloc mais ne défocalise pas le contentEditable et n'efface pas la sélection native. Le gestionnaire clavier global suffit à intercepter les touches. Cela contourne un comportement de Chrome où removeAllRanges() suivi de blur() sur un contentEditable récent fait que le clic suivant pose le caret au début du bloc au lieu de la position cliquée.

Poignée et insertion

Chaque bloc affiche au survol une poignée à gauche, DragDropVerticalIcon, à double rôle : presser-glisser pour réordonner, simple clic pour ouvrir le menu d'actions.

Il n'y a pas de bouton + dans la poignée. L'insertion se fait par la ligne d'insertion.

Ligne d'insertion entre blocs

Au survol de l'interstice entre deux blocs, et tout en bas de l'éditeur, une fine ligne se révèle avec un bouton +. Le clic ouvre le menu d'insertion, filtré par enabledBlocks, et insère le type choisi à cette position. C'est le seul point d'insertion de bloc à la souris.

Un inséreur est rendu à chaque frontière, plus un dernier après le dernier bloc. Le composant n'occupe aucune hauteur de flux : la zone de survol et la ligne sont en position: absolute et débordent symétriquement de 6 px dans le padding des blocs voisins, donc elles ne recouvrent jamais le texte d'un contentEditable.

La ligne est masquée quand l'éditeur est disabled, et fonctionne dans les colonnes avec l'enabledBlocks de l'instance imbriquée.

EntréeEffet
TailleSegmented control en haut du menu, pose block.size parmi def.sizes. Affiché seulement pour les blocs dont la définition fournit sizes. La sélection est immédiate et ne ferme pas le menu.
Transformer ▸Sous-menu listant les types de blocs texte disponibles. Remplace le bloc en conservant son contenu inline. Masqué pour les blocs non textuels. Respecte enabledBlocks.
AncreMini-formulaire qui pose block.anchor. La saisie est slugifiée en direct et un aperçu #slug est affiché. Disponible pour tous les types.
CopierCopie ce seul bloc, toutes ses données comprises, via writeBlocksToClipboard([block]).
DupliquerInsère une copie juste en dessous, avec un nouvel id. L'anchor n'est pas copiée : une ancre est unique.
SupprimerRetire le bloc et replace le focus sur le précédent.

Le glissement et le clic cohabitent sur le même bouton : si un dragstart réel se produit, un drapeau interne supprime l'ouverture du menu lors du click qui suit. Les dropdowns sont des composants maison plutôt que Headless UI, parce que MenuButton ouvre sur pointerdown, ce qui empêche le clic maintenu nécessaire au glissement.

L'attribut draggable du bloc est activé au mousedown de la poignée et remis à false au prochain mouseup global, en plus du reset dans dragend. Sans ce reset, un simple clic laisserait draggable=true indéfiniment, et un ancêtre draggable d'un contentEditable casse le placement du caret au clic dans Chrome.

Vue compacte pendant le glissement

Tant qu'un déplacement est en cours, les blocs non textuels (image, colonnes, séparateur, espaceur, blocs personnalisés) remplacent leur contenu par une boîte basse affichant l'icône et le libellé. La liste se compacte, ce qui rend le réordonnancement des blocs hauts praticable. Les blocs texte ne sont pas affectés.

Copier-coller avec formatage

Le presse-papier transporte deux types MIME en parallèle : text/html pour la structure et le formatage, text/plain en repli.

Copie

blocksToClipboardHtml(selected) produit du HTML standard (<h1> à <h6>, <p>, <ul> / <ol>, <blockquote>, <pre><code>, <hr>, <div data-spacer>, <figure><img>) préfixé d'un marqueur masqué :

<div data-lrvo-blocks="…" style="display:none">

Ce marqueur porte un instantané JSON complet des blocs : taille de paragraphe, mediaId et aspectRatio d'image, largeurs de colonnes. Il permet un aller-retour interne sans perte, là où le HTML seul aplatit ces données. Les destinations externes ignorent l'élément masqué et lisent le HTML propre.

Le marqueur est un élément data-* et non un commentaire HTML : le sanitizer du presse-papier de Chromium retire les commentaires à l'écriture asynchrone, mais conserve les éléments.

Collage

  • Dans un bloc : le gestionnaire lit d'abord le type MIME propriétaire de Notion (text/_notion-blocks-v3-production), qui conserve les types natifs là où le HTML aplatit une case à cocher en <ul><li>. À défaut, il retombe sur text/html. Si un seul paragraphe est produit, son contenu inline est inséré au caret. Sinon le bloc courant est coupé en deux et les blocs collés s'insèrent entre les moitiés.
  • En sélection multi-blocs : navigator.clipboard.read() lit le HTML, le convertit, et remplace les blocs sélectionnés.

Décodage du marqueur

Avant tout parsing HTML, htmlToBlocks cherche [data-lrvo-blocks]. Si le marqueur est présent, l'instantané JSON est décodé puis validé : tableau borné, chaque type connu du registre, colonnes vérifiées récursivement. Le HTML collé étant une donnée entrante suspecte, un marqueur forgé ou corrompu fait simplement retomber sur le parsing HTML standard.

Les blocs décodés reçoivent toujours des identifiants frais, y compris ceux des colonnes et des blocs imbriqués, et leur ancre est supprimée à chaque niveau.

Tags reconnus en entrée

<h1> à <h6>, <p>, <ul> / <ol><li>, <ul data-checklist>, <ul class="to-do-list"> (Notion), <ul class="task-list"> (markdown GitHub), un <li> contenant <input type="checkbox">, <blockquote>, <pre>, <code>, <hr>, <div data-spacer>, <div data-video>, <figure>, <img>, plus toutes les marks (<strong>, <b>, <em>, <i>, <u>, <s>, <strike>, <del>, <code>, <a href>, <span data-color>, <span data-highlight>).

Les wrappers de Word et Google Docs sont traversés pour atteindre les éléments block-level descendants. Les styles CSS inline (font-weight, font-style, text-decoration) sont lus en plus des balises sémantiques.

Bloc image

Le formulaire d'insertion accepte deux sources : une URL externe, ou la bibliothèque média via le MediaPicker du registre média. Sans @lrvo/module-medias, le bouton « Choisir un média » est masqué et l'insertion par URL reste disponible.

Liaison par mediaId

Quand un bloc est lié à un média, le média est la source unique de vérité pour alt, caption et le filename de l'URL. Le bloc ne stocke aucune de ces valeurs en base. Une mise à jour côté médiathèque, y compris un renommage de fichier, se répercute immédiatement sur tous les blocs qui référencent ce média. C'est l'intérêt de stocker l'identifiant, immuable, plutôt que le filename, mutable.

Pour une URL externe, src, alt et caption vivent sur le bloc : il n'y a pas d'autre source.

Champs internes au composant, préfixés _, jamais persistés :

ChampOrigine
_mediaAlt, _mediaCaption, _mediaFilenameInstantané capté à l'insertion, pour un affichage immédiat sans appel réseau.
_resolvedAlt, _resolvedCaption, _resolvedFilenameInjectés au rendu serveur par enrichBlocksWithMedia.

Affichage

Sous l'image, seule la légende est affichée, et seulement si elle existe. Pas de placeholder, pas de bandeau, pas d'alt visible : l'alt vit sur l'attribut <img alt>. Le rendu en édition reflète donc exactement ce que verra le lecteur.

Toolbar de l'image

ContrôleEffet
TailleS 25 %, M 50 %, L 75 %, F 100 %. Persisté dans block.size. Contrôle la largeur du conteneur. Désactivé quand align === 'full'.
AlignementGauche, centre, droite, pleine largeur. Persisté dans block.align. Contrôle le positionnement via justify-content. 'full' force 100 % et ignore size.
FormatRatio courant parmi Auto, 1:1, 4:3, 3:2, 16:9, 3:4, 9:16. Hors auto, l'image passe en object-cover avec le ratio CSS demandé : elle remplit le conteneur en recadrant le débordement. Le ratio est appliqué à l'identique dans l'éditeur, le rendu serveur et le <Content>.
LienChamp URL et case « nouvel onglet ». Persistés dans block.href et block.newTab.
MétadonnéesModale d'édition. Pour une image liée, c'est MediaDetailsModal, le même composant que la page admin Médias. Pour une URL externe, une modale légère qui patche le bloc.
RemplacerRéaffiche le formulaire URL prérempli. Conserve mediaId, align, href.
SupprimerVide tous les champs.

Côté éditeur, l'image n'est jamais enveloppée dans un <a>, pour ne pas piéger les clics. En mode disabled et à l'export HTML, elle l'est, avec target="_blank" rel="noopener noreferrer" quand newTab est vrai.

Sérialisation

<figure data-align="…"><a href><img></a><figcaption></figcaption></figure>

Le parseur inverse lit data-align et descend dans le <a> quand l'<img> y est imbriqué. Si le src matche /lrvo/media/<filename>, le filename est conservé dans _mediaFilename ; la normalisation serveur le résout ensuite en mediaId pour rétablir la liaison lors d'un copier-coller interne.

Helpers serveur

Trois helpers à appeler depuis les actions et routes du module qui héberge le contenu. Voir Registre média.

import {
  normalizeImageBlocks, enrichBlocksWithMedia, syncBlockImageReferences,
} from '@lrvo/core/media-registry/block-editor';

// à l'enregistrement
const normalized = await normalizeImageBlocks(blocks);
await persistDocument(documentId, normalized);
await syncBlockImageReferences({
  sourceType: 'post',
  sourceId: documentId,
  blocks: normalized,
});

// au rendu
const enriched = await enrichBlocksWithMedia(await loadDocument(slug));

normalizeImageBlocks fait trois choses : montée de version paresseuse (si mediaId manque mais que src matche /lrvo/media/<filename>, résout le filename en média et persiste l'identifiant) ; remplissage arrière (si le média n'a pas encore d'alt_text ou de caption mais que le bloc en porte, remonte la valeur côté média) ; nettoyage strict (retire src, alt et caption du bloc dès qu'un mediaId est résolu).

enrichBlocksWithMedia fait une seule requête groupée pour tous les mediaId du document et attache les champs _resolved*.

syncBlockImageReferences synchronise la table des références avec field = 'block_image'. La contrainte ON DELETE RESTRICT empêche ensuite de supprimer un média référencé tant que le document hôte existe. Appeler detachAllForSource à la suppression du document.

Bloc vidéo

Insère une vidéo YouTube via l'iframe d'embed officielle.

{
  id, type: 'video',
  url: 'https://www.youtube.com/watch?v=…',  // URL saisie, préservée telle quelle
  autoplay: false,
  mute: false,
  loop: false,             // force playlist=<id> dans l'embed, sinon inopérant
  hideControls: false,     // controls=0
  startTime: 0,            // secondes, 0 est ignoré
  align: 'center',
  size: 'full',            // ignoré si align='full'
  aspectRatio: '16/9',     // pas d'auto : c'est une iframe
  caption: '',
}
  • Insertion par le menu de commandes (/vidéo, /youtube) ou la ligne d'insertion. L'identifiant est extrait par une regex stricte [A-Za-z0-9_-]{11} ; les formats watch?v=, youtu.be/, embed/, shorts/ et live/ sont reconnus. Une URL invalide désactive le bouton d'insertion.
  • L'URL n'est pas transformée : on stocke la saisie, et l'URL d'embed est reconstruite à chaque rendu. Pas d'état dupliqué.
  • Overlay d'édition : un overlay transparent est posé au-dessus de l'iframe en édition. Sans lui, l'iframe YouTube capte tous les clics et le bloc devient impossible à sélectionner ou déplacer. Il est retiré en mode disabled.
  • loop exige playlist=<id> côté YouTube pour boucler sur un seul clip ; c'est ajouté automatiquement.
  • autoplay est bloqué par les navigateurs quand le son est actif. Activer mute en complément.
  • Sécurité : la regex stricte garantit que le src de l'iframe ne peut pas être manipulé par une URL forgée.

Sérialisation : un <div data-video> portant tous les attributs, relu intégralement par htmlToBlocks. Les destinations externes voient un conteneur vide, une vidéo n'a pas de représentation HTML portable.

Collage d'une iframe YouTube : coller le code d'intégration officiel crée automatiquement un bloc vidéo, en récupérant les paramètres présents dans la query string. Les iframes non YouTube sont silencieusement ignorées : on ne crée jamais un bloc vidéo à partir d'un embed arbitraire.

Bloc espaceur

Insère une hauteur verticale vide. Champ persisté : height, un nombre en pixels.

  • Insertion par le menu de commandes (/espaceur) ou la ligne d'insertion. Pas de raccourci markdown.
  • Édition : au survol, une mini-barre centrée propose des préréglages (8, 16, 24, 48, 64 px) et un champ numérique libre. Le champ utilise un brouillon local validé au blur ou à Entrée, ce qui évite que la borne minimale morde au premier chiffre d'une saisie à plusieurs chiffres. Un contour pointillé matérialise le bloc au survol.
  • Bornes : clampSpacerHeight borne à 4–200 px, défaut 24. C'est la source de vérité de la validation ; le rendu public et la sérialisation se contentent d'une coercition défensive vers un nombre positif.
  • Rendu : un <div aria-hidden="true" style="height:Npx"> vide.

Sérialisation : <div data-spacer="N" aria-hidden="true" style="height:Npx"></div>. Sans l'attribut data-spacer, un <div> reste traité comme un wrapper de mise en page venant de Word ou Google Docs.

Bloc colonnes

Pose du contenu sur plusieurs colonnes horizontales.

{
  id, type: 'columns',
  gap: 'sm' | 'md' | 'lg',
  columns: [
    { id, width: 50, blocks: [ /* Block[] */ ] },
    { id, width: 50, blocks: [ /* Block[] */ ] },
  ],
}
  • Architecture : chaque colonne est un <BlockEditor nested> autonome qui gère son propre sous-tableau. Toute la machinerie d'édition est réutilisée par colonne sans réécrire la logique de tableau plat, donc n'importe quel bloc peut vivre dans une colonne.
  • width est un poids de répartition (flex-grow) : 50/50 donne des colonnes égales, 70/30 rend la première deux fois plus large.
  • gap est mappé vers des classes Tailwind dans columns-layout, source de vérité partagée entre l'éditeur et le rendu public.
  • Responsive : en ligne dès le point de rupture md, empilé en pleine largeur en dessous.
  • 2 à 4 colonnes, ajout et suppression par la mini-barre, qui remet la répartition à égalité.
  • Pas d'imbrication : une colonne n'expose pas « Colonnes » dans ses menus. Pour une disposition plus complexe, la bonne réponse est un bloc personnalisé dédié, pas une imbrication.

Limites de portée entre colonnes, inhérentes au modèle « un éditeur par colonne » : le glisser-déposer, la multi-sélection, la navigation au clavier et l'historique ne franchissent pas la frontière d'une colonne. Chaque colonne est un scope d'édition isolé.

Mode nested : supprime le chrome (label, bordure, padding, minHeight) et borne les écouteurs document à la sous-arborescence de l'instance. Sans ce garde, l'éditeur parent et la colonne réagiraient tous deux au même évènement.

Sérialisation : <div data-columns data-gap="md"> contenant un <div data-column data-width="50"> par colonne, dont l'innerHTML est le HTML récursif du sous-tableau.

Taille du paragraphe

Le bloc paragraph propose trois tailles dans block.size : sm, md (défaut) et lg. Un bloc sans size est traité comme md, donc aucune migration des contenus existants n'est nécessaire.

  • Édition : segmented control Taille en haut du menu d'actions.
  • Rendu dans l'éditeur : la classe du contentEditable est calculée par def.getTextClassName(block), qui prend le pas sur textClassName statique.
  • Générique : le menu ne contient aucune logique propre au paragraphe. Il s'appuie sur def.sizes et def.defaultSize. Tout futur bloc déclarant ces champs obtient le sélecteur automatiquement.
  • Rendu public : géré par le template consommateur, qui mappe block.size vers une échelle Tailwind. Un <Content> personnalisé qui réécrit le rendu du paragraphe doit lire block.size lui-même.

Ancre de bloc

Tout bloc peut recevoir une ancre, un slug stable qui sert de cible à un lien #slug : table des matières, navigation intra-page.

  • Édition : menu d'actions ▸ Ancre. Le slug est normalisé (minuscules, accents retirés, tout caractère non alphanumérique en tiret, tirets de bord supprimés). Stocké dans block.anchor ; vider le champ retire l'ancre.
  • Stabilité : l'ancre est choisie à la main et ne change jamais quand on édite le texte du bloc, donc les liens ne cassent pas. C'est précisément ce que le choix manuel garantit, contrairement à un slug auto-dérivé du titre.
  • Unicité : la duplication d'un bloc n'hérite pas de l'ancre, et le copier-coller non plus.
  • Repère dans l'éditeur : un badge #slug vertical dans la gouttière droite. Aide d'édition seulement, masquée en mode disabled et non interactive.

Rendu public

Le rendu n'est pas fait par le BlockEditor. L'ancre est appliquée via anchorProps(block), qui retourne { id: block.anchor } ou null, à étaler sur l'élément racine :

import { anchorProps } from '@lrvo/core/blocks-registry/render';

<h2 {...anchorProps(block)} className="…">{renderInline(block.content)}</h2>

BlockListRenderer et le <Content> du starter l'appliquent déjà sur tous les blocs intégrés, y compris chaque <li>. Un <Content> personnalisé qui réécrit le rendu par bloc doit l'étaler lui-même.

Pour un bloc personnalisé, le render.js contrôle son propre rendu et doit poser l'ancre. Comme un render.js peut porter 'use client' alors que le module de rendu est serveur strict, on l'écrit en inline :

export default function MonBlocRender({ block }) {
  return <section id={block.anchor || undefined}>…</section>;
}

Export HTML

blocksToHtml émet l'ancre comme attribut id sur l'élément racine. Le parseur inverse ne relit pas cet id, ce qui évite de dupliquer une ancre lors d'un copier-coller interne.

Architecture interne

BlockEditor.client.js     orchestrateur : value/onChange, historique, menu de commandes, glisser-déposer, toolbar
Block.client.js           wrapper d'un bloc : poignées, contentEditable, nettoyage du collage
BlockInserter.client.js   ligne d'insertion au survol de l'interstice
insertMenu.client.js      panneau de types insérables et placement du dropdown
SlashMenu.client.js       menu flottant filtrable
blockRegistry.js          map type → définition, API publique d'extension
blockTypes/               un fichier par type intégré
inline/types.js           InlineNode[] : palette, helpers purs
inline/serialize.js       DOM ↔ InlineNode[]
inline/Toolbar.client.js  barre flottante de formatage
utils/ids.js              UUID des blocs
utils/caret.js            gestion du caret sur plusieurs nœuds texte

Les quatre dropdowns (menu d'insertion, menu d'actions, toolbar de formatage, popover de lien) partagent les classes BOX_CLASS, ITEM_CLASS, ITEM_DANGER_CLASS, ICON_BTN_CLASS et SEPARATOR_CLASS de inline/menuStyles.js. Ce sont des constantes Tailwind sans composant enveloppant : les trois formes (liste verticale, barre horizontale, formulaire) sont trop différentes pour qu'une abstraction soit rentable.

Type non enregistré

Si getBlockDef(block.type) ne trouve pas de définition (module désactivé, bloc custom: dont l'extension n'est plus chargée) le bloc reste un bloc à part entière : wrapper normal, poignée de glissement, indicateur de dépôt, lignes d'insertion. Seule la zone de contenu est remplacée par un encart d'avertissement non éditable portant un bouton Supprimer. Le sous-menu Transformer est masqué, le contenu n'étant pas exploitable, mais Dupliquer et Supprimer restent disponibles.

Limitations connues

  • Pas d'imbrication de listes.
  • La légende d'image est une chaîne plate, sans formatage inline.
  • Colonnes : horizontales uniquement, 2 à 4, sans imbrication. Le glisser-déposer, la multi-sélection et l'historique ne franchissent pas la frontière d'une colonne. Une colonne n'hérite pas de l'enabledBlocks restreint du parent : elle expose tous les blocs sauf columns.

Sur cette page