10

i need to implement highlight for numbers( in future im add more complex rules ) in the contenteditable div. The problem is When im insert new content with javascript replace, DOM changes and contenteditable div lost focus. What i need is keep focus on div with caret on the current position, so users can just type without any issues and my function simple highlighting numbers. Googling around i decide that Rangy library is the best solution. I have following code:

function formatText() {
         
              var savedSel = rangy.saveSelection();
              el = document.getElementById('pad');
              el.innerHTML = el.innerHTML.replace(/(<([^>]+)>)/ig,"");
              el.innerHTML = el.innerHTML.replace(/([0-9])/ig,"<font color='red'>$1</font>");
              rangy.restoreSelection(savedSel);
          }

<div contenteditable="true" id="pad" onkeyup="formatText();"></div>

The problem is after function end work focus is coming back to the div, but caret always point at the div begin and i can type anywhere, execept div begin. Also console.log types following Rangy warning: Module SaveRestore: Marker element has been removed. Cannot restore selection. Please help me to implement this functional. Im open for another solutiona, not only rangy library. Thanks!

http://jsfiddle.net/2rTA5/ This is jsfiddle, but it dont work properly(nothing happens when i typed numbers into my div), dunno maybe it me (first time post code via jsfiddle) or resource doesnt support contenteditable. UPD* Im read similar problems on stackoverflow, but solutions doesnt suit to my case :(

Community
  • 1
  • 1
Petya petrov
  • 2,153
  • 5
  • 23
  • 35

2 Answers2

24

The problem is that Rangy's save/restore selection module works by inserting invisible marker elements into the DOM where the selection boundaries are and then your code strips out all HTML tags, including Rangy's marker elements (as the error message suggests). You have two options:

  1. Move to a DOM traversal solution for colouring the numbers rather than innerHTML. This will be more reliable but more involved.
  2. Implement an alternative character index-based selection save and restore. This would be generally fragile but will do what you want in this case.

UPDATE

I've knocked up a character index-based selection save/restore for Rangy (option 2 above). It's a little rough, but it does the job for this case. It works by traversing text nodes. I may add this into Rangy in some form. (UPDATE 5 June 2012: I've now implemented this, in a more reliable way, for Rangy.)

jsFiddle: http://jsfiddle.net/2rTA5/2/

Code:

function saveSelection(containerEl) {
    var charIndex = 0, start = 0, end = 0, foundStart = false, stop = {};
    var sel = rangy.getSelection(), range;

    function traverseTextNodes(node, range) {
        if (node.nodeType == 3) {
            if (!foundStart && node == range.startContainer) {
                start = charIndex + range.startOffset;
                foundStart = true;
            }
            if (foundStart && node == range.endContainer) {
                end = charIndex + range.endOffset;
                throw stop;
            }
            charIndex += node.length;
        } else {
            for (var i = 0, len = node.childNodes.length; i < len; ++i) {
                traverseTextNodes(node.childNodes[i], range);
            }
        }
    }

    if (sel.rangeCount) {
        try {
            traverseTextNodes(containerEl, sel.getRangeAt(0));
        } catch (ex) {
            if (ex != stop) {
                throw ex;
            }
        }
    }

    return {
        start: start,
        end: end
    };
}

function restoreSelection(containerEl, savedSel) {
    var charIndex = 0, range = rangy.createRange(), foundStart = false, stop = {};
    range.collapseToPoint(containerEl, 0);

    function traverseTextNodes(node) {
        if (node.nodeType == 3) {
            var nextCharIndex = charIndex + node.length;
            if (!foundStart && savedSel.start >= charIndex && savedSel.start <= nextCharIndex) {
                range.setStart(node, savedSel.start - charIndex);
                foundStart = true;
            }
            if (foundStart && savedSel.end >= charIndex && savedSel.end <= nextCharIndex) {
                range.setEnd(node, savedSel.end - charIndex);
                throw stop;
            }
            charIndex = nextCharIndex;
        } else {
            for (var i = 0, len = node.childNodes.length; i < len; ++i) {
                traverseTextNodes(node.childNodes[i]);
            }
        }
    }

    try {
        traverseTextNodes(containerEl);
    } catch (ex) {
        if (ex == stop) {
            rangy.getSelection().setSingleRange(range);
        } else {
            throw ex;
        }
    }
}

function formatText() {
    var el = document.getElementById('pad');
    var savedSel = saveSelection(el);
    el.innerHTML = el.innerHTML.replace(/(<([^>]+)>)/ig,"");
    el.innerHTML = el.innerHTML.replace(/([0-9])/ig,"<font color='red'>$1</font>");

    // Restore the original selection
    restoreSelection(el, savedSel);
}
John
  • 1,210
  • 5
  • 23
  • 51
Tim Down
  • 318,141
  • 75
  • 454
  • 536
  • 1
    wow! thanks a lot Tim. one little but important issue, after pressing enter caret goes at the start instead of new line. This is important for me. User need to input multiline text. How to get this functionallity? – Petya petrov Apr 09 '11 at 11:38
  • 1
    @Mikhail: I'll look into it tomorrow. – Tim Down Apr 09 '11 at 21:37
  • 1
    @Mikhail: The problem with pressing Enter is that your code removes any `
    ` or `
    ` element the browser inserts for the line break. If handling this is important to you, you would probably be better off using a DOM traversal approach to colouring numbers.
    – Tim Down Apr 10 '11 at 20:23
  • sorry for my dumbness, but can you describe more this traversal way, or give me a link, big thanks to all your work and your library. – Petya petrov Apr 10 '11 at 21:03
  • @Mikhail: I'd use a recursive function to check each text node within the container element for numbers. It is admittedly a bit involved but has the benefit of preserving existing elements. Here's some examples that you may be able to adapt: http://stackoverflow.com/questions/4040495/dom-wrapping-a-substring-in-textnode-with-a-new-span-node/4045531#4045531 and http://stackoverflow.com/questions/2798142/tag-like-autocompletion-and-caret-cursor-movement-in-contenteditable-elements/4026684#4026684 – Tim Down Apr 10 '11 at 21:21
  • @TimDown ya also the corner case with your selection goes across multiple elements within a parent, it kinda breaks. or maybe i'm just not sure which element to pass in – daedelus_j Apr 03 '14 at 17:09
  • The link for " I've now implemented this, in a more reliable way, for Rangy." doesn't work. – John Sep 21 '16 at 21:28
0

I would like to thank Tim for the function he shared here with us, it was very important for a project I'm working on. I embeded his function a small jQuery plugin which can be accessed here: https://jsfiddle.net/sh5tboL8/

$.fn.get_selection_start = function(){
    var result = this.get(0).selectionStart;
    if (typeof(result) == 'undefined') result = this.get_selection_range().selection_start;
    return result;
}

$.fn.get_selection_end = function(){
    var result = this.get(0).selectionEnd;
    if (typeof(result) == 'undefined') result = this.get_selection_range().selection_end;
    return result;
}

$.fn_get_selected_text = function(){
    var value = this.get(0).value;
    if (typeof(value) == 'undefined'){
        var result = this.get_selection_range().selected_text;
    }else{
        var result = value.substring(this.selectionStart, this.selectionEnd);
    }
    return result;
}

$.fn.get_selection_range = function(){

    var range = window.getSelection().getRangeAt(0);
    var cloned_range = range.cloneRange();
    cloned_range.selectNodeContents(this.get(0));
    cloned_range.setEnd(range.startContainer, range.startOffset);
    var selection_start = cloned_range.toString().length;
    var selected_text = range.toString();
    var selection_end = selection_start + selected_text.length;
    var result = {
        selection_start: selection_start,
        selection_end: selection_end,
        selected_text: selected_text
    }
    return result;
}

$.fn.set_selection = function(selection_start, selection_end){
    var target_element = this.get(0);
    selection_start = selection_start || 0;
    if (typeof(target_element.selectionStart) == 'undefined'){
        if (typeof(selection_end) == 'undefined') selection_end = target_element.innerHTML.length;

        var character_index = 0;
        var range = document.createRange();
        range.setStart(target_element, 0);
        range.collapse(true);
        var node_stack = [target_element];
        var node = null;
        var start_found = false;
        var stop = false;

        while (!stop && (node = node_stack.pop())) {
            if (node.nodeType == 3){
                var next_character_index = character_index + node.length;
                if (!start_found && selection_start >= character_index && selection_start <= next_character_index){
                    range.setStart(node, selection_start - character_index);
                    start_found = true;
                }

                if (start_found && selection_end >= character_index && selection_end <= next_character_index){
                    range.setEnd(node, selection_end - character_index);
                    stop = true;
                }
                character_index = next_character_index;
            }else{
                var child_counter = node.childNodes.length;
                while (child_counter --){
                    node_stack.push(node.childNodes[child_counter]);
                }
            }
        }

        var selection = window.getSelection();
        selection.removeAllRanges();
        selection.addRange(range);
    }else{
        if (typeof(selection_end) == 'undefined') selection_end = target_element.value.length;
        target_element.focus();
        target_element.selectionStart = selection_start;
        target_element.selectionEnd = selection_end;
    }
}

plugin does only what I needed it to do, get selected text, and setting custom text selection. It also works on textboxes and contentEditable divs.

domaci_a_nas
  • 220
  • 2
  • 11