Reputation: 358
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>
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):
Please help, thank you.
Upvotes: 1
Views: 66
Reputation: 44088
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>
Upvotes: 1