fix(BlockEditor): handle br tags as single character in caret offset calculation

- replace `getCaretOffset` with `countCharsUpTo` to treat `<br>` as 1 char
- rewrite `locateOffset` to walk dom tree and account for `<br>` nodes
- add `textLength` helper to compute model-consistent node length
- update `getCaretOffset` and `getCaretRange` to use new counting logic
This commit is contained in:
2026-04-25 19:36:30 -04:00
parent 51cbf11729
commit 3d9431389b
@@ -1,17 +1,103 @@
// 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.
// Les fonctions ci-dessous calculent / posent un offset texte global,
// en comptant les <br> comme 1 caractère (équivalent au \n du modèle InlineNode).
// Longueur « modèle » d'un sous-arbre DOM : texte + <br>=1 chacun.
function textLength(node) {
if (!node) return 0;
if (node.nodeType === 3) return node.nodeValue?.length ?? 0;
if (node.nodeType !== 1) return 0;
if (node.tagName === 'BR') return 1;
let total = 0;
for (const child of node.childNodes) total += textLength(child);
return total;
}
// Compte les caractères depuis root jusqu'à (targetNode, targetOffset),
// en traitant les <br> comme 1 caractère.
function countCharsUpTo(root, targetNode, targetOffset) {
if (root === targetNode) {
let count = 0;
const kids = root.childNodes;
for (let i = 0; i < targetOffset && i < kids.length; i++) count += textLength(kids[i]);
return count;
}
let count = 0;
for (const child of root.childNodes) {
if (child === targetNode) {
if (child.nodeType === 3) {
count += targetOffset;
} else {
const kids = child.childNodes;
for (let i = 0; i < targetOffset && i < kids.length; i++) count += textLength(kids[i]);
}
return count;
}
if (child.nodeType === 1 && child.tagName !== 'BR' && child.contains?.(targetNode)) {
return count + countCharsUpTo(child, targetNode, targetOffset);
}
count += textLength(child);
}
return count;
}
// Trouve le Text node + offset local correspondant à un offset global.
// Compte les <br> comme 1 caractère.
function locateOffset(el, offset) {
if (!el) return null;
let consumed = 0;
function walk(node) {
if (node.nodeType === 3 /* TEXT */) {
const len = node.nodeValue?.length ?? 0;
if (offset <= consumed + len) return { node, offset: offset - consumed };
consumed += len;
return null;
}
if (node.nodeType !== 1) return null;
if (node.tagName === 'BR') {
if (offset <= consumed) {
// Cursor tombe juste avant ce <br> : se positionner avant dans le parent.
const parent = node.parentNode ?? el;
let idx = 0;
for (const sib of parent.childNodes) {
if (sib === node) break;
idx++;
}
return { node: parent, offset: idx };
}
consumed += 1;
return null;
}
for (const child of node.childNodes) {
const result = walk(child);
if (result) return result;
}
return null;
}
for (const child of el.childNodes) {
const result = walk(child);
if (result) return result;
}
// offset au-delà de la fin : pointe la fin du dernier nœud texte.
const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let last = null;
let node = walker.nextNode();
while (node) { last = node; node = walker.nextNode(); }
if (last) return { node: last, offset: last.nodeValue?.length ?? 0 };
return { node: el, offset: 0 };
}
export function getCaretOffset(el) {
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
if (!sel || sel.rangeCount === 0) return 0;
const range = sel.getRangeAt(0);
if (!el.contains(range.startContainer)) return 0;
const pre = range.cloneRange();
pre.selectNodeContents(el);
pre.setEnd(range.startContainer, range.startOffset);
return pre.toString().length;
return countCharsUpTo(el, range.startContainer, range.startOffset);
}
export function getCaretRange(el) {
@@ -19,35 +105,10 @@ export function getCaretRange(el) {
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 };
return {
start: countCharsUpTo(el, range.startContainer, range.startOffset),
end: countCharsUpTo(el, range.endContainer, range.endOffset),
};
}
export function setCaretOffset(el, offset) {
@@ -82,8 +143,7 @@ export function setCaretRange(el, start, end) {
export function focusEnd(el) {
if (!el) return;
const len = (el.textContent ?? '').length;
setCaretOffset(el, len);
setCaretOffset(el, textLength(el));
}
export function isCaretAtStart(el) {
@@ -91,5 +151,5 @@ export function isCaretAtStart(el) {
}
export function isCaretAtEnd(el) {
return getCaretOffset(el) === (el.textContent ?? '').length;
return getCaretOffset(el) === textLength(el);
}