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
@@ -1,5 +1,7 @@
// Helpers de gestion du caret pour les contentEditable mono-bloc.
// On reste sur du texte brut au MVP : un seul Text node enfant (ou aucun).
// Helpers de gestion du caret pour les contentEditable.
// Depuis Phase 2, l'arbre interne peut contenir plusieurs Text nodes
// emballés dans des wrappers (<strong>, <em>, <a>, <span data-color>...).
// Les fonctions ci-dessous calculent / posent un offset texte global.
export function getCaretOffset(el) {
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
@@ -12,22 +14,68 @@ export function getCaretOffset(el) {
return pre.toString().length;
}
export function getCaretRange(el) {
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return null;
const startPre = range.cloneRange();
startPre.selectNodeContents(el);
startPre.setEnd(range.startContainer, range.startOffset);
const endPre = range.cloneRange();
endPre.selectNodeContents(el);
endPre.setEnd(range.endContainer, range.endOffset);
return { start: startPre.toString().length, end: endPre.toString().length };
}
// Trouve le Text node + offset local correspondant à un offset global.
// Si l'arbre est vide, retourne `{ node: el, offset: 0 }` pour positionner
// le caret directement dans l'élément racine.
function locateOffset(el, offset) {
if (!el) return null;
const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let consumed = 0;
let last = null;
let textNode = walker.nextNode();
while (textNode) {
const len = textNode.nodeValue?.length ?? 0;
if (offset <= consumed + len) {
return { node: textNode, offset: Math.max(0, offset - consumed) };
}
consumed += len;
last = textNode;
textNode = walker.nextNode();
}
if (last) return { node: last, offset: last.nodeValue?.length ?? 0 };
return { node: el, offset: 0 };
}
export function setCaretOffset(el, offset) {
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel) return;
const target = locateOffset(el, Math.max(0, offset || 0));
if (!target) return;
const range = document.createRange();
const text = el.firstChild;
if (!text) {
range.setStart(el, 0);
range.collapse(true);
} else {
const max = text.textContent?.length ?? 0;
const pos = Math.max(0, Math.min(offset, max));
range.setStart(text, pos);
range.collapse(true);
}
range.setStart(target.node, target.offset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
// Pose une sélection couvrant [start, end[ dans l'élément.
export function setCaretRange(el, start, end) {
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel) return;
const a = locateOffset(el, Math.max(0, start || 0));
const b = locateOffset(el, Math.max(0, end || 0));
if (!a || !b) return;
const range = document.createRange();
range.setStart(a.node, a.offset);
range.setEnd(b.node, b.offset);
sel.removeAllRanges();
sel.addRange(range);
}