skip to Main Content

I am trying to create a contenteditable div, in which the user, when editing, does not edit the text within certain HTML tags (or conversely, can ONLY edit the the text within certain tags: either achieves my goal).

For example, say the code is:

<div><ruby>
<rb>T</rb><rt>u</rt>
<rb>E</rb><rt>f</rt>
<rb>S</rb><rt>t</rt>
<rb>T</rb><rt>u</rt>
</ruby></div>

This displays the text:
text "TEST" with furigana annotation "uftu"

I want to make it so that as they move their cursor with the cursor keys, the cursor moves between the letters delimited with the <rb> tag, "TEST", as if the letters wrapped in <rt>; and that, whether they have insert active or not, they can overwrite and delete and insert additional regular characters, but their edits will not affect the <rt> characters (yes, it’s acceptable that if they mess this up, it will look weird, with hanging hiragana with no letters under it, etc: they should ideally be able to come back and add new characters to replace the deleted ones, to fix it up).

In another mode (with shift pressed, or whatever), I hope to make it the opposite: that they can only edit, delete, and move their insertion caret into, the furigana <rt> characters "uftu", and cannot affect the regular <rb> characters "TEST".

This gets close:

<div><ruby>
<rb>T</rb><rt contenteditable="false">u</rt>
<rb>E</rb><rt contenteditable="false">f</rt>
<rb>S</rb><rt contenteditable="false">t</rt>
<rb>T</rb><rt contenteditable="false">u</rt>
</ruby></div>

With contenteditable-false for the <rt> tags (which could be toggled using JS), the caret cannot be moved up into the furigana text.

Unfortunately, there are still issues:

  • backspace still deletes them. I could maybe fix with onkeydown... preventDefault().
  • selecting them and hitting a key deletes them. I could maybe fix by preventing them from being selected, perhaps using methods from the related question "https://stackoverflow.com/questions/13438391/preventing-text-in-rt-tags-furigana-from-being-selected".
  • the cursor keys need to be pressed a second time between each letter, as the caret moves from being to the right of <rb> tag to the left of the next. The only fix for this I can see for this is awful: event handlers to detect cursor movement; some way to find where the cursor moved from and to, and decide where I want it to end up; and tricks from "https://stackoverflow.com/questions/6249095/how-to-set-the-caret-cursor-position-in-a-contenteditable-element-div" to actually move the caret. That all sounds nightmare-kludgey, though, and I hope there’s a cleaner way.

Perhaps what I really want is just a way to explicitly specify where the valid "insertion caret points" are, and prevent the caret from going anywhere else?

I’d definitely like to avoid external libs (jQuery et al) if at all possible, though if it makes things way simpler because they’ve already solved this problem, then I’m willing to cave in: no point reinventing this wheel.

2

Answers


  1. Try this

      <div id="editable">
      <ruby>
        <rb>T</rb><rt class="editable-furigana">u</rt>
        <rb>E</rb><rt class="editable-furigana">f</rt>
        <rb>S</rb><rt class="editable-furigana">t</rt>
        <rb>T</rb><rt class="editabl``e-furigana">u</rt>
      </ruby>
    </div>
    
       
    
     <script> const editableDiv = document.getElementById('editable');
    const furiganaTags = editableDiv.getElementsByClassName('editable-furigana');
    
    editableDiv.addEventListener('keydown', function (event) {
      const caretPosition = getCaretCharacterOffsetWithin(editableDiv);
      const isShiftPressed = event.shiftKey;
      
      if (isShiftPressed) {
        // Allow editing only in the furigana tags
        for (const tag of furiganaTags) {
          tag.setAttribute('contenteditable', 'true');
        }
      } else {
        // Prevent editing in the furigana tags
        for (const tag of furiganaTags) {
          tag.setAttribute('contenteditable', 'false');
        }
      }
      
      // Handle backspace key
      if (event.key === 'Backspace') {
        if (isShiftPressed) {
          event.preventDefault();
          return;
        }
        
        const prevChar = editableDiv.textContent.charAt(caretPosition - 1);
        if (prevChar === ' ') {
          event.preventDefault();
          return;
        }
      }
      
      // Handle caret movement between <rb> tags
      if (!isShiftPressed && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
        const currentChar = editableDiv.textContent.charAt(caretPosition);
        const nextChar = editableDiv.textContent.charAt(caretPosition + 1);
        if (event.key === 'ArrowLeft' && nextChar === ' ') {
          setCaretPosition(editableDiv, caretPosition + 1);
        } else if (event.key === 'ArrowRight' && currentChar === ' ') {
          setCaretPosition(editableDiv, caretPosition - 1);
        }
      }
    });
    
    // Helper function to get the caret position within the div
    function getCaretCharacterOffsetWithin(element) {
      let caretOffset = 0;
      const doc = element.ownerDocument || element.document;
      const win = doc.defaultView || doc.parentWindow;
      const sel = win.getSelection();
      
      if (sel.rangeCount > 0) {
        const range = sel.getRangeAt(0);
        const preCaretRange = range.cloneRange();
        preCaretRange.selectNodeContents(element);
        preCaretRange.setEnd(range.endContainer, range.endOffset);
        caretOffset = preCaretRange.toString().length;
      }
      
      return caretOffset;
    }
    
    // Helper function to set the caret position within the div
    function setCaretPosition(element, offset) {
      const range = document.createRange();
      const sel = window.getSelection();
      range.setStart(element.firstChild, offset);
      range.collapse(true);
      sel.removeAllRanges();
      sel.addRange(range);
    }
    </script>
    
    Login or Signup to reply.
  2. In the below solution, a locked class is toggled on the rt or rb elements depending on shift key being held down or not.

    On keydown, the class toggling occurs and the keyboard arrows are disabled, because it tries to select the text and causes issues.

    On keyup, the class toggling occurs again and, unless it was an arrow key, the "locked" values are restored from an array.

    There is an annoying side effect caused by the restoring, where the caret position changes a bit…
    But, on my part, I’m stopping here.

    You can fix that by looking at getCaretPosition in this answer and setCaretPosition in this answer.

    const ruby = document.querySelector("ruby");
    const editables = ruby.querySelectorAll("rt,rb");
    const arrows = ["ArrowUp", "ArrowLeft", "ArrowRight", "ArrowDown"];
    
    document.addEventListener("keyup", (event) => {
      // Toggle the locked classes to rt
      if (event.key === "Shift") {
        toggleLocked("RT");
        return;
      }
      // Allow keyboard arrows navigation without restoring uselessly
      if (arrows.includes(event.key)) {
        return;
      }
      // Restore the "locked values"
      restoreLockedValues();
    });
    
    document.addEventListener("keydown", (event) => {
      // Toggle the locked classes to rb
      if (event.key === "Shift") {
        toggleLocked("RB");
      }
      // Disallow keyboard arrows navigation while shift key is pressed
      if (event.shiftKey && arrows.includes(event.key)) {
        event.preventDefault()
      }
    });
    
    const toggleLocked = (tag) => {
      editables.forEach((el) => el.classList.toggle("locked", el.tagName === tag));
      mem = getLockedValue();
      console.log(mem);
    };
    
    const getLockedValue = () =>
      Array.from(document.querySelectorAll(".locked")).map((el) => el.innerText);
    
    const restoreLockedValues = () => {
      Array.from(document.querySelectorAll(".locked")).forEach(
        (el, index) => (el.innerText = mem[index])
      );
    };
    
    // An array holding the currently locked values
    let mem = getLockedValue();
    div {
      border: 1px solid black;
      width: fit-content;
    }
    
    rt {
      font-size: 1em;
    }
    
    rb {
      font-size: 1.4em;
      color: red;
    }
    <div><ruby contenteditable="true">
    <rb>T</rb><rt class="locked">u</rt>
    <rb>E</rb><rt class="locked">f</rt>
    <rb>S</rb><rt class="locked">t</rt>
    <rb>T</rb><rt class="locked">u</rt>
    </ruby></div>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search