6

I have a bunch of multiline contenteditable divs arranged vertically, and I want to allow for natural navigation between them by arrow keys (as if it was one document). For that, on keydown event I need to:

  • Know current row of caret and number of rows to determine if we need to move up (first line and ↑ key pressed) or down (last line and ↓ key)
  • Know current character (position in a shown string) to determine if we need to move up (position==0 and ← key pressed) or down (position==text.length and → pressed)
  • The process should not stop between switching elements when the key is being held and not released (hence keydown event, not keyup)
  • Preferrably: The event should stop propagating (for example, if I'm on the first column on last row and I press ↓ key, it should not jump to the last character on the line and then go down)
  • Preferrably (would be really awesome): After we jump to the next element, we would not just .focus() the element, but emulate a click in the same vertical position as where we were before, so that it would feel natural, like in text editors.

All scripts/libraries I had found to date are either not doing all things I need or buggy. Please include demos in your suggestions, so that I can test without incorporating in my code first. Thanks!

Update: Visual explanation - note that there are more than 2 divs and 'arrow down key on the last line' is just one of the four triggers

enter image description here

Adjit
  • 10,134
  • 12
  • 53
  • 98
Serge Uvarov
  • 113
  • 9
  • What do you want to achieve exactly ? Something which looks like Excel ? When you click a text in a row, where do you want to put the caret? At the end of the row ? Where you clicked ? How the contenteditable divs are loaded in the DOM ? Do you load it with JS or directly in HTML ? Do you have columns or just rows ? – R. Foubert Jul 18 '16 at 15:46
  • @R.Foubert, no, not Excel, but like Notepad or WYSIWYG editors. There's no columns, just paragraphs with rows/lines, and the paragraphs must be separate div blocks. Those blocks are loaded by AJAX and saved by AJAX. When you click the text, the caret should be at the clicked pixel, just like any editor. – Serge Uvarov Jul 18 '16 at 15:55
  • and just one other question. Why do you need contenteditable? You could use textarea for example so why did you chose contenteditable? – R. Foubert Jul 18 '16 at 16:03
  • To make possible rich-formatting and other things, just like in WYSIWYG editors. I also added a visual explanation in the question. And pracically I need multiple divs because it's a hierarchical list, not exactly one block under another, but I didn't want to overcomplicate the question. I just need an answer to this simplifed problem and I'll adapt it to my application. – Serge Uvarov Jul 18 '16 at 16:08
  • Yes I just saw it. I think I understand your need. It will need some code :p I'll try to write something down for tomorrow if I have some time ;) – R. Foubert Jul 18 '16 at 16:10
  • Thanks, appreciate your attention. – Serge Uvarov Jul 18 '16 at 16:12
  • Do you need some feature like 'enter is pressed' => 'new paragraph' => insert new contenteditable div ? And just to be sure, what do you mean by 'loaded by AJAX' ? You get the whole text by AJAX and you split it into paragraphs using JS ? Or you get a list of paragraphs directly ? – R. Foubert Jul 18 '16 at 16:15
  • 2 posts I recommend - http://stackoverflow.com/questions/5528004/how-to-get-number-of-rows-in-contenteditable-area-and-current-caret-line-positio and http://stackoverflow.com/questions/3972014/get-caret-position-in-contenteditable-div – Adjit Jul 18 '16 at 16:32
  • @R.Foubert, yes, I need to create a new node on 'Enter is pressed' and other events, but those are the easy part and therefore not part of the question. I get a list of paragraphs from the database and generate contenteditable nodes based on this, one under another. – Serge Uvarov Jul 18 '16 at 16:56
  • @Adjit, thanks, I saw them, but they are not answering the whole question. I was thinking there is already some script or library for tracking caret. I mean, that's how all those WYSIWYG editors work, right? So, figured it should be common knowledge by now. – Serge Uvarov Jul 18 '16 at 16:58

2 Answers2

4

I already wrote some code but it's not finished at all... Maybe you can start with that and try to complete what I've done if you want ;) I'll continue working on it this week in order to provide you with a solution... Here is what I've done so far :

var ajaxResult = [
  "Inter has ruinarum varietates a Nisibi quam tuebatur accitus Vrsicinus, cui nos obsecuturos iunxerat imperiale praeceptum, dispicere litis exitialis certamina cogebatur. Inter has ruinarum varietates a Nisibi quam tuebatur accitus Vrsicinus, cui nos obsecuturos iunxerat imperiale praeceptum, dispicere litis exitialis certamina cogebatur. Inter has ruinarum varietates  exitialis certamina cogebatur",
  "Inter has ruinarum varietates a Nisibi quam tuebatur accitus",
  "Inter has ruinarum varietates a Nisibi quam tuebatur accitus Vrsicinus, cui nos obsecuturos iunxerat imperiale praeceptum, dispicere litis exitialis certamina cogebatur. Inter has ruinarum varietates a Nisibi quamos iunxerat imperiale praeceptum, dispicere litis exitialis certamina cogebatur. Inter has ruinarum varietates  exitialis certamina cogebatur",
];
/*************************************************************
* 
* LIST OF CONTENT EDITABLE DIVS MANAGEMENT
*
**************************************************************/
// Create the editable divs
window.onload = function(){
  var contentEditables = createContentEditables();
  document.body.appendChild(contentEditables);
}

// Remember all the content editable elements in the order they appear in the dom
var _currentEdit,
 _edits = [];
 
function createContentEditables(){
  var div;
  var result = document.createDocumentFragment();
  for (var i = 0, n = ajaxResult.length ; i < n ; i++){
    div = createContentEditable(ajaxResult[i]);
    _edits.push(div);
    result.appendChild(div);
  }

  return result;
}

function getPreviousEdit(edit){
  // Search for the edit index
  var index = _edits.indexOf(edit);

  if(index == 0)
    return;

  // Return the previous one
  return _edits[index - 1];
}

function getNextEdit(edit){
  // Search for the edit index
  var index = _edits.indexOf(edit);

  if(index == _edits.length - 1)
    return;

  // Return the previous one
  return _edits[index + 1];
}

/*************************************************************
* 
* CONTENT EDITABLE MANAGEMENT
*
**************************************************************/
// We need to define the line height of the div to be able to retrieve the number of lines
var LINE_HEIGHT = 16;

// variables to keep trace of relevant information about the div
var _lines, _caretPosition;

/*
* Create a div with contenteditable set to true with the text
* received from the server
*/
function createContentEditable(text){
  var element =  document.createElement('div');
  element.className = 'contenteditable';
  element.innerHTML = text;
  element.style.lineHeight = LINE_HEIGHT + 'px';
  element.setAttribute('contenteditable', true);

  // Set listeners
  element.addEventListener('mouseup', onEdit_mouseup);
  element.addEventListener('keydown', onEdit_keydown);
  element.addEventListener('focus', onEdit_focus);

  return element;
}

function onEdit_keydown(domEvent){
  // Update caret position
  _caretPosition = getCaretPosition(domEvent.target);
  switch(domEvent.keyCode){
    case 37: // left arrow
      if (_caretPosition.index == 0){
        var previousEdit = getPreviousEdit(domEvent.target);
        if(previousEdit){
          console.log("go to end of previous edit");
          console.log(previousEdit);
          previousEdit.focus();
        }
      }
      break;
    case 38: // up arrow
      if (_caretPosition.line == 1){
        var previousEdit = getPreviousEdit(domEvent.target);
        if(previousEdit){
          console.log("go to previous edit keeping the caret offset");
          console.log(previousEdit);
          previousEdit.focus();
        }
      }
      break;
    case 39: // right arrow
      if (_caretPosition.index == domEvent.target.innerHTML.length){
        var nextEdit = getNextEdit(domEvent.target);
        if(nextEdit){
          console.log("go to beginning of next edit");
          console.log(nextEdit);
          nextEdit.focus();
        }
      }
      break;
    case 40: // down arrow
      if (_caretPosition.line == getLines(domEvent.target)){
        var nextEdit = getNextEdit(domEvent.target);
        if(nextEdit){
          console.log("go to next edit keeping the caret offset");
          console.log(nextEdit);
          nextEdit.focus();
        }
      }
      break;
  }
}

function onEdit_mouseup(domEvent){
  // Update caret position
  _caretPosition = getCaretPosition(domEvent.target);
}

function onEdit_focus(domEvent){
  // Add listeners
  _currentEdit = domEvent.target;
  _currentEdit.addEventListener('blur', onEdit_blur);
  window.addEventListener('resize', onWindow_resize);
}

function onEdit_blur(domEvent){
  // Remove listeners
  domEvent.target.removeEventListener('blur', onEdit_blur);
  window.removeEventListener('resize', onWindow_resize);
}

function onWindow_resize(domEvent){
  // Update caret position
  _caretPosition = getCaretPosition(_currentEdit);
}

/*************************************************************
* 
* HELPERS
*
**************************************************************/
//http://stackoverflow.com/questions/4811822/get-a-ranges-start-and-end-offsets-relative-to-its-parent-container/4812022#4812022
//http://stackoverflow.com/questions/5528004/how-to-get-number-of-rows-in-contenteditable-area-and-current-caret-line-positio
function getCaretPosition(element){
  var caretPosition = {index: 0, line: 0};
  var doc = element.ownerDocument || element.document;
  var win = doc.defaultView || doc.parentWindow;
  var elemOffsetTop = element.offsetTop;
  var sel;
  // Get the x position of the caret
  if (typeof win.getSelection != "undefined") {
    sel = win.getSelection();
    if (sel.rangeCount > 0) {
      var range = win.getSelection().getRangeAt(0);
      // Retrieve the current line
      var rects = range.getClientRects();
      var caretOffsetTop;
      if (typeof rects[1] != "undefined"){
        caretOffsetTop = rects[1].top;
      }
      else if (typeof rects[0] != "undefined"){
        caretOffsetTop = rects[0].top;
      }
      else{
        // Create dummy element to get y position of the caret
        var dummy = document.createElement('CANVAS');
        dummy.id = 'findCaretHelper';
        range.insertNode(dummy);
        caretOffsetTop = dummy.offsetTop;
        element.removeChild(dummy);
      }

      var preCaretRange = range.cloneRange();
      preCaretRange.selectNodeContents(element);
      preCaretRange.setEnd(range.endContainer, range.endOffset);

      // Remember caret position
      caretPosition.index = preCaretRange.toString().length;
      caretPosition.line = Math.ceil((caretOffsetTop - elemOffsetTop)/LINE_HEIGHT) + 1;
    }
  } 
  // support ie
  //else if ( (sel = doc.selection) && sel.type != "Control") {
  //var textRange = sel.createRange();
  //var preCaretTextRange = doc.body.createTextRange();
  //preCaretTextRange.moveToElementText(element);
  //preCaretTextRange.setEndPoint("EndToEnd", textRange);
  //caretPosition.x = preCaretTextRange.text.length;
  //}

  return caretPosition;
}

function getLines(element){ 
  return element.clientHeight/LINE_HEIGHT;;
}
.contenteditable{
  border: solid 1px #aaa;
  margin: 10px 0;
}

I managed getting information about the current line, the current character index in the content editable div and some other stuff... I still have to work on focusing an other content editable div in order to put the caret at the right place... I hope this beginning of a solution will help you!

R. Foubert
  • 633
  • 4
  • 8
  • This is what I need! Just two small things remain: the event is being fired after the jump (so when you press down arrow key, you end up on the 2nd line of next element instead of 1st), but I think this is easily fixable via event.stopPropagation. And the second, as you already said, - would be really awesome if you found a way to emulate the click on the same horizontal position as we were before a jump. This would be the perfect solution. Thanks! – Serge Uvarov Jul 20 '16 at 13:57
  • Yes I still have a few bugs but I think it's the way to go ;) Fell free to improve what I've done if you have some time... I'll try to finish that this week as I told you ;) haha you're welcome ;) PS: just something to notice, I had to use a fixed line height on order to be able to deal with counting lines etc... I hope you're ok with that ? – R. Foubert Jul 20 '16 at 13:59
  • I can live with fixed line height, but I think we can do better. What about inserting dummy 1x1 span at the end of the contenteditable, and assume that if the caret's vertical position >= this span's offsetY, than it must be the last line? What do you think? That should get more reliable results than calculations based on line height. – Serge Uvarov Jul 20 '16 at 16:15
  • mhhh yeah why not it could be a good idea :D let's try that ! – R. Foubert Jul 20 '16 at 16:21
  • any luck with that yet? – Serge Uvarov Jul 25 '16 at 11:09
  • Sorry I didnt have time to have a look this weekend... I'll try to do something asap. Did you already try to modify my code a little bit ? – R. Foubert Jul 25 '16 at 15:48
0

You could simply make the parent element / containing element contenteditable as opposed to each paragraph. This will automatically add / remove p tags accordingly.

https://jsfiddle.net/ewesmwmv/2/

connorb
  • 301
  • 4
  • 16
  • Right, that would be a really simple solution, but unfortunately, it's impossible. It has to be multiple contenteditables, because they are being loaded via AJAX, automatically, and they represent separate records (nodes) in the database. – Serge Uvarov Jul 20 '16 at 13:51
  • Could you not, load the content via AJAX append it to the editable parent, give it some form of identifier (so we can trace where it needs to go) then on save, simply split them back up and save them into the corresponding locations... Doing so seems a much more straightforward approach. – connorb Jul 20 '16 at 13:58
  • Yes, well, I simplifed the question, but in my application it's actually a hierarchical list, where each node has a bullet on the left. If I make it all contenteditable, users will be able to delete bullets and do other nasty things to break the layout, and that's undesired behavior. Hope this helps to clarify the initial conditions of the question. – Serge Uvarov Jul 20 '16 at 14:04