skip to Main Content

I have a contenteditable div, and I want to split a node around a selection. Using execCommand(), I can toggle "bold" on or off for a selection, so if I have:

<b>ABCDEFGHI</b>

and select DEF, toggling "bold" gives me

<b>ABC</b>DEF<b>GHI</b>

where the <b> node has been split into two <b> nodes with a text node in between.

I want to be able to do the same with other elements not supported by execCommand(), for example <bdi>. In other words, if I start with

<bdi>ABCDEFGHI</bdi>

and select DEF, I want to end up with

<bdi>ABC</bdi>DEF<bdi>GHI</bdi>

I can test if the selection is contained in a surrounding <bdi> tag using range.commonAncestorContainer() and if not wrap the range in a <bdi> tag. However, what I want is the opposite: if there is an enclosing <bdi> node, I want to split it into (a) a well-formed <bdi> node before the selection, (b) a well-formed selection with no enclosing <bdi>, and (c) another well-formed <bdi> node after the selection, and then reassemble them. How can I do this?

EDIT: it seems that everyone believes I am trying to wrap a selection, but I’m not. Sergey’s response below shows how to wrap some plain text, but I want something else.

Trying for a minimal reproducible example, consider the following:

<html>
<head></head>
<body>
  <b>This text is <i>marked as 
     bold
     with</i> some italic text too.</b>
</body>
</html>

Now what I want is to UNMARK the text "bold" so that the final result is:

<html>
<head></head>
<body>
  <b>This text is <i>marked as</i></b>
  <i>bold</i>
  <b><i>with</i> some italic text too.</b>
</body>
</html>

Note that the text includes <i></i>, which must also be split. This is trivially easy with execCommand(), but I can’t figure out how to do it without execCommand() (and hence do it for tags like <bdi> as well). I’m looking for a vanilla JS solutsion, not jQuery or Rangy please.

2

Answers


  1. You never even need document.execCommand(). This function is deprecated, by the way.

    Please consider this code sample:

    const handleSelection = wrapIn => {
      const selection = window.getSelection();
      const node = selection.anchorNode;
    
      if (node != selection.focusNode)
            return; // just for simplicity
      if (node == null || node.constructor != Text)
        return; // just for simplicity
      if (selection.rangeCount != 1)
        return; // just for simplicity
    
      const parent = node.parentElement;
      const range = selection.getRangeAt(0);
      const before = node.textContent.slice(0, range.startOffset);
      const selected = node.textContent.slice(range.startOffset,
          range.endOffset);
      const after = node.textContent.slice(range.endOffset);
    
      parent.innerHTML =
          `${before}<${wrapIn}>${selected}</${wrapIn}>${after}`;
    };
    
    window.onload = () => {
      window.addEventListener('keydown', event => {
        if (event.ctrlKey && event.key == "y")
          handleSelection("b");
      });
    };
    b { font-size: 120%; }
    bdi { color: red; }
    <p>1234567890</p>
    <p>123<bdi>45678</bdi>90</p>

    This code snipped is greatly simplified, to keep it short. It wraps a fragment in bold <b></b> on the key gesture Ctrl+Y. I made the modified text bigger, by CSS styling b to make the effect more visible.

    The implementation is not complete, for the sake of simplicity. It does not work correctly across elements. To modify the DOM in the second line, where I have <bdi>45678</bdi>, you can select the text only inside the fragment 45678 (colored, for the clearer demo), or only outside it. Also, the operation doesn’t work correctly if your selection combines both <p> elements. I did not care about those cases just to keep this demo as simple as possible.

    You may want to refine it by processing all selection ranges and all nodes inside the selection, not just one text-only node in just one range, as in my example.

    Added:

    As the inquirer wasn’t satisfied with this simplified sample, I added a different, a bit more complicated one. It splits the inline element in two.

    const handleSelection = () => {
        const selection = window.getSelection();
        const node = selection.anchorNode;
        if (node != selection.focusNode)
            return; // just for simplicity
        if (node == null || node.constructor != Text)
            return; // just for simplicity
        if (selection.rangeCount != 1)
            return; // just for simplicity
        const range = selection.getRangeAt(0);
        const before = node.textContent.slice(0, range.startOffset);
        const selected =
            node.textContent.slice(range.startOffset,
                 range.endOffset);
        const after = node.textContent.slice(range.endOffset);
        const parent = node.parentElement;
        const wrapIn = parent.tagName.toLowerCase();
        console.log(selection.rangeCount);   
        const replacementNode = document.createElement("span");
        parent.replaceWith(replacementNode);
        replacementNode.innerHTML = `<${wrapIn}>${before}</${wrapIn}>${selected}<${wrapIn}>${after}</${wrapIn}>`;
    };
    
    window.onload = () => {
        window.addEventListener('keydown', event => {
            if (event.ctrlKey && event.key == "y")
                handleSelection("b");
        });
    };
    b { font-size: 120%; }
    bdi { color: red; }
    <p>1234567890</p>
    <p>123<bdi>45678</bdi>90</p>

    This more complicated code also needs further development. It does work as it is, but for any practical purpose, you really need to classify the types of parent and apply different processing for different types. For example, I demonstrate splitting <p> into two separate paragraphs, but the code adds another <span>, and this is required only for an inline element. What to do, depends on your purpose. Besides, if you repeat the same operations with different overlapping ranges, it will complicate and mess up HTML structure. Ideally, you need to analyze this structure and simplify it, and this is not so simple. I would suggest that the entire idea of your requirements is not the best design, so you better think of something more robust. But this is up to you.

    Login or Signup to reply.
  2. Usage

    1. In the example below, select a tag using <select id="tags">:

      • <mark>
      • <b>
      • <i>
      • <u>
      • <q>
      • <code>

      ✳ In addition, there’s a color picker <input id="color"> that can allow you to assign different background color for each <mark>.

    2. Next, select text within <fieldset id="edit"> while holding down the ctrl key.

    3. To remove any tag just click the text while holding the alt key down.

    If a selection intersects with a tag they will be split, but if a selection is within a tag completely it will be a nested tag. See Fig. 1 and Fig. 2.

    Fig. 1 – Intersected tags will split.

     <b>Bold text</b> this normal text.
                      ⮤ Selected Text ⮥
     <b>Bold t</b><i>ext this nor<i>mal text.


    Fig. 2 – A tag completely within another will be nested.

     <b>Bold text this normal text.</b>
                      ⮤ Selected Text ⮥
     <b>Bold te<i>xt this normal te</i>xt.</b>

    Details are commented in the example.

    Example

    // Reference <form>
    const ui = document.forms.ui;
    
    /**
     * Reference all form controls of <form>
     * In this particular layout that would be:
     *   - <fieldset>
     *   - <select>
     *   - <input>
     */
    const io = ui.elements;
    
    // https://stackoverflow.com/a/11508164/2813224
    /**
     * Converts hex color to RGB color 
     * @param {string} hex - Hex color
     * @return {array}     - Array of [r, g, b]
     */
    const hexToRGB = hex => {
      hex = hex.slice(1);
      const bigint = parseInt(hex, 16);
      const r = (bigint >> 16) & 255;
      const g = (bigint >> 8) & 255;
      const b = bigint & 255;
      return [r, g, b];
    };
    
    // https://stackoverflow.com/a/35523264/2813224
    /**
     * Calculates the contrasting RGB color from the
     * given RGB color.
     * @param {array} rgb - Array of [r, g, b]
     * @return {string}   - CSS rgb() value
     */
    const contrastRGB = rgb => {
      const r = Math.floor(255 - rgb[0]);
      const g = Math.floor(255 - rgb[1]);
      const b = Math.floor(255 - rgb[2]);
      return `rgb(${r}, ${g}, ${b})`;
    }
    
    /**
     * "pointerup" event handler wraps selected text with
     * a given tag. If the right mouse button is clicked,
     * the clicked text's parent tag is removed.
     * @param {object} event -  Event object
     */
    const tagText = event => {
      // Get selection
      const select = document.getSelection();
    
      // If the user clicked while holding down the CTRL key...
      if (event.ctrlKey) {
        // Get the value of <select>
        const action = io.tags.value;
        // If the value is "Pick Action" end function
        if (action === "Pick Action") return;
        /**
         * Next extract the selected text and create
         * the selected tag and insert the text into the
         * new tag.
         */
        const range = select.getRangeAt(0);
        const text = range.toString();
        const content = range.extractContents();
        const tag = document.createElement(action);
        tag.textContent = text;
    
        // If the user selected a <mark>...
        if (tag.tagName === "MARK") {
          // get the background color from <input>...
          const bkg = io.color.value;
          // convert hex to RGB color...
          const rgb = hexToRGB(bkg);
          // get the contrasting color..
          const txt = contrastRGB(rgb);
          /**
           * and assign the background color and contrasting
           * color to the <mark>
           */
          tag.style.cssText = `background: ${bkg}; color: ${txt};`;
        }
        // Insert the tag into the selected area of text.
        range.insertNode(tag);
      }
      // After any range operation, the ranges should be cleared.
      select.removeAllRanges();
    
      // If user clicks while holding the ALT key down...
      if (event.altKey) {
        /**
         * Make an array of the values of the <option>s
         * that are in <select>.
         */
        const elements = Array.from(io.tags.options)
          .map(opt => opt.value);
        // Determine the tag the user clicked
        const target = event.target;
    
        // if the tag is one of the tags in <select>...
        if (elements.includes(target.tagName)) {
          // get the tag's text and remove the tag.
          const text = target.textContent;
          const tNode = document.createTextNode(text);
          target.parentNode.replaceChild(tNode, target);
        }
      }
    };
    
    /**
     * Register <fieldset id="edit"> to listen for the
     * "pointerup" event.
     */
    io.edit.addEventListener("pointerup", tagText);
    :root {
      font: 2ch/1.5 "Segoe UI"
    }
    
    fieldset {
      padding-top: 0.75rem;
    }
    
    label {
      display: inline-block;
      margin-left: 0.75rem
    }
    
    select,
    input {
      font: inherit;
    }
    
    code {
      font-family: Consolas;
      background: #baddad;
    }
    <form id="ui">
      <fieldset>
        <select id="tags">
          <option>Pick Action</option>
          <option value="MARK">Highlight</option>
          <option value="B">Bold</option>
          <option value="I">Italics</option>
          <option value="U">Underline</option>
          <option value="Q">Quote</option>
          <option value="CODE">Code</option>
        </select>
        <label>Highlight Color: 
        <input id="color" type="color" value="#ffcc00">
        </label>
      </fieldset>
      <fieldset id="edit" contenteditable>
        The path of the righteous man is beset on all sides by the iniquities of the selfish and the tyranny of evil men. Blessed is he who, in the name of charity and good will, shepherds the weak through the valley of darkness, for he is truly his brother's keeper and the finder of lost children. And I will strike down upon thee with great vengeance and furious anger those who would attempt to poison and destroy My brothers. And you will know My name is the Lord when I lay My vengeance upon thee.
      </fieldset>
    </form>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search