Justification in contentEditable div which each letter have own element

Viewed 1490

I have got contentEditable div which each letter have own element, like:

<div contenteditable="true">
    <p id="p1">
        <span id="s1">S</span>
        <span id="s2">o</span>
        <span id="s3">m</span>
        <span id="s4">e</span>
        <span id="s5">&nbsp;</span>
        <span id="s6">t</span>
        <span id="s7">e</span>
        <span id="s8">x</span>
        <span id="s9">t</span>
    </p>
</div>

And I am trying to justify some longer text using text-align: justify, but this don't work. That is strange, because text-align: center and text-align: right works.

After that I am trying to do that using a script which adds margin-right to each space, but when I am writing new text into paragraph it crashes.

How can I do that (and save id and other attributes in each element) using JavaScript and/or JQuery?

7 Answers

Here is an example of a JS + jQuery way to simulate the justification for characters wrapped in spans inside a contenteditable element. It's just a quick proof of concept, tested in Chrome only, and not thoroughly. Functionalities like delete / undo / copy / paste, etc. were not added to this example.

var selectors = {
  'wrapper': '#editable',
  'paragraphs': '#editable > p',
  'spans': '#editable > p > span'
}

function get_cursor_position(element) {
  var caretOffset = 0;
  var doc = element.ownerDocument || element.document;
  var win = doc.defaultView || doc.parentWindow;
  var sel;
  if (typeof win.getSelection != "undefined") {
    sel = win.getSelection();
    if (sel.rangeCount > 0) {
      var range = win.getSelection().getRangeAt(0);
      var preCaretRange = range.cloneRange();
      preCaretRange.selectNodeContents(element);
      preCaretRange.setEnd(range.endContainer, range.endOffset);
      caretOffset = preCaretRange.toString().length;
    }
  } else if ((sel = doc.selection) && sel.type != "Control") {
    var textRange = sel.createRange();
    var preCaretTextRange = doc.body.createTextRange();
    preCaretTextRange.moveToElementText(element);
    preCaretTextRange.setEndPoint("EndToEnd", textRange);
    caretOffset = preCaretTextRange.text.length;
  }
  return caretOffset;
}


function set_cursor_position(pos) {
  if (document.selection) {
    sel = document.selection.createRange();
    sel.moveStart('character', pos);
    sel.select();
  } else {
    sel = window.getSelection();
    sel.collapse($(selectors['spans'])[pos].firstChild, 0);
  }
}

function set_cursor_position_to_element(el) {
  if (document.selection) {
    sel = document.selection.createRange();
    sel.moveStart('character', pos);
    sel.select();
  } else {
    sel = window.getSelection();
    sel.collapse(el, 0);
  }
}


function justify(wrapper_selector, children_selector) {
  var line_width = 0,
    first_line_char = 0,
    wrapper = $(wrapper_selector),
    wrapper_width = wrapper.width(),
    children = $(children_selector),
    position_of_last_space_found,
    filled_line_width_at_last_space_found;

  // refresh
  children.removeAttr("padding-right").removeClass("spaced first-line-char last-line-space");

  for (var space_positions = [], l = children.length, child_i = 0; child_i < l; child_i++) {
    child_e = children.eq(child_i);
    line_width += $(child_e).width();
    first_line_char += 1;
    if (/\s/g.test($(child_e).text())) {
      space_positions.push(child_i);
      position_of_last_space_found = child_i;
      filled_line_width_at_last_space_found = line_width - child_e.width();
    }
    if (line_width >= wrapper_width) {
      remaining_space = wrapper_width - filled_line_width_at_last_space_found;
      line_chars_extra_margin = remaining_space / (space_positions.length - 1);
      for (margin_i = 0; margin_i < space_positions.length; margin_i++) {
        children.eq(space_positions[margin_i]).addClass("spaced").css("padding-right", Math.floor(line_chars_extra_margin * 10) / 10);
      }
      children.eq(position_of_last_space_found + 1).addClass("first-line-char");
      children.eq(position_of_last_space_found).addClass("last-line-space");
      line_width = 0;
      child_i = position_of_last_space_found;
      first_line_char = 0;
      space_positions = [];
    }
  }
}

function insert_char(html) {
  var sel, range;
  if (window.getSelection) {
    sel = window.getSelection();
    if (sel.getRangeAt && sel.rangeCount) {
      range = sel.getRangeAt(0);
      range.deleteContents();
      var el = document.createElement("div");
      el.innerHTML = html;
      var frag = document.createDocumentFragment(),
        node, lastNode;
      while ((node = el.firstChild)) {
        lastNode = frag.appendChild(node);
      }
      var firstNode = frag.firstChild;
      pos = $(range.commonAncestorContainer).parent('span').index() + range.startOffset;
      $(selectors['spans']).eq(pos).before(frag);
      set_cursor_position(pos + 1, $(selectors['paragraphs']));
    }
  }
}

function get_span_at(x, y) {
  var $elements = $(selectors['spans']).map(function() {
    var $this = $(this);
    var offset = $this.offset();
    var l = offset.left;
    var t = offset.top;
    var h = $this.outerHeight(true);
    var w = $this.outerWidth(true);

    var maxx = l + w;
    var maxy = t + h;

    return (y <= maxy && y >= t) && (x <= maxx && x >= l) ? $this : null;
  });

  return $elements;
}

function init_demo() {
  var next_pos;

  // Copy the text from div.reference inside div.editable 
  // (only for the purpose of this example)
  var characters = $('div.reference p').text().trim().replace(/ /g, '\u00a0');
  for (var x = 0; x < characters.length; x++) {
    var c = characters.charAt(x);
    // wrap each character in a span
    $(selectors['paragraphs']).append("<span>" + c + "</span");
  }

  // initial justification
  justify(selectors['wrapper'], selectors['spans']);

  // re-justify on window resize
  $(window).resize(function() {
    clearTimeout(window.resizedFinished);
    window.resizedFinished = setTimeout(function() {
      justify(selectors['wrapper'], selectors['spans']);
    }, 20);
  });

  // Improve navigation with arrow keys
  $(selectors['wrapper']).on('keydown', function(e) {
    switch (e.which) {
      case 37: // left
        next_pos = get_cursor_position($(selectors['spans'])[0]) - 1;
        set_cursor_position(next_pos, $(selectors['paragraphs']));
        break;

      case 38: // up
        curr_pos = get_cursor_position($(selectors['spans'])[0]);
        curr_span = $(selectors['spans']).eq(curr_pos);
        curr_span_y = curr_span.position().top;
        curr_span_x = curr_span.position().left;
        next_span_y = curr_span_y - 1;
        next_span_x = curr_span_x + 1;
        next_span = get_span_at(curr_span_x, next_span_y);
        if (next_span[0]) {
          set_cursor_position_to_element(next_span[0][0]);
        }
        break;

      case 39: // right
        next_pos = get_cursor_position($(selectors['spans'])[0]);
        set_cursor_position(next_pos, $(selectors['paragraphs']));
        break;

      case 40: // down
        curr_pos = get_cursor_position($(selectors['spans'])[0]);
        curr_span = $(selectors['spans']).eq(curr_pos);
        curr_span_y = curr_span.position().top;
        curr_span_x = curr_span.position().left;
        curr_span_h = curr_span.outerHeight(true);
        next_span_y = curr_span_y + curr_span_h + 1;
        next_span_x = curr_span_x + 1;
        next_span = get_span_at(curr_span_x, next_span_y);
        if (next_span[0]) {
          set_cursor_position_to_element(next_span[0][0]);
        }
        break;
    }
  });

  // re-justify on character insertion
  $(selectors['wrapper']).on('keypress', function(e) {
    new_char = String.fromCharCode(e.which).replace(/ /g, '\u00a0');
    // Wrap new characters in spans
    new_el = '<span>' + new_char + '</span>';
    insert_char(new_el);
    justify(selectors['wrapper'], selectors['spans']);
    e.preventDefault();
  });

}

init_demo();
div.col {
  width: 50%;
  overflow: hidden;
  font-size: 1.2em;
  float: left;
  box-sizing: border-box;
  padding: 20px;
}

p {
  overflow: hidden;
}

div.reference p {
  text-align: justify;
}

div span {
  display: block;
  float: left;
}

.first-line-char {
  content: ' ';
  display: block;
  clear: left;
}

.last-line-space {
  display: none;
}
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<div class="col col1">
  <h2>reference</h2>
  <h4>This is just a reference. Edit the right column.</h4>
  <div class="reference">
    <p>
      Lorem ipsum dolor sit amet, consectetur adipisicing elit. Excepturi provident, nemo incidunt voluptate officia, ipsa nulla itaque laudantium aperiam cupiditate vero, nesciunt consequuntur, facilis aliquam enim quis ad. Fugiat, magni.
    </p>
  </div>
</div>
<div class="col col2">
  <h2>editable</h2>
  <h4>Click the paragraph text below to start to edit.</h4>
  <div contenteditable="true" class="editable" id="editable">
    <p>
      <!-- Text will be copied from the reference div -->
    </p>
  </div>
</div>

Demo.

You can add one fake word at the end and make its width:100% and display: inline-block. Like this: (change width of parent according to your requirement)

.justified {
  width: 300px;
  text-align: justify;
}

.justified .fake-word {
  width: 100%;
  display: inline-block;
  height: 0.1em;
}
<div contenteditable="true">
  <p id="p1" class="justified">
    <span id="s1">S</span>
    <span id="s2">o</span>
    <span id="s3">m</span>
    <span id="s4">e</span>
    <span id="s5">&nbsp;</span>
    <span id="s6">t</span>
    <span id="s7">e</span>
    <span id="s8">x</span>
    <span id="s9">t</span>
    <span class="fake-word"></span>
  </p>
</div>

EDIT 1:

You can remove all span tags if you don't want space in between characters:

<div contenteditable="true">
      <p id="p1" class="justified">
         Some Text
        <span class="fake-word"></span>
      </p>
    </div>

EDIT 2: Also, you don't have to add .fake-word, there is another way of doing this by using pseudo-class:

 .justified:after {
      content: "";
      display: inline-block;
      width: 100%;
 }

For this problem, I think the flexbox approach is the best to take. Add an id to your <div> or give it style so that it take the width you want it to take, for example :

<div contenteditable="true" id="container">
    <p id="p1">
        <span id="s1">S</span>
        <span id="s2">o</span>
        <span id="s3">m</span>
        <span id="s4">e</span>
        <span id="s5">&nbsp;</span>
        <span id="s6">t</span>
        <span id="s7">e</span>
        <span id="s8">x</span>
        <span id="s9">t</span>
    </p>
</div>

Then in your css, add the following :

#container {
    width: 400px;
}

#p1 {
    width: 100%;
    display: flex;
    justify-content: space-between;
}

The harsh answer is that it is not easy to achieve what you are looking for if you want normal justify behavior but keeping all the spans.

But if you want to attempt such a thing you need to make your own justify script. To do this you need to loop through all the spans and when you enter a new line (check the offset of the span from top) you need to go back to the last space and then try to justify all the characters previously iterated. This can be done by encapsulating them in a new span or div and justify inside that (encapsulating div needs to be 100% width). Either just using css or by changing with of the space spans to have only the spans expand.

SO question to answer how to check the offset of the span: How to select nth line of text (CSS/JS)

I am sorry but without adding more spans/divs or removing spans there is no way to justify this content correctly what I can see.

What you have IS working but its just not showing with the text you have for a couple of reasons.

First reason is that that text is too short. You need enough to make the text wrap to show the justification.

And second reason is that by putting the span tags in new lines is adding a space between each one. Try putting it all on the same line

Like this

<div contenteditable="true">
    <p id="p1">
      <span id="s1">S</span><span id="s2">o</span><span id="s3">m</span><span id="s4">e</span><span id="s5">&nbsp;</span><span id="s6">t</span><span id="s7">e</span><span id="s8">x</span><span id="s9">t</span>
    </p>
</div>

Here is jsfiddle example working

You can achive this by wrapping the words in an span tag with: display: inline-block;. example: https://codepen.io/MaxViewUp/pen/XeMJmX?editors=1100

<div id="d1" contenteditable="true">
 <p id="p1">
  <span class="word">
    <span id="s1">S</span>
    <span id="s2">o</span>
    <span id="s3">m</span>
    <span id="s4">e</span>
  </span>
  <span id="s5">&nbsp;</span>
  <span class="word">
    <span id="s1">S</span>
    <span id="s2">o</span>
    <span id="s3">m</span>
    <span id="s4">e</span>
  </span>
 </p>
</div>

use text-align: justify with display:flex(mandatory at times) this should work.

Related