alexanderkuk
alexanderkuk

Reputation: 1691

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>

Upvotes: 4

Views: 1981

Answers (1)

Munim Munna
Munim Munna

Reputation: 17546

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>

Upvotes: 4

Related Questions