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.
| Type | Champs | Description |
|---|---|---|
paragraph | content, size? | Texte. size vaut sm, md ou lg, défaut md. Voir Taille du paragraphe. |
heading_1 à heading_6 | content | Titre de 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 et texte. |
quote | content | Citation. |
code | content | Bloc de code, monospace. |
divider | — | Séparateur horizontal. |
spacer | height | Espace vertical vide, en px, borné 4 à 200, défaut 24. Voir Bloc espaceur. |
image | src?, mediaId?, alt?, caption?, align, size, aspectRatio, href, newTab | Voir Bloc image. |
video | url, autoplay, mute, loop, hideControls, startTime, align, size, aspectRatio, caption? | Vidéo YouTube. Voir Bloc vidéo. |
columns | gap, columns | Contenu sur plusieurs colonnes. Voir Bloc colonnes. |
Valeurs des champs partagés :
| Champ | Valeurs | Défaut |
|---|---|---|
align | left, center, right, full | — |
size | s, m, l, full | full |
aspectRatio (image) | auto, 1/1, 4/3, 3/2, 16/9, 3/4, 9/16 | auto |
aspectRatio (vidéo) | 16/9, 4/3, 1/1, 9/16 | 16/9 |
gap | sm, md, lg | md |
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
| Type | Charge utile | Rendu |
|---|---|---|
bold | — | <strong> |
italic | — | <em> |
underline | — | <u> |
strike | — | <s> |
code | — | <code> |
link | href: string | <a href> avec target="_blank" |
color | color | Couleur du texte. |
highlight | color | Surlignage 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
| Raccourci | Effet |
|---|---|
/ | 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 E | Gras, italique, souligné, code inline, sur sélection non vide. |
Ctrl/Cmd + K | Lien. |
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 vide | Sort de la liste. |
Shift + Entrée | Saut de ligne souple. |
Ctrl/Cmd + Z / Ctrl/Cmd + Shift + Z | Annuler, rétablir. |
Ctrl/Cmd + A | Premier 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.
| Bouton | Effet |
|---|---|
B I U S </> | Marks simples, en bascule. |
| A | Couleur 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
contentEditablesont défocalisés et les blocs sélectionnés reçoivent un surlignage. Cela évite la fusion accidentelle de texte entre blocs surBackspace. - Clavier : double
Ctrl/Cmd + A.
En mode sélection multi-blocs :
| Action | Effet |
|---|---|
Backspace / Delete | Supprime les blocs sélectionnés. |
Échap | Quitte la sélection. |
Ctrl/Cmd + A | Étend à tous les blocs. |
Ctrl/Cmd + C / X | Copie ou coupe au format HTML structuré, avec repli text/plain. Collable dans Word, Google Docs, Slack ou un autre BlockEditor. |
Ctrl/Cmd + V | Remplace les blocs sélectionnés par le contenu du presse-papier. |
| Caractère imprimable | Remplace les blocs sélectionnés par un paragraphe contenant ce caractère. |
| Clic dans l'éditeur | Quitte 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.
Menu d'actions du bloc
| Entrée | Effet |
|---|---|
| Taille | Segmented 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. |
| Ancre | Mini-formulaire qui pose block.anchor. La saisie est slugifiée en direct et un aperçu #slug est affiché. Disponible pour tous les types. |
| Copier | Copie ce seul bloc, toutes ses données comprises, via writeBlocksToClipboard([block]). |
| Dupliquer | Insère une copie juste en dessous, avec un nouvel id. L'anchor n'est pas copiée : une ancre est unique. |
| Supprimer | Retire 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 surtext/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 :
| Champ | Origine |
|---|---|
_mediaAlt, _mediaCaption, _mediaFilename | Instantané capté à l'insertion, pour un affichage immédiat sans appel réseau. |
_resolvedAlt, _resolvedCaption, _resolvedFilename | Injecté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ôle | Effet |
|---|---|
| Taille | S 25 %, M 50 %, L 75 %, F 100 %. Persisté dans block.size. Contrôle la largeur du conteneur. Désactivé quand align === 'full'. |
| Alignement | Gauche, centre, droite, pleine largeur. Persisté dans block.align. Contrôle le positionnement via justify-content. 'full' force 100 % et ignore size. |
| Format | Ratio 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>. |
| Lien | Champ URL et case « nouvel onglet ». Persistés dans block.href et block.newTab. |
| Métadonnées | Modale 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. |
| Remplacer | Réaffiche le formulaire URL prérempli. Conserve mediaId, align, href. |
| Supprimer | Vide 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 formatswatch?v=,youtu.be/,embed/,shorts/etlive/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. loopexigeplaylist=<id>côté YouTube pour boucler sur un seul clip ; c'est ajouté automatiquement.autoplayest bloqué par les navigateurs quand le son est actif. Activermuteen complément.- Sécurité : la regex stricte garantit que le
srcde 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 :
clampSpacerHeightborne à 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. widthest un poids de répartition (flex-grow) :50/50donne des colonnes égales,70/30rend la première deux fois plus large.gapest mappé vers des classes Tailwind danscolumns-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
contentEditableest calculée pardef.getTextClassName(block), qui prend le pas surtextClassNamestatique. - Générique : le menu ne contient aucune logique propre au paragraphe. Il s'appuie sur
def.sizesetdef.defaultSize. Tout futur bloc déclarant ces champs obtient le sélecteur automatiquement. - Rendu public : géré par le template consommateur, qui mappe
block.sizevers une échelle Tailwind. Un<Content>personnalisé qui réécrit le rendu du paragraphe doit lireblock.sizelui-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
#slugvertical dans la gouttière droite. Aide d'édition seulement, masquée en modedisabledet 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 texteLes 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'
enabledBlocksrestreint du parent : elle expose tous les blocs saufcolumns.