10

I'm try to traverse between contenteditable paragraphs using the arrow keys. I can't put a containing div around all paragraphs as the may be divided by other non-editable elements.

I need to be able to determine the character length of the first line so that when the up arrow key is pressed when the cursor is on the line then it will jump up to the previous paragraph - hopefully keeping the cursor position relative to the line.

I can get the cursor index with:

function cursorIndex() {
    return window.getSelection().getRangeAt(0).startOffset;
}

and set it with: as found here - Javascript Contenteditable - set Cursor / Caret to index

var setSelectionRange = function(element, start, end) {
    var rng = document.createRange(),
        sel = getSelection(),
        n, o = 0,
        tw = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, null);
    while (n = tw.nextNode()) {
        o += n.nodeValue.length;
        if (o > start) {
            rng.setStart(n, n.nodeValue.length + start - o);
            start = Infinity;
        }
        if (o >= end) {
            rng.setEnd(n, n.nodeValue.length + end - o);
            break;
        }
    }
    sel.removeAllRanges();
    sel.addRange(rng);
};

var setCaret = function(element, index) {
    setSelectionRange(element, index, index);
};

Say the cursor is at the top row of the third paragraph and the up arrow is pressed, I would like it to jump to the bottom row of the second paragraph

http://jsfiddle.net/Pd52U/2/

Community
  • 1
  • 1
Ryan King
  • 3,538
  • 12
  • 48
  • 72
  • 1
    What are you trying to do that the normal behavior of the arrow keys doesn't do? – kennebec Apr 24 '13 at 14:47
  • Say the cursor is at the top row of the third paragraph and the up arrow is pressed, I would like it to jump to the bottom rom of the second paragraph. – Ryan King Apr 25 '13 at 00:47
  • 2
    I've implemented something like this before. There's no way to do it in all browsers other than tediously measuring character positions and sizes. – Tim Down Apr 25 '13 at 08:59
  • 1
    This is nontrivial. That said, I /think/ I saw a question about Javascript font metrics somewhere where the comments included a library for this, you might want to search for it. – millimoose Apr 25 '13 at 15:13
  • @TimDown Character sizes aren't completely exact. (Because of ligatures and kerning.) Although they could be a decent approximation, if you're going to approximate you might as well just use the size of an `em` which is the same as the font height. – millimoose Apr 25 '13 at 15:14
  • @millimoose: I know, I had to deal with that. In my particular application each word is in its own span, so I did it by measuring substrings of each word. I wouldn't recommend it in general: there are browser issues that it took me a long time to work around, particularly for words at the start or end of a line. – Tim Down Apr 25 '13 at 15:38

1 Answers1

3

Looks like there's no easy way to do this, I have the following working example. There's a bit of processing so it's a little slow and it can be out by the odd character when moving up and down between paragraph.

Please inform me of any improvements that can be made.

http://jsfiddle.net/zQUhV/47/


What I've done is split the paragraph by each work, insert them into a new element one by one, checking for a height change - when it does change a new line was added.

This function returns an array of line objects containing the line text, starting index and end index:

(function($) {
$.fn.lines = function(){
    words = this.text().split(" "); //split text into each word
    lines = [];

    hiddenElement = this.clone(); //copies font settings and width
    hiddenElement.empty();//clear text
    hiddenElement.css("visibility", "hidden");

    jQuery('body').append(hiddenElement); // height doesn't exist until inserted into document

    hiddenElement.text('i'); //add character to get height
    height = hiddenElement.height();
    hiddenElement.empty();

    startIndex = -1; // quick fix for now - offset by one to get the line indexes working
    jQuery.each(words, function() {
      lineText = hiddenElement.text(); // get text before new word appended
      hiddenElement.text(lineText + " " + this);
        if(hiddenElement.height() > height) { // if new line
            lines.push({text: lineText, startIndex: startIndex, endIndex: (lineText.length + startIndex)}); // push lineText not hiddenElement.text() other wise each line will have 1 word too many
            startIndex = startIndex + lineText.length +1;
            hiddenElement.text(this); //first word of the next line
        }
   });
    lines.push({text: hiddenElement.text(), startIndex: startIndex, endIndex: (hiddenElement.text().length + startIndex)}); // push last line
    hiddenElement.remove();
    lines[0].startIndex = 0; //quick fix for now - adjust first line index
    return lines;
}
})(jQuery);

Now you could use that to measure the number of character up until the point of the cursor and apply that when traversing paragraph to keep the cursor position relative to the start of the line. However that can produce wildly inaccurate results when considering the width of an 'i' to the width of an 'm'.

Instead it would be better to find the width of the line up to the point of the cursor:

function distanceToCaret(textElement,caretIndex){

    line = findLineViaCaret(textElement,caretIndex);
    if(line.startIndex == 0) { 
     // +1 needed for substring to be correct but only first line?
        relativeIndex = caretIndex - line.startIndex +1;
    } else {
      relativeIndex = caretIndex - line.startIndex;  
    }
    textToCaret = line.text.substring(0, relativeIndex);

    hiddenElement = textElement.clone(); //copies font settings and width
    hiddenElement.empty();//clear text
    hiddenElement.css("visibility", "hidden");
    hiddenElement.css("width", "auto"); //so width can be measured
    hiddenElement.css("display", "inline-block"); //so width can be measured

    jQuery('body').append(hiddenElement); // doesn't exist until inserted into document

    hiddenElement.text(textToCaret); //add to get width
    width = hiddenElement.width();
    hiddenElement.remove();

    return width;
}
function findLineViaCaret(textElement,caretIndex){
    jQuery.each(textElement.lines(), function() {
        if(this.startIndex <= caretIndex && this.endIndex >= caretIndex) {
            r = this;
            return false; // exits loop
        }
   });
    return r;
}

Then split the target line up into characters and find the point that closest matches the width above by adding characters one by one until the point is reached:

function getCaretViaWidth(textElement, lineNo, width) {
    line = textElement.lines()[lineNo-1];

    lineCharacters = line.text.replace(/^\s+|\s+$/g, '').split("");

    hiddenElement = textElement.clone(); //copies font settings and width
    hiddenElement.empty();//clear text
    hiddenElement.css("visibility", "hidden");
    hiddenElement.css("width", "auto"); //so width can be measured
    hiddenElement.css("display", "inline-block"); //so width can be measured

    jQuery('body').append(hiddenElement); // doesn't exist until inserted into document

    if(width == 0) { //if width is 0 index is at start
        caretIndex = line.startIndex;
    } else {// else loop through each character until width is reached
        hiddenElement.empty();
        jQuery.each(lineCharacters, function() {
            text = hiddenElement.text();
            prevWidth = hiddenElement.width();
            hiddenElement.text(text + this);
            elWidth = hiddenElement.width();
            caretIndex = hiddenElement.text().length + line.startIndex;
            if(hiddenElement.width() > width) {
                // check whether character after width or before width is closest
                if(Math.abs(width - prevWidth) < Math.abs(width - elWidth)) {
                   caretIndex = caretIndex -1; // move index back one if previous is closes
                }
                return false;
            }
        });
    }
    hiddenElement.remove();
    return caretIndex;
}

That with the following keydown function is enough to traverse pretty accurately between contenteditable paragraphs:

$(document).on('keydown', 'p[contenteditable="true"]', function(e) {
    //if cursor on first line & up arrow key
    if(e.which == 38 && (cursorIndex() < $(this).lines()[0].text.length)) { 
        e.preventDefault();
        if ($(this).prev().is('p')) {
            prev = $(this).prev('p');
            getDistanceToCaret = distanceToCaret($(this), cursorIndex());
            lineNumber = prev.lines().length;
            caretPosition = getCaretViaWidth(prev, lineNumber, getDistanceToCaret);
            prev.focus();
            setCaret(prev.get(0), caretPosition);
        }
    // if cursor on last line & down arrow
    } else if(e.which == 40 && cursorIndex() >= $(this).lastLine().startIndex && cursorIndex() <= ($(this).lastLine().startIndex + $(this).lastLine().text.length)) {
        e.preventDefault();
        if ($(this).next().is('p')) {
            next = $(this).next('p');
            getDistanceToCaret = distanceToCaret($(this), cursorIndex());
            caretPosition = getCaretViaWidth(next, 1, getDistanceToCaret);
            next.focus();
            setCaret(next.get(0), caretPosition);
        }
        //if start of paragraph and left arrow
    } else if(e.which == 37 && cursorIndex() == 0) {
        e.preventDefault();
        if ($(this).prev().is('p')) {
            prev = $(this).prev('p');
            prev.focus();
            setCaret(prev.get(0), prev.text().length); 
        }
        // if end of paragraph and right arrow
    } else if(e.which == 39 && cursorIndex() == $(this).text().length) {
        e.preventDefault();
        if ($(this).next().is('p')) {
            $(this).next('p').focus();
        }
    };
Ryan King
  • 3,538
  • 12
  • 48
  • 72
  • I was looking for this answer for to long! Thank u very much! I have a question for you. I have different code structure, like this: there are many lists, and within lists there is a div and after div is p(contenteditable). I used your code, but I cannot find this next p(contenteditable). How can I achieve that? – Teuta Koraqi Jun 23 '16 at 08:14