diff --git a/src/shared/components/BlockEditor/utils/caret.js b/src/shared/components/BlockEditor/utils/caret.js index 0405d2f..1a512d1 100644 --- a/src/shared/components/BlockEditor/utils/caret.js +++ b/src/shared/components/BlockEditor/utils/caret.js @@ -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 (, , , ...). -// Les fonctions ci-dessous calculent / posent un offset texte global. +// Les fonctions ci-dessous calculent / posent un offset texte global, +// en comptant les
comme 1 caractère (équivalent au \n du modèle InlineNode). + +// Longueur « modèle » d'un sous-arbre DOM : texte +
=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
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
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
: 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); }