¿Cómo establecer la posición de cursor (cursor) en elemento contenteditable (div)?


191

Tengo este HTML simple como ejemplo:

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

Quiero algo simple: cuando hago clic en el botón, quiero colocar el cursor (cursor) en un lugar específico en el div editable. Al buscar en la web, tengo este JS adjunto al clic del botón, pero no funciona (FF, Chrome):

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

¿Es posible establecer manualmente la posición del cursor de esta manera?

Respuestas:


261

En la mayoría de los navegadores, necesita los objetos Rangey Selection. Usted especifica cada uno de los límites de selección como un nodo y un desplazamiento dentro de ese nodo. Por ejemplo, para establecer el cursor en el quinto carácter de la segunda línea de texto, haría lo siguiente:

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 funciona de manera completamente diferente. Si necesita admitir estos navegadores, necesitará un código diferente.

Ejemplo de jsFiddle: http://jsfiddle.net/timdown/vXnCM/


2
Tu solución funciona perfectamente. Muchas gracias. ¿Existe la posibilidad de que funcione en "contexto de texto", lo que significa que la posición # 5 sería la quinta letra en una pantalla y no la quinta letra en un código?
Frodik

3
@Frodik: Podría usar la setSelectionRange()función de la respuesta que escribí aquí: stackoverflow.com/questions/6240139/… . Como señalé en la respuesta, hay varias cosas que no manejará de manera correcta / consistente, pero puede ser lo suficientemente bueno.
Tim Down

77
¿qué tal establecer el cursor dentro de una etiqueta span como esta: << div id = "editable" contenteditable = "true"> test1 <br> test2 <br> <span> </span> </div>
Med Akram Z

1
@MalcolmOcean: Barf, porque IE <9 no tiene document.createRange(o window.getSelection, pero no llegará tan lejos).
Tim Down

1
@undroid: El jsfiddle funciona bien para mí en Firefox 38.0.5 en Mac.
Tim Down

62

La mayoría de las respuestas que encuentra en el posicionamiento de cursor contento son bastante simplistas, ya que solo atienden entradas con texto simple. Una vez que usa elementos html dentro del contenedor, el texto ingresado se divide en nodos y se distribuye generosamente en una estructura de árbol.

Para establecer la posición del cursor, tengo esta función que recorre todos los nodos de texto secundarios dentro del nodo suministrado y establece un rango desde el inicio del nodo inicial hasta el carácter 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;
};

Luego llamo a la rutina con esta función:

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);
        }
    }
};

Range.collapse (false) establece el cursor al final del rango. Lo probé con las últimas versiones de Chrome, IE, Mozilla y Opera y todas funcionan bien.

PD. Si alguien está interesado, obtengo la posición actual del cursor usando este código:

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;
};

El código hace lo contrario de la función set: obtiene la ventana actual.getSelection (). FocusNode y focusOffset y cuenta hacia atrás todos los caracteres de texto encontrados hasta que llega a un nodo padre con la identificación de containerId. La función isChildOf solo comprueba antes de ejecutar que el nodo suministrado es realmente hijo del parentId proporcionado .

El código debería funcionar directamente sin cambios, pero lo acabo de tomar de un complemento jQuery que he desarrollado, así que he pirateado algunos de estos, ¡ avíseme si algo no funciona!


1
¿Podría proporcionar un jsfiddle de este trabajo por favor? Me cuesta entender cómo funciona esto, ya que no estoy seguro de qué node.idy me parentIdrelaciono sin un ejemplo. Gracias :)
Bendihossan

44
@Bendihossan: intente esto jsfiddle.net/nrx9yvw9/5 ; por alguna razón, el contenido editable div en este ejemplo está agregando algunos caracteres y un retorno de carro al comienzo del texto (incluso podría ser jsfiddle mismo haciéndolo como lo hace) ; hacer lo mismo en mi servidor asp.net).
Liam

@Bendihossan: los elementos html dentro del div contenteditable se dividen en una estructura de árbol con un nodo para cada elemento html. GetCurrentCursorPosition obtiene la posición de selección actual y vuelve al árbol contando cuántos caracteres de texto sin formato hay. Node.id es la identificación del elemento html, mientras que parentId se refiere a la identificación del elemento html a la que debe dejar de contar
Liam

1
Está en mi lista de tareas pendientes para escribir uno que esté completamente separado de mi código de interfaz de usuario; lo publicaré cuando tenga un segundo.
Liam

1
Para poder probar sus diferentes soluciones rápidamente, ¿podría editar su respuesta en fragmentos de código ejecutables? Gracias de antemano.
Basj

3

Si no desea usar jQuery, puede probar este enfoque:

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);
}

editableDivtu elemento editable, no te olvides de configurarlo id. Luego debe obtener su innerHTMLelemento y cortar todas las líneas de freno. Y solo establece el colapso con los siguientes argumentos.


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>

Es muy difícil colocar el cursor en la posición correcta cuando tienes un elemento avanzado como (p) (span), etc. El objetivo es obtener (texto del objeto):

<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
Para poder evaluar su respuesta rápidamente, ¿podría editar su respuesta en un fragmento de código ejecutable? Gracias de antemano.
Basj

1

Estoy escribiendo un resaltador de sintaxis (y un editor de código básico), y necesitaba saber cómo escribir automáticamente un carácter de comillas simples y mover el cursor hacia atrás (como muchos editores de código hoy en día).

He aquí un fragmento de mi solución, gracias a la gran ayuda de este hilo, los documentos de MDN y la observación de muchas consolas 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

Esto está en un elemento div contenteditable

Dejo esto aquí como agradecimiento, dándome cuenta de que ya hay una respuesta aceptada.


1

Hice esto para mi editor de texto simple.

Diferencias con otros métodos:

  • Alto rendimiento
  • Funciona con todos los espacios.

uso

// 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)

selección.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

Refactoré la respuesta de @ Liam. Lo puse en una clase con métodos estáticos, hice que sus funciones recibieran un elemento en lugar de un #id, y algunos otros pequeños ajustes.

Este código es particularmente bueno para fijar el cursor en un cuadro de texto enriquecido con el que podría estar haciendo <div contenteditable="true">. Estuve atrapado en esto durante varios días antes de llegar al siguiente código.

editar: Su respuesta y esta respuesta tienen un error que implica presionar enter. Dado que enter no cuenta como un carácter, la posición del cursor se desordena después de presionar enter. Si puedo corregir el código, actualizaré mi respuesta.

edit2: ahórrate muchos dolores de cabeza y asegúrate de <div contenteditable=true>que sí display: inline-block. Esto corrige algunos errores relacionados con la colocación de Chrome en <div>lugar de <br>presionar Intro.

Cómo utilizar

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();

Código

// 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

Creo que no es simple colocar a Caret en alguna posición en un elemento contento. Escribí mi propio código para esto. Omite el árbol de nodos que calcula cuántos caracteres quedan y establece el símbolo de intercalación en el elemento necesario. No probé mucho este código.

//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;
    }
}

También escribí un código para obtener la posición actual de caret (no probé):

//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;
    }
}

También debe ser consciente de que range.startOffset y range.endOffset contienen desplazamiento de caracteres para nodos de texto (nodeType === 3) y desplazamiento de nodo secundario para nodos de elementos (nodeType === 1). range.startContainer y range.endContainer pueden referirse a cualquier nodo de elemento de cualquier nivel en el árbol (por supuesto, también pueden referirse a nodos de texto).


0

Basado en la respuesta de Tim Down, pero verifica la última fila de texto "buena" conocida. Coloca el cursor al final.

Además, también podría verificar de forma recursiva / iterativa el último hijo de cada último hijo consecutivo para encontrar el último nodo de texto "bueno" absoluto en el 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>

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.