1

I am developing a text editor for the web frontend, and I want to highlight the line where the text caret is located.

Normally, it should be quite simple to implement: just by listening to the selectionchange event and updating the position of the highlight block based on the DOMRect of the range.

The code as follows:

 let edit = document.getElementById('edit'); let hLine = document.getElementById('highlightLine'); let selection = getSelection(); let y = hLine.getBoundingClientRect().y; document.addEventListener("selectionchange", (event) => { let range = selection.getRangeAt(0); let newTop = String(5 + range.getBoundingClientRect().y - y) + "px"; hLine.style.top = newTop; // console.log(range.getBoundingClientRect()); }); edit.addEventListener('focus', function(event) { hLine.style.opacity = "1"; }); edit.addEventListener('blur', function(event) { hLine.style.opacity = "0"; }); </script>
 #container { position: relative; } #edit { width: 200px; height: 200px; padding: 5px; font-size: 18px; overflow-wrap: break-word; white-space: pre-wrap; } #highlightLine { width: 200px; height: 25px; background-color: rgb(237, 237, 237); opacity: 0; position: absolute; z-index: -1; left: 5px; top: 5px; } 
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Example</title> </head> <body> <article id="container"> <div id="edit" contenteditable="true">This is an example of this problem</div> <div id="highlightLine"></div> </article> </body> </html>

It seems to be OK: enter image description here

But, there is a problem: If a physical line has a soft wrap, the DOMRect for the two positions at the wrap (the end of the previous line and the start of the next line) are identical.

This will result in the highlight block staying on the previous line if the caret is at the start of the next line (or possibly the other way around):

enter image description here

Please help, thank you.

5
  • How do you make it work? It's not obvious at all. What does "#highlightLine" do? Is it supposed to show the text that's supposed to be highlighted? Commented Dec 23, 2024 at 14:30
  • #highlightLine is a div element used to display the background highlight of the current line. It is positioned absolutely to cover the current line in the text editor. It does not display text; instead, it acts as a background highlight to emphasize the current line. Commented Dec 23, 2024 at 14:35
  • OK, IC, so how does it supposed to work? Clicking? Selecting? Commented Dec 23, 2024 at 14:38
  • Any way that changes the position of the caret will make it work, including clicking, selecting, moving the caret with the keyboard, etc. Commented Dec 23, 2024 at 14:48
  • I was under the impression that it works except in a certain circumstance as shown in the second image. Commented Dec 23, 2024 at 15:29

1 Answer 1

1

Update

Added up/down arrow keys. The next thing that should be added is having renderHTML() run after the text has been edited or if #editor is resized.

Details are commented in example. BTW, the position to where you indicated isn't a problem with this solution because every line is wrapped in a <mark> from edge to edge of #editor.

// Reference <form> const ui = document.forms.ui; // Reference <fieldset> const io = ui.elements; /** * This small function removes the .active class * from each tag of a given array. * @param {array} arr - An array of tags. */ const removeActive = arr => { arr.forEach(t => t.classList.remove("active")); }; /** * This function will wrap every given number * of characters in a html tag. * @param {string} str - A string * @param {number} max - Max. number of chars. * @param {string} front - A htmlString of the first tag. * @param {string} end - A htmlString of the end tag. * @return {string} - A htmlString of each N chars. */ const lineWrap = (str, max, front, end) => { return str.replace( new RegExp( `(?![^\\n]{1,${max}}$)([^\\n]{1,${max}})\\s`, 'g'), `${front}$1${end}` ); }; /** * Renders HTML to the given element. * @param {object} target - The element with the text. * @param {number} lines - See lineWrap() @param max. * @param {string} tagA - See lineWrap() @param front. * @param {string} tagB - See linewrap() @param end. * @return {array} - Array of tags. */ const renderHTML = (target, lines, tagA, tagB) => { // Get the tagName of the tags const tag = tagA.replace(/[<>]/g, ""); // Get the text from target const str = target.textContent; // Run lineWrap const brk = lineWrap(str, lines, tagA, tagB); // Render the htmlString into HTML in target target.innerHTML = brk; // Create an array of the tags. const tags = [...document.querySelectorAll(tag)]; /** * Iterate through tags and assign .line class * and data-idx = index number to each tag. */ tags.forEach((m, i) => { m.className = "line"; m.dataset.idx = i; }); // Return array of tags return tags; }; /** * This event handler will add the .active class * to the clicked tag and remove the .active * class from all of the other tags. * @params {object} e - Event object */ const mark = e => { // e.target is the element that the user clicked. const clk = e.target; // If the user clicked an element that has .line... if (clk.matches(".line")) { // remove .active from all tags... removeActive(tags); // add .active to the tag the user clicked... clk.classList.add("active"); // otherwise remove .active } else { removeActive(tags); } }; /** * This event handler will move the .active class * to the next or previous tag according to which of * the ArrowUP/Down key was keyed. * @param {object} e - Event object */ const keyIO = e => { // Reference the .active tag... const act = document.querySelector(".active"); // get it's data-idx value and convert into a real number. const pos = Number(act.dataset.idx); // Find the key clicked. const key = e.code; /** * If the key was up, the new index number * equals .active's data-idx - 1. * If the key was down the new index number * equals .active's data-idx + 1. * @param {object} e - Event object */ switch (key) { case "ArrowUp": idx = pos - 1; break; case "ArrowDown": idx = pos + 1; break; default: break; } // Index constraints idx = idx < 0 ? 0 : idx > size ? size : idx; // Remove .active removeActive(tags); // Add .active to the current active tag. tags[idx].classList.add("active"); }; // @params const target = io.editor; const lines = 40; const tagA = `<mark>`; const tagB = `</mark>`; // Rendering HTML. const tags = renderHTML(target, lines, tagA, tagB); // more @params let idx = 0; const size = tags.length - 1; /** * #register listens for "click" events on the tags. * document listens for "keydown" events. */ io.editor.addEventListener("click", mark); document.addEventListener("keydown", keyIO);
:root { font: 2ch/1.2 Consolas } /** * If this width is changed then renderHTML()/ * lineWrap() must be adjusted accordingly * (@param lines/max). */ #ui { max-width: 40ch; padding: 0; } /** * Same as above. */ #editor { min-width: 40ch; padding: 1rem 0.5rem; } /** * display: table is what makes the text sit * perfectly in each line. */ .line { display: table; width: 100%; background: transparent; } .active { color: white; background: black; }
<form id="ui"> <fieldset id="editor" contenteditable> Do you see any Teletubbies in here? Do you see a slender plastic tag clipped to my shirt with my name printed on it? Do you see a little Asian child with a blank expression on his face sitting outside on a mechanical helicopter that shakes when you put quarters in it? No? Well, that's what you see at a toy store. And you must think you're in a toy store, because you're here shopping for an infant named Jeb. </fieldset> </form>

Sign up to request clarification or add additional context in comments.

2 Comments

Thank you for your help. The code is so elegant and the comments are very detailed. This seem to have solved part of the issue (for clicking). However, for text cursor movement, such as keyboard control, the issue I mentioned still occurs. Although it cannot be adopted, I can upvote your answer.
Like up/down arrow keys, tab, shift+tab, etc...? Not really sure what text cursor movement, like hover? The issue isn't possible with my solution because there's no "soft wraps". Check the HTML in Devtools you'll see each line is wrapped in a <mark>, each mark has display: table.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.