Comment définir la position du curseur (curseur) dans l'élément contenteditable (div)?


191

J'ai ce HTML simple comme exemple:

<div id="editable" contenteditable="true">
  text text text<br>
  text text text<br>
  text text text<br>
</div>
<button id="button">focus</button>

Je veux une chose simple - lorsque je clique sur le bouton, je veux placer le curseur (curseur) à un endroit spécifique dans le div modifiable. De la recherche sur le Web, j'ai ce JS attaché au clic de bouton, mais cela ne fonctionne pas (FF, Chrome):

var range = document.createRange();
var myDiv = document.getElementById("editable");
range.setStart(myDiv, 5);
range.setEnd(myDiv, 5);

Est-il possible de définir manuellement la position du curseur de cette manière?

Réponses:


261

Dans la plupart des navigateurs, vous avez besoin des objets Rangeet Selection. Vous spécifiez chacune des limites de sélection comme un nœud et un décalage dans ce nœud. Par exemple, pour définir le curseur sur le cinquième caractère de la deuxième ligne de texte, procédez comme suit:

var el = document.getElementById("editable");
var range = document.createRange();
var sel = window.getSelection();
range.setStart(el.childNodes[2], 5);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);

IE <9 fonctionne complètement différemment. Si vous devez prendre en charge ces navigateurs, vous aurez besoin d'un code différent.

Exemple jsFiddle: http://jsfiddle.net/timdown/vXnCM/


2
Votre solution fonctionne parfaitement. Merci beaucoup. Y a-t-il une chance que cela fonctionne dans un «contexte de texte» - cela signifie que la position # 5 serait la cinquième lettre sur un écran et non la cinquième lettre dans un code?
Frodik

3
@Frodik: Vous pouvez utiliser la setSelectionRange()fonction de la réponse que j'ai écrite ici: stackoverflow.com/questions/6240139/… . Comme je l'ai noté dans la réponse, il y a diverses choses qu'il ne gère pas correctement / systématiquement, mais cela peut être suffisant.
Tim Down

7
que diriez-vous de placer le curseur dans une balise span comme celle-ci: << div id = "editable" contenteditable = "true"> test1 <br> test2 <br> <span> </span> </div>
Med Akram Z

1
@MalcolmOcean: Barf, parce que IE <9 n'a pas document.createRange(ou window.getSelection, mais il n'ira pas aussi loin).
Tim Down

1
@undroid: Le jsfiddle fonctionne très bien pour moi dans Firefox 38.0.5 sur Mac.
Tim Down

62

La plupart des réponses que vous trouvez sur le positionnement du curseur contentable sont assez simplistes en ce sens qu'elles ne concernent que les entrées avec du texte simple. Une fois que vous utilisez des éléments html dans le conteneur, le texte saisi est divisé en nœuds et distribué généreusement dans une arborescence.

Pour définir la position du curseur, j'ai cette fonction qui boucle autour de tous les nœuds de texte enfant dans le nœud fourni et définit une plage allant du début du nœud initial au caractère chars.count :

function createRange(node, chars, range) {
    if (!range) {
        range = document.createRange()
        range.selectNode(node);
        range.setStart(node, 0);
    }

    if (chars.count === 0) {
        range.setEnd(node, chars.count);
    } else if (node && chars.count >0) {
        if (node.nodeType === Node.TEXT_NODE) {
            if (node.textContent.length < chars.count) {
                chars.count -= node.textContent.length;
            } else {
                range.setEnd(node, chars.count);
                chars.count = 0;
            }
        } else {
           for (var lp = 0; lp < node.childNodes.length; lp++) {
                range = createRange(node.childNodes[lp], chars, range);

                if (chars.count === 0) {
                    break;
                }
            }
        }
    } 

    return range;
};

J'appelle ensuite la routine avec cette fonction:

function setCurrentCursorPosition(chars) {
    if (chars >= 0) {
        var selection = window.getSelection();

        range = createRange(document.getElementById("test").parentNode, { count: chars });

        if (range) {
            range.collapse(false);
            selection.removeAllRanges();
            selection.addRange(range);
        }
    }
};

Le range.collapse (false) place le curseur à la fin de la plage. Je l'ai testé avec les dernières versions de Chrome, IE, Mozilla et Opera et elles fonctionnent toutes très bien.

PS. Si quelqu'un est intéressé, j'obtiens la position actuelle du curseur en utilisant ce code:

function isChildOf(node, parentId) {
    while (node !== null) {
        if (node.id === parentId) {
            return true;
        }
        node = node.parentNode;
    }

    return false;
};

function getCurrentCursorPosition(parentId) {
    var selection = window.getSelection(),
        charCount = -1,
        node;

    if (selection.focusNode) {
        if (isChildOf(selection.focusNode, parentId)) {
            node = selection.focusNode; 
            charCount = selection.focusOffset;

            while (node) {
                if (node.id === parentId) {
                    break;
                }

                if (node.previousSibling) {
                    node = node.previousSibling;
                    charCount += node.textContent.length;
                } else {
                     node = node.parentNode;
                     if (node === null) {
                         break
                     }
                }
           }
      }
   }

    return charCount;
};

Le code fait le contraire de la fonction set - il obtient le window.getSelection (). FocusNode et focusOffset en cours et compte à rebours tous les caractères de texte rencontrés jusqu'à ce qu'il atteigne un nœud parent avec l'ID de containerId. La fonction isChildOf vérifie juste avant d'exécuter que le nœud fourni est en fait un enfant du parentId fourni .

Le code devrait fonctionner directement sans changement, mais je viens pris d'un plugin jQuery j'ai développé ont donc piraté un couple de cette de - laissez - moi savoir si quelque chose ne fonctionne pas!


1
Pourriez-vous fournir un jsfiddle de ce travail s'il vous plaît? J'ai du mal à comprendre comment cela fonctionne car je ne suis pas sûr de quoi node.idet je m'identifie parentIdsans exemple. Merci :)
Bendihossan

4
@Bendihossan - essayez ceci jsfiddle.net/nrx9yvw9/5 - pour une raison quelconque, le contenu div modifiable dans cet exemple ajoute quelques caractères et un retour chariot au début du texte (il se peut même que jsfiddle le fasse comme il le fait ; t faire de même sur mon serveur asp.net).
Liam

@Bendihossan - les éléments html dans la div contenteditable sont décomposés en une structure arborescente avec un nœud pour chaque élément html. GetCurrentCursorPosition récupère la position de sélection actuelle et remonte dans l'arborescence en comptant le nombre de caractères de texte brut. Node.id est l'identifiant de l'élément html, tandis que parentId fait référence à l'identifiant de l'élément html auquel il devrait cesser de compter
Liam

1
C'est sur ma liste de choses à faire pour en écrire un qui soit complètement séparé de mon code d'interface utilisateur - je le posterai quand j'aurai une seconde.
Liam

1
Afin de pouvoir tester rapidement vos différentes solutions, pourriez-vous modifier votre réponse en extraits de code exécutables? Merci d'avance.
Basj

3

Si vous ne souhaitez pas utiliser jQuery, vous pouvez essayer cette approche:

public setCaretPosition() {
    const editableDiv = document.getElementById('contenteditablediv');
    const lastLine = this.input.nativeElement.innerHTML.replace(/.*?(<br>)/g, '');
    const selection = window.getSelection();
    selection.collapse(editableDiv.childNodes[editableDiv.childNodes.length - 1], lastLine.length);
}

editableDivvotre élément modifiable, n'oubliez pas de définir un idpour lui. Ensuite, vous devez obtenir votre innerHTMLde l'élément et couper toutes les conduites de frein. Et réglez simplement l'effondrement avec les arguments suivants.


3
  const el = document.getElementById("editable");
  el.focus()
  let char = 1, sel; // character at which to place caret

  if (document.selection) {
    sel = document.selection.createRange();
    sel.moveStart('character', char);
    sel.select();
  }
  else {
    sel = window.getSelection();
    sel.collapse(el.lastChild, char);
  }

3

function set_mouse() {
  var as = document.getElementById("editable");
  el = as.childNodes[1].childNodes[0]; //goal is to get ('we') id to write (object Text) because it work only in object text
  var range = document.createRange();
  var sel = window.getSelection();
  range.setStart(el, 1);
  range.collapse(true);
  sel.removeAllRanges();
  sel.addRange(range);

  document.getElementById("we").innerHTML = el; // see out put of we id
}
<div id="editable" contenteditable="true">dddddddddddddddddddddddddddd
  <p>dd</p>psss
  <p>dd</p>
  <p>dd</p>
  <p>text text text</p>
</div>
<p id='we'></p>
<button onclick="set_mouse()">focus</button>

Il est très difficile de placer le curseur dans la bonne position lorsque vous avez un élément avancé comme (p) (span) etc. Le but est d'obtenir (texte de l'objet):

<div id="editable" contenteditable="true">dddddddddddddddddddddddddddd<p>dd</p>psss<p>dd</p>
    <p>dd</p>
    <p>text text text</p>
</div>
<p id='we'></p>
<button onclick="set_mouse()">focus</button>
<script>

    function set_mouse() {
        var as = document.getElementById("editable");
        el = as.childNodes[1].childNodes[0];//goal is to get ('we') id to write (object Text) because it work only in object text
        var range = document.createRange();
        var sel = window.getSelection();
        range.setStart(el, 1);
        range.collapse(true);
        sel.removeAllRanges();
        sel.addRange(range);

        document.getElementById("we").innerHTML = el;// see out put of we id
    }
</script>

1
Afin de pouvoir tester votre réponse rapidement, pouvez-vous modifier votre réponse dans un extrait de code exécutable? Merci d'avance.
Basj

1

J'écris un surligneur de syntaxe (et un éditeur de code de base), et j'avais besoin de savoir comment taper automatiquement un caractère de guillemet simple et déplacer le curseur (comme beaucoup d'éditeurs de code de nos jours).

Voici un extrait de ma solution, grâce à l'aide de ce fil, aux documents MDN et à de nombreuses vidéos de surveillance de la console moz.

//onKeyPress event

if (evt.key === "\"") {
    let sel = window.getSelection();
    let offset = sel.focusOffset;
    let focus = sel.focusNode;

    focus.textContent += "\""; //setting div's innerText directly creates new
    //nodes, which invalidate our selections, so we modify the focusNode directly

    let range = document.createRange();
    range.selectNode(focus);
    range.setStart(focus, offset);

    range.collapse(true);
    sel.removeAllRanges();
    sel.addRange(range);
}

//end onKeyPress event

Ceci est dans un élément div contenteditable

Je laisse cela ici en guise de remerciement, réalisant qu'il existe déjà une réponse acceptée.


1

J'ai fait ceci pour mon simple éditeur de texte.

Différences par rapport aux autres méthodes:

  • Haute performance
  • Fonctionne avec tous les espaces

usage

// get current selection
const [start, end] = getSelectionOffset(container)

// change container html
container.innerHTML = newHtml

// restore selection
setSelectionOffset(container, start, end)

// use this instead innerText for get text with keep all spaces
const innerText = getInnerText(container)
const textBeforeCaret = innerText.substring(0, start)
const textAfterCaret = innerText.substring(start)

selection.ts

/** return true if node found */
function searchNode(
    container: Node,
    startNode: Node,
    predicate: (node: Node) => boolean,
    excludeSibling?: boolean,
): boolean {
    if (predicate(startNode as Text)) {
        return true
    }

    for (let i = 0, len = startNode.childNodes.length; i < len; i++) {
        if (searchNode(startNode, startNode.childNodes[i], predicate, true)) {
            return true
        }
    }

    if (!excludeSibling) {
        let parentNode = startNode
        while (parentNode && parentNode !== container) {
            let nextSibling = parentNode.nextSibling
            while (nextSibling) {
                if (searchNode(container, nextSibling, predicate, true)) {
                    return true
                }
                nextSibling = nextSibling.nextSibling
            }
            parentNode = parentNode.parentNode
        }
    }

    return false
}

function createRange(container: Node, start: number, end: number): Range {
    let startNode
    searchNode(container, container, node => {
        if (node.nodeType === Node.TEXT_NODE) {
            const dataLength = (node as Text).data.length
            if (start <= dataLength) {
                startNode = node
                return true
            }
            start -= dataLength
            end -= dataLength
            return false
        }
    })

    let endNode
    if (startNode) {
        searchNode(container, startNode, node => {
            if (node.nodeType === Node.TEXT_NODE) {
                const dataLength = (node as Text).data.length
                if (end <= dataLength) {
                    endNode = node
                    return true
                }
                end -= dataLength
                return false
            }
        })
    }

    const range = document.createRange()
    if (startNode) {
        if (start < startNode.data.length) {
            range.setStart(startNode, start)
        } else {
            range.setStartAfter(startNode)
        }
    } else {
        if (start === 0) {
            range.setStart(container, 0)
        } else {
            range.setStartAfter(container)
        }
    }

    if (endNode) {
        if (end < endNode.data.length) {
            range.setEnd(endNode, end)
        } else {
            range.setEndAfter(endNode)
        }
    } else {
        if (end === 0) {
            range.setEnd(container, 0)
        } else {
            range.setEndAfter(container)
        }
    }

    return range
}

export function setSelectionOffset(node: Node, start: number, end: number) {
    const range = createRange(node, start, end)
    const selection = window.getSelection()
    selection.removeAllRanges()
    selection.addRange(range)
}

function hasChild(container: Node, node: Node): boolean {
    while (node) {
        if (node === container) {
            return true
        }
        node = node.parentNode
    }

    return false
}

function getAbsoluteOffset(container: Node, offset: number) {
    if (container.nodeType === Node.TEXT_NODE) {
        return offset
    }

    let absoluteOffset = 0
    for (let i = 0, len = Math.min(container.childNodes.length, offset); i < len; i++) {
        const childNode = container.childNodes[i]
        searchNode(childNode, childNode, node => {
            if (node.nodeType === Node.TEXT_NODE) {
                absoluteOffset += (node as Text).data.length
            }
            return false
        })
    }

    return absoluteOffset
}

export function getSelectionOffset(container: Node): [number, number] {
    let start = 0
    let end = 0

    const selection = window.getSelection()
    for (let i = 0, len = selection.rangeCount; i < len; i++) {
        const range = selection.getRangeAt(i)
        if (range.intersectsNode(container)) {
            const startNode = range.startContainer
            searchNode(container, container, node => {
                if (startNode === node) {
                    start += getAbsoluteOffset(node, range.startOffset)
                    return true
                }

                const dataLength = node.nodeType === Node.TEXT_NODE
                    ? (node as Text).data.length
                    : 0

                start += dataLength
                end += dataLength

                return false
            })

            const endNode = range.endContainer
            searchNode(container, startNode, node => {
                if (endNode === node) {
                    end += getAbsoluteOffset(node, range.endOffset)
                    return true
                }

                const dataLength = node.nodeType === Node.TEXT_NODE
                    ? (node as Text).data.length
                    : 0

                end += dataLength

                return false
            })

            break
        }
    }

    return [start, end]
}

export function getInnerText(container: Node) {
    const buffer = []
    searchNode(container, container, node => {
        if (node.nodeType === Node.TEXT_NODE) {
            buffer.push((node as Text).data)
        }
        return false
    })
    return buffer.join('')
}

1

J'ai remanié la réponse de @ Liam. Je l'ai mis dans une classe avec des méthodes statiques, j'ai fait que ses fonctions reçoivent un élément au lieu d'un #id, et quelques autres petits ajustements.

Ce code est particulièrement utile pour fixer le curseur dans une zone de texte riche que vous pourriez créer <div contenteditable="true">. J'étais coincé là-dessus pendant plusieurs jours avant d'arriver au code ci-dessous.

edit: Sa réponse et cette réponse ont un bug impliquant d'appuyer sur Entrée. Puisque enter ne compte pas comme un caractère, la position du curseur est perturbée après avoir appuyé sur Entrée. Si je parviens à corriger le code, je mettrai à jour ma réponse.

edit2: Épargnez-vous beaucoup de maux de tête et assurez-vous que le vôtre l' <div contenteditable=true>est display: inline-block. Cela corrige certains bugs liés à la mise en <div>place de Chrome au lieu d' <br>appuyer sur Entrée.

Comment utiliser

let richText = document.getElementById('rich-text');
let offset = Cursor.getCurrentCursorPosition(richText);
// do stuff to the innerHTML, such as adding/removing <span> tags
Cursor.setCurrentCursorPosition(offset, richText);
richText.focus();

Code

// Credit to Liam (Stack Overflow)
// https://stackoverflow.com/a/41034697/3480193
class Cursor {
    static getCurrentCursorPosition(parentElement) {
        var selection = window.getSelection(),
            charCount = -1,
            node;
        
        if (selection.focusNode) {
            if (Cursor._isChildOf(selection.focusNode, parentElement)) {
                node = selection.focusNode; 
                charCount = selection.focusOffset;
                
                while (node) {
                    if (node === parentElement) {
                        break;
                    }

                    if (node.previousSibling) {
                        node = node.previousSibling;
                        charCount += node.textContent.length;
                    } else {
                        node = node.parentNode;
                        if (node === null) {
                            break;
                        }
                    }
                }
            }
        }
        
        return charCount;
    }
    
    static setCurrentCursorPosition(chars, element) {
        if (chars >= 0) {
            var selection = window.getSelection();
            
            let range = Cursor._createRange(element, { count: chars });

            if (range) {
                range.collapse(false);
                selection.removeAllRanges();
                selection.addRange(range);
            }
        }
    }
    
    static _createRange(node, chars, range) {
        if (!range) {
            range = document.createRange()
            range.selectNode(node);
            range.setStart(node, 0);
        }

        if (chars.count === 0) {
            range.setEnd(node, chars.count);
        } else if (node && chars.count >0) {
            if (node.nodeType === Node.TEXT_NODE) {
                if (node.textContent.length < chars.count) {
                    chars.count -= node.textContent.length;
                } else {
                    range.setEnd(node, chars.count);
                    chars.count = 0;
                }
            } else {
                for (var lp = 0; lp < node.childNodes.length; lp++) {
                    range = Cursor._createRange(node.childNodes[lp], chars, range);

                    if (chars.count === 0) {
                    break;
                    }
                }
            }
        } 

        return range;
    }
    
    static _isChildOf(node, parentElement) {
        while (node !== null) {
            if (node === parentElement) {
                return true;
            }
            node = node.parentNode;
        }

        return false;
    }
}

0

Je pense que ce n'est pas simple de placer le curseur sur une position dans un élément contenteditable. J'ai écrit mon propre code pour cela. Il contourne l'arborescence des nœuds en calculant le nombre de caractères restants et place le curseur dans l'élément nécessaire. Je n'ai pas beaucoup testé ce code.

//Set offset in current contenteditable field (for start by default or for with forEnd=true)
function setCurSelectionOffset(offset, forEnd = false) {
    const sel = window.getSelection();
    if (sel.rangeCount !== 1 || !document.activeElement) return;

    const firstRange = sel.getRangeAt(0);

    if (offset > 0) {
        bypassChildNodes(document.activeElement, offset);
    }else{
        if (forEnd)
            firstRange.setEnd(document.activeElement, 0);
        else
            firstRange.setStart(document.activeElement, 0);
    }



    //Bypass in depth
    function bypassChildNodes(el, leftOffset) {
        const childNodes = el.childNodes;

        for (let i = 0; i < childNodes.length && leftOffset; i++) {
            const childNode = childNodes[i];

            if (childNode.nodeType === 3) {
                const curLen = childNode.textContent.length;

                if (curLen >= leftOffset) {
                    if (forEnd)
                        firstRange.setEnd(childNode, leftOffset);
                    else
                        firstRange.setStart(childNode, leftOffset);
                    return 0;
                }else{
                    leftOffset -= curLen;
                }
            }else
            if (childNode.nodeType === 1) {
                leftOffset = bypassChildNodes(childNode, leftOffset);
            }
        }

        return leftOffset;
    }
}

J'ai également écrit du code pour obtenir la position actuelle du curseur (je n'ai pas testé):

//Get offset in current contenteditable field (start offset by default or end offset with calcEnd=true)
function getCurSelectionOffset(calcEnd = false) {
    const sel = window.getSelection();
    if (sel.rangeCount !== 1 || !document.activeElement) return 0;

    const firstRange     = sel.getRangeAt(0),
          startContainer = calcEnd ? firstRange.endContainer : firstRange.startContainer,
          startOffset    = calcEnd ? firstRange.endOffset    : firstRange.startOffset;
    let needStop = false;

    return bypassChildNodes(document.activeElement);



    //Bypass in depth
    function bypassChildNodes(el) {
        const childNodes = el.childNodes;
        let ans = 0;

        if (el === startContainer) {
            if (startContainer.nodeType === 3) {
                ans = startOffset;
            }else
            if (startContainer.nodeType === 1) {
                for (let i = 0; i < startOffset; i++) {
                    const childNode = childNodes[i];

                    ans += childNode.nodeType === 3 ? childNode.textContent.length :
                           childNode.nodeType === 1 ? childNode.innerText.length :
                           0;
                }
            }

            needStop = true;
        }else{
            for (let i = 0; i < childNodes.length && !needStop; i++) {
                const childNode = childNodes[i];
                ans += bypassChildNodes(childNode);
            }
        }

        return ans;
    }
}

Vous devez également savoir que range.startOffset et range.endOffset contiennent un décalage de caractère pour les nœuds de texte (nodeType === 3) et un décalage de nœud enfant pour les nœuds d'élément (nodeType === 1). range.startContainer et range.endContainer peuvent faire référence à n'importe quel nœud d'élément de n'importe quel niveau de l'arborescence (bien sûr, ils peuvent également faire référence à des nœuds de texte).


0

Basé sur la réponse de Tim Down, mais il recherche la dernière «bonne» ligne de texte connue. Il place le curseur à la toute fin.

De plus, je pourrais aussi vérifier récursivement / itérativement le dernier enfant de chaque dernier enfant consécutif pour trouver le dernier nœud de texte "bon" absolu dans le DOM.

function onClickHandler() {
  setCaret(document.getElementById("editable"));
}

function setCaret(el) {
  let range = document.createRange(),
      sel = window.getSelection(),
      lastKnownIndex = -1;
  for (let i = 0; i < el.childNodes.length; i++) {
    if (isTextNodeAndContentNoEmpty(el.childNodes[i])) {
      lastKnownIndex = i;
    }
  }
  if (lastKnownIndex === -1) {
    throw new Error('Could not find valid text content');
  }
  let row = el.childNodes[lastKnownIndex],
      col = row.textContent.length;
  range.setStart(row, col);
  range.collapse(true);
  sel.removeAllRanges();
  sel.addRange(range);
  el.focus();
}

function isTextNodeAndContentNoEmpty(node) {
  return node.nodeType == Node.TEXT_NODE && node.textContent.trim().length > 0
}
<div id="editable" contenteditable="true">
  text text text<br>text text text<br>text text text<br>
</div>
<button id="button" onclick="onClickHandler()">focus</button>

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.