Bagaimana cara mengatur posisi caret (cursor) di elemen contenteditable (div)?

191

Saya punya HTML sederhana ini sebagai contoh:

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

Saya ingin hal yang sederhana - ketika saya mengklik tombol, saya ingin menempatkan tanda (kursor) ke tempat tertentu di div yang dapat diedit. Dari pencarian melalui web, JS ini saya lampirkan pada klik tombol, tetapi tidak berfungsi (FF, Chrome):

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

Apakah mungkin untuk mengatur posisi caret secara manual seperti ini?

Frodik
sumber

Jawaban:

261

Di sebagian besar browser, Anda memerlukan Rangedan Selectionobjek. Anda menentukan masing-masing batas seleksi sebagai simpul dan offset di dalam simpul itu. Misalnya, untuk mengatur tanda sisipan ke karakter kelima dari baris kedua teks, Anda akan melakukan hal berikut:

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 bekerja dengan sangat berbeda. Jika Anda perlu mendukung browser ini, Anda akan memerlukan kode yang berbeda.

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

Tim Down
sumber
2
Solusi Anda bekerja dengan sempurna. Terima kasih banyak. Apakah ada kemungkinan itu dapat dibuat untuk bekerja dalam "konteks teks" - itu berarti posisi # 5 akan menjadi huruf kelima di layar dan bukan huruf kelima dalam kode?
Frodik
3
@Frodik: Anda dapat menggunakan setSelectionRange()fungsi dari jawaban yang saya tulis di sini: stackoverflow.com/questions/6240139/… . Seperti yang saya catat dalam jawaban, ada berbagai hal yang tidak akan ditangani dengan benar / konsisten tetapi mungkin cukup baik.
Tim Down
7
bagaimana dengan mengatur tanda sisipan di dalam tag rentang seperti ini: << div id = "dapat diedit" contenteditable = "true"> test1 <br> test2 <br> <span> </span> </div>
Med Akram Z
1
@ MalcolmOcean: Barf, karena IE <9 tidak memiliki document.createRange(atau window.getSelection, tetapi tidak akan sampai sejauh itu).
Tim Down
1
@undroid: jsfiddle berfungsi dengan baik untuk saya di Firefox 38.0.5 di Mac.
Tim Down
62

Sebagian besar jawaban yang Anda temukan pada penentuan posisi kursor yang dapat diedit cukup sederhana karena hanya memenuhi input dengan teks vanilla biasa. Setelah Anda menggunakan elemen html dalam wadah, teks yang dimasukkan akan dipecah menjadi node dan didistribusikan secara bebas di seluruh struktur pohon.

Untuk mengatur posisi kursor saya memiliki fungsi ini yang loop semua simpul teks anak dalam node yang disediakan dan menetapkan rentang dari awal node awal ke karakter 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;
};

Saya kemudian memanggil rutin dengan fungsi ini:

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) menetapkan kursor ke akhir rentang. Saya sudah mengujinya dengan versi terbaru Chrome, IE, Mozilla dan Opera dan semuanya bekerja dengan baik.

PS. Jika ada yang tertarik, saya mendapatkan posisi kursor saat ini menggunakan kode ini:

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

Kode melakukan kebalikan dari fungsi yang ditetapkan - mendapat jendela saat ini.getSelection (). FocusNode dan focusOffset dan menghitung mundur semua karakter teks yang ditemui sampai menyentuh node induk dengan id dari containerId. Fungsi isChildOf hanya memeriksa sebelum menjalankan bahwa node suplied sebenarnya adalah anak dari parentId yang disediakan .

Kode harus bekerja langsung tanpa perubahan, tetapi saya baru saja mengambilnya dari plugin jQuery yang telah saya kembangkan sehingga telah meretas beberapa dari ini - beri tahu saya jika ada yang tidak berfungsi!

Liam
sumber
1
Bisakah Anda memberikan jsfiddle ini berfungsi tolong? Saya berjuang untuk mencari tahu bagaimana ini bekerja karena saya tidak yakin apa node.iddan parentIdberhubungan dengan tanpa contoh. Terima kasih :)
Bendihossan
4
@Bendihossan - coba ini jsfiddle.net/nrx9yvw9/5 - untuk beberapa alasan, konten div yang dapat diedit dalam contoh ini menambahkan beberapa karakter dan carriage return pada awal teks (bahkan mungkin jsfiddle sendiri yang melakukannya seperti halnya tidak ; t melakukan hal yang sama di server asp.net saya).
Liam
@Bendihossan - elemen html dalam div contenteditable bisa dipecah menjadi struktur pohon dengan satu node untuk setiap elemen html. GetCurrentCursorPosition mendapatkan posisi pemilihan saat ini dan naik kembali pohon menghitung berapa banyak karakter teks biasa. Node.id adalah id elemen html, sedangkan parentId mengacu pada id elemen html yang harusnya berhenti dihitung kembali
Liam
1
Ada dalam daftar todo saya untuk menulis satu yang sepenuhnya terpisah dari kode UI saya - saya akan mempostingnya ketika saya memiliki detik.
Liam
1
Agar dapat menguji solusi yang berbeda dengan cepat, dapatkah Anda mengedit jawaban menjadi potongan kode yang dapat dijalankan? Terima kasih sebelumnya.
Basj
3

Jika Anda tidak ingin menggunakan jQuery, Anda dapat mencoba pendekatan ini:

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

editableDivAnda elemen yang dapat diedit, jangan lupa mengatur iduntuk itu. Maka Anda harus mendapatkan innerHTMLdari elemen dan memotong semua garis rem. Dan atur runtuh dengan argumen berikutnya.

Volodymyr Khmil
sumber
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);
  }
Sagar M
sumber
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>

Ini sangat sulit mengatur tanda pada posisi yang tepat ketika Anda memiliki elemen muka seperti (p) (rentang) dll. Tujuannya adalah untuk mendapatkan (objek teks):

<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>
Jalaluddin Rumi
sumber
1
Agar dapat menguji jawaban Anda dengan cepat, dapatkah Anda mengedit jawaban menjadi potongan kode yang dapat dijalankan? Terima kasih sebelumnya.
Basj
1

Saya sedang menulis highlighter sintaks (dan editor kode dasar), dan saya perlu tahu bagaimana cara mengetikkan char tanda kutip tunggal secara otomatis dan memindahkan tanda sisipan (seperti banyak editor kode saat ini).

Inilah cuplikan dari solusi saya, terima kasih banyak bantuan dari utas ini, MDN docs, dan banyak moz console menonton ..

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

Ini dalam elemen div yang dapat diedit

Saya meninggalkan ini di sini sebagai ucapan terima kasih, menyadari sudah ada jawaban yang diterima.

Jonathan Crowder
sumber
1

Saya membuat ini untuk editor teks sederhana saya.

Perbedaan dari metode lain:

  • Performa tinggi
  • Bekerja dengan semua ruang

pemakaian

// 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('')
}
Nikolay Makhonin
sumber
1

Saya refactored jawaban Liam. Saya letakkan di kelas dengan metode statis, saya membuat fungsinya menerima elemen, bukan #id, dan beberapa tweak kecil lainnya.

Kode ini sangat baik untuk memperbaiki kursor di kotak teks kaya yang mungkin Anda buat <div contenteditable="true"> . Saya terjebak dalam hal ini selama beberapa hari sebelum tiba di kode di bawah ini.

sunting: Jawabannya dan jawaban ini memiliki bug yang melibatkan hitting enter. Karena enter tidak dihitung sebagai karakter, posisi kursor akan kacau setelah menekan enter. Jika saya dapat memperbaiki kode, saya akan memperbarui jawaban saya.

edit2: Simpan sendiri banyak sakit kepala dan pastikan Anda <div contenteditable=true>yaitu display: inline-block. Ini memperbaiki beberapa bug yang terkait dengan penempatan Chrome <div>alih-alih <br>saat Anda menekan enter.

Cara Penggunaan

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

Kode

// 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;
    }
}
AdmiralThrawn
sumber
0

Saya pikir itu tidak mudah untuk mengatur tanda sisipan ke beberapa posisi dalam elemen yang dapat diedit. Saya menulis kode saya sendiri untuk ini. Ini memotong simpul pohon calving berapa banyak karakter yang tersisa dan menetapkan tanda sisipan dalam elemen yang diperlukan. Saya tidak banyak menguji kode ini.

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

Saya juga menulis kode untuk mendapatkan posisi tanda kuret saat ini (tidak menguji):

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

Anda juga perlu mengetahui range.startOffset dan range.endOffset berisi offset karakter untuk node teks (nodeType === 3) dan offset simpul anak untuk node elemen (nodeType === 1). range.startContainer dan range.endContainer dapat merujuk ke simpul elemen apa pun dari tingkat apa pun di pohon (tentu saja mereka juga dapat merujuk ke simpul teks).

vitaliydev
sumber
0

Berdasarkan jawaban Tim Down, tetapi memeriksa baris teks "baik" yang terakhir diketahui. Ini menempatkan kursor di bagian paling akhir.

Selain itu, saya juga bisa secara rekursif / iteratif memeriksa anak terakhir dari setiap anak terakhir berturut-turut untuk menemukan simpul teks "baik" terakhir yang absolut dalam 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>

Tuan Polywhirl
sumber