feat(BlockEditor): add inline formatting with rich content model

- migrate block content from plain strings to InlineNode[] structure
- add inline toolbar (bold, italic, code, color, link) on text selection
- add checklist block type with toggle support
- add image block type (URL-based, phase 2)
- add inline serialization helpers (inlineToDom, domToInline)
- add inline types and length utilities
- extend caret utils with range get/set support
- update block registry and all existing block types for new content model
- update demo blocks in ComponentsPage to use rich inline content
- update README to reflect new architecture
This commit is contained in:
2026-04-25 18:27:20 -04:00
parent 3eeaebfa68
commit 5a8d2ad02f
19 changed files with 1244 additions and 126 deletions
@@ -0,0 +1,210 @@
'use client';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { INLINE_COLORS, INLINE_COLOR_KEYS, markKey } from './types.js';
// Toolbar flottant de formatage. Affiché tant qu'une sélection non-vide
// existe dans un bloc. Ancré au-dessus du rect de sélection ; flip en
// dessous si pas assez de place.
//
// Ne contient pas d'état métier — tous les changements remontent via
// `onToggleMark(mark)`. Le parent recalcule `activeMarks` à chaque rendu.
const TOOLBAR_HEIGHT = 36;
const TOOLBAR_GAP = 8;
const VIEWPORT_MARGIN = 8;
const SIMPLE_BUTTONS = [
{ type: 'bold', label: 'B', title: 'Gras (Ctrl+B)', className: 'font-bold' },
{ type: 'italic', label: 'I', title: 'Italique (Ctrl+I)', className: 'italic' },
{ type: 'underline', label: 'U', title: 'Soulignement (Ctrl+U)', className: 'underline' },
{ type: 'strike', label: 'S', title: 'Barré', className: 'line-through' },
{ type: 'code', label: '</>', title: 'Code (Ctrl+E)', className: 'font-mono text-[11px]' },
];
export default function InlineToolbar({ rect, activeMarks, onToggleMark }) {
const ref = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
const [popover, setPopover] = useState(null); // 'color' | 'highlight' | 'link' | null
const [linkUrl, setLinkUrl] = useState('');
useLayoutEffect(() => {
if (!rect || typeof window === 'undefined') return;
const width = ref.current?.offsetWidth ?? 280;
const height = ref.current?.offsetHeight ?? TOOLBAR_HEIGHT;
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceAbove = rect.top - VIEWPORT_MARGIN;
const flipBelow = spaceAbove < height + TOOLBAR_GAP;
let top = flipBelow
? rect.bottom + TOOLBAR_GAP
: rect.top - height - TOOLBAR_GAP;
let left = rect.left + rect.width / 2 - width / 2;
if (left + width + VIEWPORT_MARGIN > vw) left = vw - width - VIEWPORT_MARGIN;
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN;
if (top + height + VIEWPORT_MARGIN > vh) top = vh - height - VIEWPORT_MARGIN;
setPos({ top, left, flipped: flipBelow });
}, [rect]);
// Ferme un popover ouvert quand la sélection change (rect change).
useEffect(() => { setPopover(null); }, [rect?.top, rect?.left]);
function isActive(type, payloadKey) {
if (!Array.isArray(activeMarks)) return false;
if (payloadKey) return activeMarks.some(m => markKey(m) === payloadKey);
return activeMarks.some(m => m.type === type);
}
function handleSimple(type) {
onToggleMark?.({ type });
}
function handleColor(color) {
onToggleMark?.({ type: 'color', color });
setPopover(null);
}
function handleHighlight(color) {
onToggleMark?.({ type: 'highlight', color });
setPopover(null);
}
function handleLinkSubmit(e) {
e.preventDefault();
if (!linkUrl) return;
onToggleMark?.({ type: 'link', href: linkUrl });
setLinkUrl('');
setPopover(null);
}
function handleLinkRemove() {
// Trouver le href actif pour reproduire la même mark (toggle off).
const link = activeMarks.find(m => m.type === 'link');
if (link) onToggleMark?.({ type: 'link', href: link.href });
setPopover(null);
}
function openLinkPopover() {
const link = activeMarks.find(m => m.type === 'link');
setLinkUrl(link?.href ?? '');
setPopover(p => (p === 'link' ? null : 'link'));
}
return (
<div
ref={ref}
data-inline-toolbar
className="fixed z-50 flex items-center gap-0.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-1 py-1"
style={{ top: pos.top, left: pos.left }}
onMouseDown={(e) => e.preventDefault()}
>
{SIMPLE_BUTTONS.map(btn => (
<button
key={btn.type}
type="button"
title={btn.title}
onClick={() => handleSimple(btn.type)}
className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${btn.className} ${isActive(btn.type) ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
{btn.label}
</button>
))}
<span className="mx-1 w-px h-5 bg-neutral-200 dark:bg-neutral-700" aria-hidden />
<button
type="button"
title="Couleur du texte"
onClick={() => setPopover(p => (p === 'color' ? null : 'color'))}
className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${isActive('color') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
<span className="font-semibold">A</span>
</button>
<button
type="button"
title="Surlignage"
onClick={() => setPopover(p => (p === 'highlight' ? null : 'highlight'))}
className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${isActive('highlight') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
<span aria-hidden></span>
</button>
<button
type="button"
title="Lien (Ctrl+K)"
onClick={openLinkPopover}
className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${isActive('link') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
<span aria-hidden>🔗</span>
</button>
{popover === 'color' && (
<ColorGrid
mode="text"
activeKey={activeMarks.find(m => m.type === 'color')?.color}
onPick={handleColor}
/>
)}
{popover === 'highlight' && (
<ColorGrid
mode="highlight"
activeKey={activeMarks.find(m => m.type === 'highlight')?.color}
onPick={handleHighlight}
/>
)}
{popover === 'link' && (
<form
onSubmit={handleLinkSubmit}
className="absolute top-full left-0 mt-1 flex items-center gap-1 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5"
>
<input
autoFocus
type="url"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="w-56 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500"
/>
<button
type="submit"
className="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white"
>
OK
</button>
{isActive('link') && (
<button
type="button"
onClick={handleLinkRemove}
title="Retirer le lien"
className="px-2 py-1 text-xs rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
>
</button>
)}
</form>
)}
</div>
);
}
function ColorGrid({ mode, activeKey, onPick }) {
return (
<div className="absolute top-full left-0 mt-1 flex items-center gap-1 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5">
{INLINE_COLOR_KEYS.map(key => {
const palette = INLINE_COLORS[key];
const tw = mode === 'text' ? palette.text : palette.highlight;
return (
<button
key={key}
type="button"
title={key}
onClick={() => onPick(key)}
className={`w-6 h-6 flex items-center justify-center rounded ${tw} ${activeKey === key ? 'ring-2 ring-blue-500' : ''}`}
>
<span className={mode === 'text' ? 'font-semibold' : ''}>A</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,166 @@
// Sérialisation `InlineNode[]` ↔ DOM.
//
// Le contentEditable de chaque bloc texte contient un sous-arbre HTML que
// l'utilisateur édite. À chaque frappe, on lit l'arbre via `domToInline`
// pour reconstruire les nœuds. À chaque changement externe (undo, transform,
// toolbar), on réécrit l'arbre via `inlineToDom`.
//
// Ordre canonique des wrappers (extérieur → intérieur) :
// <a> > <span data-highlight> > <span data-color> > <strong> > <em> > <u>
// > <s> > <code> > #text
//
// Cet ordre est important pour que la sérialisation aller-retour soit
// stable : `inlineToDom(domToInline(x))` produit le même HTML que `x`.
import { INLINE_COLORS, normalize } from './types.js';
const SIMPLE_TAGS = {
bold: 'STRONG',
italic: 'EM',
underline: 'U',
strike: 'S',
code: 'CODE',
};
const TAG_TO_MARK = {
STRONG: 'bold',
B: 'bold',
EM: 'italic',
I: 'italic',
U: 'underline',
S: 'strike',
STRIKE: 'strike',
DEL: 'strike',
CODE: 'code',
};
function findMark(marks, type) {
return marks?.find(m => m.type === type);
}
// Construit un fragment DOM. Reçoit un Document optionnel (utile en SSR /
// tests) ; sinon utilise `document` global.
export function inlineToDom(nodes, doc) {
const d = doc || (typeof document !== 'undefined' ? document : null);
if (!d) throw new Error('inlineToDom: document requis');
const fragment = d.createDocumentFragment();
if (!Array.isArray(nodes) || nodes.length === 0) return fragment;
for (const node of nodes) {
fragment.appendChild(buildNode(d, node));
}
return fragment;
}
function buildNode(d, node) {
let el = d.createTextNode(node.text ?? '');
const marks = node.marks || [];
// Ordre intérieur → extérieur (on enveloppe progressivement).
// 1. Marks simples (code, strike, underline, italic, bold) — plus on est
// interne, plus on est dans la cascade visuelle « précise ».
if (findMark(marks, 'code')) el = wrap(d, el, 'code', { className: 'rounded px-1 py-0.5 font-mono text-[0.9em] bg-neutral-100 dark:bg-neutral-800/80' });
if (findMark(marks, 'strike')) el = wrap(d, el, 's');
if (findMark(marks, 'underline')) el = wrap(d, el, 'u');
if (findMark(marks, 'italic')) el = wrap(d, el, 'em');
if (findMark(marks, 'bold')) el = wrap(d, el, 'strong');
// 2. Couleur du texte.
const color = findMark(marks, 'color');
if (color) {
const tw = INLINE_COLORS[color.color]?.text;
el = wrap(d, el, 'span', {
className: tw || '',
attrs: { 'data-color': color.color },
});
}
// 3. Surlignage.
const highlight = findMark(marks, 'highlight');
if (highlight) {
const tw = INLINE_COLORS[highlight.color]?.highlight;
el = wrap(d, el, 'span', {
className: `rounded px-0.5 ${tw || ''}`,
attrs: { 'data-highlight': highlight.color },
});
}
// 4. Lien — toujours à l'extérieur.
const link = findMark(marks, 'link');
if (link) {
el = wrap(d, el, 'a', {
className: 'text-blue-600 dark:text-blue-400 underline underline-offset-2',
attrs: {
href: link.href,
rel: 'noopener noreferrer',
target: '_blank',
},
});
}
return el;
}
function wrap(d, child, tagName, opts = {}) {
const el = d.createElement(tagName);
if (opts.className) el.className = opts.className;
if (opts.attrs) {
for (const [k, v] of Object.entries(opts.attrs)) el.setAttribute(k, v);
}
el.appendChild(child);
return el;
}
// Walk DOM → InlineNode[]. Accumule les marks dans une pile au fur et à
// mesure qu'on descend. Émet un nœud par run de texte.
export function domToInline(root) {
if (!root) return [];
const out = [];
walk(root, [], out);
return normalize(out);
}
function walk(node, marks, out) {
if (node.nodeType === 3 /* TEXT_NODE */) {
if (node.nodeValue) {
out.push({
type: 'text',
text: node.nodeValue,
...(marks.length ? { marks: marks.map(m => ({ ...m })) } : {}),
});
}
return;
}
if (node.nodeType !== 1 /* ELEMENT_NODE */) return;
// <br> : émet un saut de ligne explicite. Notre modèle n'a pas de bloc
// multi-lignes (Enter crée un nouveau bloc), mais Chrome injecte parfois
// un <br> trailing dans un contentEditable vide — on l'ignore.
if (node.tagName === 'BR') {
if (node.nextSibling || node.previousSibling) {
out.push({ type: 'text', text: '\n', ...(marks.length ? { marks: marks.map(m => ({ ...m })) } : {}) });
}
return;
}
const added = [];
const tag = node.tagName;
const simple = TAG_TO_MARK[tag];
if (simple) added.push({ type: simple });
if (tag === 'A') {
const href = node.getAttribute('href') || '';
if (href) added.push({ type: 'link', href });
}
if (tag === 'SPAN') {
const color = node.getAttribute('data-color');
const highlight = node.getAttribute('data-highlight');
if (color) added.push({ type: 'color', color });
if (highlight) added.push({ type: 'highlight', color: highlight });
}
const nextMarks = added.length ? [...marks, ...added] : marks;
for (const child of node.childNodes) {
walk(child, nextMarks, out);
}
}
@@ -0,0 +1,290 @@
// Format `InlineNode[]` : représentation du contenu inline d'un bloc texte.
// Tableau plat de nœuds `text`, chacun pouvant porter des marks (gras,
// italique, lien, couleur…). Un seul type de nœud — pas d'arbre imbriqué.
//
// Schéma :
// InlineNode = { type: 'text', text: string, marks?: Mark[] }
// Mark =
// | { type: 'bold' }
// | { type: 'italic' }
// | { type: 'underline' }
// | { type: 'strike' }
// | { type: 'code' }
// | { type: 'color', color: string } // clé palette
// | { type: 'highlight', color: string } // clé palette
// | { type: 'link', href: string }
//
// Le contenu vide est `[]` (jamais `[{type:'text', text:''}]`).
// Palette des couleurs sémantiques du DESIGN system (cf. docs/DESIGN.md).
// Bleu / vert / ambre / rouge — utilisées avec parcimonie. Les classes
// Tailwind sont appliquées au rendu via `inlineToDom`.
export const INLINE_COLORS = {
blue: { text: 'text-blue-600 dark:text-blue-400', highlight: 'bg-blue-100 dark:bg-blue-900/40' },
green: { text: 'text-green-600 dark:text-green-400', highlight: 'bg-green-100 dark:bg-green-900/40' },
amber: { text: 'text-amber-600 dark:text-amber-400', highlight: 'bg-amber-100 dark:bg-amber-900/40' },
red: { text: 'text-red-600 dark:text-red-400', highlight: 'bg-red-100 dark:bg-red-900/40' },
};
export const INLINE_COLOR_KEYS = Object.keys(INLINE_COLORS);
const SIMPLE_MARK_TYPES = ['bold', 'italic', 'underline', 'strike', 'code'];
function marksEqual(a, b) {
if (a === b) return true;
if (!a && !b) return true;
if (!a || !b) return (a?.length ?? 0) === 0 && (b?.length ?? 0) === 0;
if (a.length !== b.length) return false;
// Comparaison ensemble (ordre indifférent), via sérialisation déterministe.
const ka = a.map(markKey).sort();
const kb = b.map(markKey).sort();
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return false;
return true;
}
export function markKey(mark) {
switch (mark.type) {
case 'color':
case 'highlight':
return `${mark.type}:${mark.color}`;
case 'link':
return `link:${mark.href}`;
default:
return mark.type;
}
}
function cloneMarks(marks) {
return marks ? marks.map(m => ({ ...m })) : undefined;
}
function normalizeMarks(marks) {
if (!marks || marks.length === 0) return undefined;
// Déduplique : pour les marks paramétrées (color/highlight/link), garder la
// dernière occurrence ; pour les simples, garder une seule.
const byBucket = new Map();
for (const m of marks) {
const bucket = m.type === 'color' || m.type === 'highlight' || m.type === 'link' ? m.type : m.type;
byBucket.set(bucket, m);
}
const out = Array.from(byBucket.values()).map(m => ({ ...m }));
return out.length === 0 ? undefined : out;
}
function makeNode(text, marks) {
const node = { type: 'text', text };
const norm = normalizeMarks(marks);
if (norm) node.marks = norm;
return node;
}
// Longueur texte totale.
export function inlineLength(nodes) {
if (!Array.isArray(nodes)) return 0;
let n = 0;
for (const node of nodes) n += node.text?.length ?? 0;
return n;
}
// Texte concaténé (pour copier/coller, comparaisons rapides).
export function inlineToPlainText(nodes) {
if (!Array.isArray(nodes)) return '';
let out = '';
for (const node of nodes) out += node.text ?? '';
return out;
}
// Construit un tableau `InlineNode[]` à partir d'une string brute.
// `[]` si la string est vide — pas de nœud vide.
export function inlineFromText(text) {
if (!text) return [];
return [{ type: 'text', text }];
}
// Sous-tableau couvrant l'intervalle [start, end[. Découpe les nœuds aux
// frontières si besoin. Préserve les marks.
export function sliceInline(nodes, start, end) {
if (!Array.isArray(nodes)) return [];
const total = inlineLength(nodes);
const a = Math.max(0, Math.min(start, total));
const b = Math.max(a, Math.min(end ?? total, total));
if (a === b) return [];
const out = [];
let pos = 0;
for (const node of nodes) {
const len = node.text?.length ?? 0;
const nodeStart = pos;
const nodeEnd = pos + len;
pos = nodeEnd;
if (nodeEnd <= a) continue;
if (nodeStart >= b) break;
const localStart = Math.max(0, a - nodeStart);
const localEnd = Math.min(len, b - nodeStart);
if (localEnd <= localStart) continue;
out.push(makeNode(node.text.slice(localStart, localEnd), cloneMarks(node.marks)));
}
return normalize(out);
}
// Concatène deux tableaux en fusionnant le dernier nœud de `a` et le premier
// de `b` s'ils ont les mêmes marks.
export function concatInline(a, b) {
if (!Array.isArray(a) || a.length === 0) return Array.isArray(b) ? normalize(b) : [];
if (!Array.isArray(b) || b.length === 0) return normalize(a);
return normalize([...a.map(n => ({ ...n, marks: cloneMarks(n.marks) })),
...b.map(n => ({ ...n, marks: cloneMarks(n.marks) }))]);
}
// Fusionne les nœuds adjacents identiques et supprime les nœuds vides.
export function normalize(nodes) {
if (!Array.isArray(nodes)) return [];
const out = [];
for (const node of nodes) {
if (!node.text) continue;
const last = out[out.length - 1];
if (last && marksEqual(last.marks, node.marks)) {
last.text += node.text;
} else {
out.push({ type: 'text', text: node.text, ...(node.marks ? { marks: cloneMarks(node.marks) } : {}) });
}
}
return out;
}
// Retourne les marks actives à un offset donné (utile pour le toolbar).
// À une frontière entre deux nœuds, on prend les marks du nœud à droite,
// sauf en fin de tableau où on prend le nœud de gauche.
export function marksAtOffset(nodes, offset) {
if (!Array.isArray(nodes) || nodes.length === 0) return [];
let pos = 0;
for (const node of nodes) {
const len = node.text?.length ?? 0;
if (offset < pos + len) return node.marks ? node.marks.map(m => ({ ...m })) : [];
pos += len;
}
// offset au-delà du dernier nœud → marks du dernier nœud.
const last = nodes[nodes.length - 1];
return last.marks ? last.marks.map(m => ({ ...m })) : [];
}
// Marks communes à toute la plage [start, end[. Si la plage est vide,
// retourne les marks à l'offset `start`. Utile pour afficher l'état actif
// du toolbar.
export function marksInRange(nodes, start, end) {
if (start >= end) return marksAtOffset(nodes, start);
if (!Array.isArray(nodes) || nodes.length === 0) return [];
let common = null;
let pos = 0;
for (const node of nodes) {
const len = node.text?.length ?? 0;
const nodeStart = pos;
const nodeEnd = pos + len;
pos = nodeEnd;
if (nodeEnd <= start) continue;
if (nodeStart >= end) break;
const ks = (node.marks || []).map(markKey);
if (common === null) {
common = new Set(ks);
} else {
for (const k of Array.from(common)) if (!ks.includes(k)) common.delete(k);
}
if (common.size === 0) return [];
}
if (!common) return [];
// Reconstruit les objets mark depuis les nœuds couverts.
const result = [];
pos = 0;
for (const node of nodes) {
const len = node.text?.length ?? 0;
const nodeStart = pos;
const nodeEnd = pos + len;
pos = nodeEnd;
if (nodeEnd <= start) continue;
if (nodeStart >= end) break;
for (const m of node.marks || []) {
if (common.has(markKey(m)) && !result.some(r => markKey(r) === markKey(m))) {
result.push({ ...m });
}
}
}
return result;
}
function addMarkToNode(node, mark) {
const existing = node.marks || [];
// Pour les marks paramétrées, on remplace (color/highlight/link sont
// exclusives entre elles dans la même catégorie).
const filtered = existing.filter(m => m.type !== mark.type);
return makeNode(node.text, [...filtered, { ...mark }]);
}
function removeMarkFromNode(node, type) {
const existing = node.marks || [];
const filtered = existing.filter(m => m.type !== type);
return makeNode(node.text, filtered);
}
function mapRange(nodes, start, end, fn) {
if (start >= end || !Array.isArray(nodes)) return Array.isArray(nodes) ? normalize(nodes) : [];
const out = [];
let pos = 0;
for (const node of nodes) {
const len = node.text?.length ?? 0;
const nodeStart = pos;
const nodeEnd = pos + len;
pos = nodeEnd;
if (len === 0) continue;
if (nodeEnd <= start || nodeStart >= end) {
out.push(makeNode(node.text, cloneMarks(node.marks)));
continue;
}
const localStart = Math.max(0, start - nodeStart);
const localEnd = Math.min(len, end - nodeStart);
if (localStart > 0) {
out.push(makeNode(node.text.slice(0, localStart), cloneMarks(node.marks)));
}
if (localEnd > localStart) {
const middle = makeNode(node.text.slice(localStart, localEnd), cloneMarks(node.marks));
out.push(fn(middle));
}
if (localEnd < len) {
out.push(makeNode(node.text.slice(localEnd), cloneMarks(node.marks)));
}
}
return normalize(out);
}
export function applyMark(nodes, start, end, mark) {
return mapRange(nodes, start, end, n => addMarkToNode(n, mark));
}
export function removeMark(nodes, start, end, type) {
return mapRange(nodes, start, end, n => removeMarkFromNode(n, type));
}
// Toggle : si toute la plage porte déjà la mark (au sens markKey strict),
// on la retire ; sinon on l'applique partout.
export function toggleMark(nodes, start, end, mark) {
const active = marksInRange(nodes, start, end).some(m => markKey(m) === markKey(mark));
if (active) {
if (mark.type === 'color' || mark.type === 'highlight' || mark.type === 'link') {
return removeMark(nodes, start, end, mark.type);
}
return removeMark(nodes, start, end, mark.type);
}
return applyMark(nodes, start, end, mark);
}
// Insère du texte brut à un offset, héritant des marks de l'environnement.
// Utilisé après une transformation pour réinjecter du texte sans perdre le
// contexte. Phase 2 ne s'en sert pas en édition (la frappe passe par le
// DOM puis `domToInline`), mais c'est utile pour les helpers programmatiques.
export function insertText(nodes, offset, text) {
if (!text) return normalize(nodes ?? []);
const before = sliceInline(nodes, 0, offset);
const after = sliceInline(nodes, offset, inlineLength(nodes));
// Hérite des marks du nœud à gauche (continuité de la frappe).
const marks = marksAtOffset(nodes, Math.max(0, offset - 1));
const inserted = [makeNode(text, marks)];
return concatInline(concatInline(before, inserted), after);
}