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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user