Search code examples
javascriptwysiwygcontenteditable

Caret position, new lines and undo in contenteditable block


In example below I have a contenteditable block where I implemented multiple underline algorithm. Every line starts at first appearance of a letter and stops at last appearance. For example blue line starts at first letter "a" and stops at last: enter image description here

User can enter new letters to update lines: enter image description here enter image description here

There are 3 problems with this examples:

  1. On every input carets jumps to start of text. It happens because on every key press I update entire html inside contenteditable. I tried to save and restore caret position as suggested in Saving and Restoring caret position for contentEditable div. But I am not sure this solution works across different browsers. And in general code looks dirty.

  2. User can not enter new line. In contenteditable instead in of \n symbols <div><br/></div> is added.

  3. When I press Ctrl+Z undo does not happen.

I am not experiences in Javascript and Web-development in general. Could you please help me fix these problems?

It seems to me that there must be some good solution. There are a lot of WYSIWYG editors on internet. They must somehow solved these issues?

Maybe there are some standard libraries to solved these issues?

var TEXT = $('#text');

var COLORS = [
  '#1f77b4',
  '#ff7f0e',
  '#2ca02c',
  '#d62728',
  '#9467bd',
  '#e377c2',
  '#bcbd22',
  '#17becf',
];


function makeSpan(start, stop, type, level) {
  return {
    start: start,
    stop: stop,
    type: type,
    level: level
  }
}


function parse(text) {
  var mins = {};
  var maxes = {};
  for (var index = 0; index < text.length; index++) {
    var char = text[index];
    if (char.match(/\s/)) {
      continue;
    }
    var min = mins[char];
    if (min == undefined) {
      mins[char] = index;
    }
    var max = maxes[char];
    if ((max == undefined) || (index > max)) {
      maxes[char] = index;
    }
  }
  var spans = [];
  for (var char in mins) {
    var min = mins[char];
    var max = maxes[char];
    if (max > min) {
      var span = makeSpan(min, max + 1, char);
      spans.push(span);
    }
  }
  return spans;
}


function querySpans(spans, value) {
  var results = [];
  spans.forEach(function(span) {
    if ((span.start <= value) && (value < span.stop)) {
      results.push(span)
    }
  });
  return results;
}


function getMaxLevel(spans) {
  var level = -1;
  spans.forEach(function(span) {
    if (level < span.level) {
      level = span.level;
    }
  });
  return level;
}


function levelSpans(spans) {
  var results = [];
  spans.forEach(function(span) {
    var found = querySpans(results, span.start);
    var level = getMaxLevel(found);
    span.level = level + 1;
    results.push(span);
  });
  return results;
}


function sortSpans(spans) {
  spans.sort(function(a, b) {
    return ((a.start - b.start) ||
      (a.stop - b.stop) ||
      a.type.localeCompare(b.type));
  })
  return spans;
}


function getBoundValues(spans) {
  var values = [];
  spans.forEach(function(span) {
    values.push(span.start);
    values.push(span.stop);
  });
  return values;
}


function uniqueValues(values) {
  var set = {};
  values.forEach(function(value) {
    set[value] = value;
  });
  var values = [];
  for (var key in set) {
    values.push(set[key]);
  }
  values.sort(function(a, b) {
    return a - b;
  });
  return values;
}


function chunkSpan(span, bounds) {
  var results = [];
  var previous = span.start;
  bounds.forEach(function(bound) {
    if ((span.start < bound) && (bound < span.stop)) {
      results.push(makeSpan(
        previous, bound,
        span.type, span.level
      ));
      previous = bound
    }
  });
  results.push(makeSpan(
    previous, span.stop,
    span.type, span.level
  ));
  return results;
}


function chunkSpans(spans) {
  var bounds = getBoundValues(spans);
  bounds = uniqueValues(bounds);

  var results = [];
  spans.forEach(function(span) {
    var chunks = chunkSpan(span, bounds);
    chunks.forEach(function(chunk) {
      results.push(chunk);
    });
  });
  return results;
}


function makeGroup(start, stop) {
  return {
    start: start,
    stop: stop,
    items: []
  }
}


function groupSpans(spans) {
  var previous = undefined;
  var results = [];
  spans.forEach(function(span) {
    if (previous == undefined) {
      previous = makeGroup(span.start, span.stop);
    }
    if (previous.start == span.start) {
      previous.items.push(span);
    } else {
      results.push(previous)
      previous = makeGroup(span.start, span.stop);
      previous.items.push(span);
    }
  });
  if (previous != undefined) {
    results.push(previous)
  }
  return results;
}


function formatTag(span, types) {
  var size = 2;
  var padding = 1 + span.level * (size + 1);
  var index = types.indexOf(span.type);
  color = COLORS[index % COLORS.length];
  return {
    open: ('<span style="' +
      'border-bottom: ' + size + 'px solid; ' +
      'padding-bottom: ' + padding + 'px; ' +
      'border-color: ' + color + '">'),
    close: '</span>'
  }

}

function formatSpans(text, groups, types) {
  var html = '';
  var previous = 0;
  groups.forEach(function(group) {
    html += text.slice(previous, group.start);
    var tags = [];
    group.items.forEach(function(span) {
      tags.push(formatTag(span, types));
    });
    tags.forEach(function(tag) {
      html += tag.open;
    });
    html += text.slice(group.start, group.stop);
    tags.forEach(function(tag) {
      html += tag.close;
    });
    previous = group.stop;
  });
  html += text.slice(previous, text.length);
  return html;
}


function getSpanTypes(spans) {
  var results = [];
  spans.forEach(function(span) {
    if (span.type != undefined) {
      results.push(span.type)
    }
  });
  return results;
}


function updateSpans(text, spans) {
  types = getSpanTypes(spans);
  types = uniqueValues(types);

  spans = sortSpans(spans);
  spans = levelSpans(spans);
  spans = chunkSpans(spans);
  spans = sortSpans(spans);
  groups = groupSpans(spans);

  html = formatSpans(text, groups, types);
  TEXT.html(html);
}


function update() {
  var text = TEXT.text();
  var spans = parse(text);
  updateSpans(text, spans);
}


TEXT.on('input propertychange', update);
TEXT.focus();
update();
#text {
  border: 1px solid silver;
  padding: 1em;
  line-height: 2em;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

<div contenteditable="true" id="text">
  a d a b a a a b c c c f d
</div>


Solution

  • WYSIWYG Editors generally let the user edit on one contenteditable div and show the output in another non-editable div showing one over other cleverly. Thus they overcome lot of complexities like tracking around the caret position and undo-redo sequences.

    I added #textresult to show the output and a .wrapper to enclose both div and Problem 1 & 3 is solved just by that.

    <div class="wrapper">
      <div contenteditable="true" id="text">
        a d a b a a a b c c c f d
      </div>
      <div id="textresult"></div>
    </div>
    

    To solve Problem 2 you should not use jQuery.text, use the native HTMLELement.innerText to get the content with new line character and replace it with <br> after span formatting.

    var TEXT = $('#text');
    var TEXTRESULT = $('#textresult');
    
    var COLORS = [
      '#1f77b4',
      '#ff7f0e',
      '#2ca02c',
      '#d62728',
      '#9467bd',
      '#e377c2',
      '#bcbd22',
      '#17becf',
    ];
    
    
    function makeSpan(start, stop, type, level) {
      return {
        start: start,
        stop: stop,
        type: type,
        level: level
      }
    }
    
    
    function parse(text) {
      var mins = {};
      var maxes = {};
      for (var index = 0; index < text.length; index++) {
        var char = text[index];
        if (char.match(/\s/)) {
          continue;
        }
        var min = mins[char];
        if (min == undefined) {
          mins[char] = index;
        }
        var max = maxes[char];
        if ((max == undefined) || (index > max)) {
          maxes[char] = index;
        }
      }
      var spans = [];
      for (var char in mins) {
        var min = mins[char];
        var max = maxes[char];
        if (max > min) {
          var span = makeSpan(min, max + 1, char);
          spans.push(span);
        }
      }
      return spans;
    }
    
    
    function querySpans(spans, value) {
      var results = [];
      spans.forEach(function(span) {
        if ((span.start <= value) && (value < span.stop)) {
          results.push(span)
        }
      });
      return results;
    }
    
    
    function getMaxLevel(spans) {
      var level = -1;
      spans.forEach(function(span) {
        if (level < span.level) {
          level = span.level;
        }
      });
      return level;
    }
    
    
    function levelSpans(spans) {
      var results = [];
      spans.forEach(function(span) {
        var found = querySpans(results, span.start);
        var level = getMaxLevel(found);
        span.level = level + 1;
        results.push(span);
      });
      return results;
    }
    
    
    function sortSpans(spans) {
      spans.sort(function(a, b) {
        return ((a.start - b.start) ||
          (a.stop - b.stop) ||
          a.type.localeCompare(b.type));
      })
      return spans;
    }
    
    
    function getBoundValues(spans) {
      var values = [];
      spans.forEach(function(span) {
        values.push(span.start);
        values.push(span.stop);
      });
      return values;
    }
    
    
    function uniqueValues(values) {
      var set = {};
      values.forEach(function(value) {
        set[value] = value;
      });
      var values = [];
      for (var key in set) {
        values.push(set[key]);
      }
      values.sort(function(a, b) {
        return a - b;
      });
      return values;
    }
    
    
    function chunkSpan(span, bounds) {
      var results = [];
      var previous = span.start;
      bounds.forEach(function(bound) {
        if ((span.start < bound) && (bound < span.stop)) {
          results.push(makeSpan(
            previous, bound,
            span.type, span.level
          ));
          previous = bound
        }
      });
      results.push(makeSpan(
        previous, span.stop,
        span.type, span.level
      ));
      return results;
    }
    
    
    function chunkSpans(spans) {
      var bounds = getBoundValues(spans);
      bounds = uniqueValues(bounds);
    
      var results = [];
      spans.forEach(function(span) {
        var chunks = chunkSpan(span, bounds);
        chunks.forEach(function(chunk) {
          results.push(chunk);
        });
      });
      return results;
    }
    
    
    function makeGroup(start, stop) {
      return {
        start: start,
        stop: stop,
        items: []
      }
    }
    
    
    function groupSpans(spans) {
      var previous = undefined;
      var results = [];
      spans.forEach(function(span) {
        if (previous == undefined) {
          previous = makeGroup(span.start, span.stop);
        }
        if (previous.start == span.start) {
          previous.items.push(span);
        } else {
          results.push(previous)
          previous = makeGroup(span.start, span.stop);
          previous.items.push(span);
        }
      });
      if (previous != undefined) {
        results.push(previous)
      }
      return results;
    }
    
    
    function formatTag(span, types) {
      var size = 2;
      var padding = 1 + span.level * (size + 1);
      var index = types.indexOf(span.type);
      color = COLORS[index % COLORS.length];
      return {
        open: ('<span style="' +
          'border-bottom: ' + size + 'px solid; ' +
          'padding-bottom: ' + padding + 'px; ' +
          'border-color: ' + color + '">'),
        close: '</span>'
      }
    
    }
    
    function formatSpans(text, groups, types) {
      var html = '';
      var previous = 0;
      groups.forEach(function(group) {
        html += text.slice(previous, group.start);
        var tags = [];
        group.items.forEach(function(span) {
          tags.push(formatTag(span, types));
        });
        tags.forEach(function(tag) {
          html += tag.open;
        });
        html += text.slice(group.start, group.stop);
        tags.forEach(function(tag) {
          html += tag.close;
        });
        previous = group.stop;
      });
      html += text.slice(previous, text.length);
      return html;
    }
    
    
    function getSpanTypes(spans) {
      var results = [];
      spans.forEach(function(span) {
        if (span.type != undefined) {
          results.push(span.type)
        }
      });
      return results;
    }
    
    
    function updateSpans(text, spans) {
      types = getSpanTypes(spans);
      types = uniqueValues(types);
    
      spans = sortSpans(spans);
      spans = levelSpans(spans);
      spans = chunkSpans(spans);
      spans = sortSpans(spans);
      groups = groupSpans(spans);
    
      html = formatSpans(text, groups, types);
      TEXTRESULT.html(html.replace(/\n/g,'<br>'));
    }
    
    
    function update() {
      var text = TEXT[0].innerText;
      var spans = parse(text);
      updateSpans(text, spans);
    }
    
    
    TEXT.on('input propertychange', update);
    TEXT.focus();
    update();
    .wrapper{
      position: relative;
    }
    #text {
      border: 1px solid silver;
      padding: 1em;
      line-height: 2em;
    }
    #textresult {
      border: 1px solid transparent;
      padding: 1em;
      line-height: 2em;
      color: transparent;
      position: absolute;
      top: 0;
      z-index: -1;
    }
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <div class="wrapper">
      <div contenteditable="true" id="text">
        a d a b a a a b c c c f d
      </div>
      <div id="textresult"></div>
    </div>