I have a task in which users need to highlight portions of text with their cursor. However, the way I am handling mouse move events right now is only allowing users to highlight text in a single direction--i.e., if they change their mouse direction mid-move event, the text highlighting does not follow their cursor.
This post is relevant in request but handles text highlighting by adding/toggling classes, whereas I am handling highlighting by changing the background color of span elements. This post is also very relevant but I'm not sure how to implement.
I am hoping to figure out how to allow removing highlighting when mouse changes direction. Is this possible?
// Initialize data structures and variables
var wordDictionary = {}; // Dictionary to store words
var selectedWords = {}; // Currently selected words
var isMouseDown = false; // Flag for mouse state
var currentColor = ''; // Current highlight color
var usedColors = new Set(); // Set of used colors
// Available highlight colors
var availableColors = ["yellow", "red", "blue", "green", "orange"];
var highlights = {}; // Store highlighted words for each event
var eventCounter = 0; // Counter for events
var text = "Pangolins, sometimes known as scaly anteaters, are mammals of the order Pholidota. \
The one extant family, the Manidae, has three genera: Manis, Phataginus, and Smutsia. \
Manis comprises four species found in Asia, while Phataginus and Smutsia include two species each, all found in sub-Saharan Africa. \
These species range in size from 30 to 100 cm (12 to 39 in). \
A number of extinct pangolin species are also known. \
In September 2023, nine species were reported.<br><br> \
Pangolins have large, protective keratin scales, similar in material to fingernails and toenails, covering their skin; \
they are the only known mammals with this feature. \
They live in hollow trees or burrows, depending on the species. \
Pangolins are nocturnal, and their diet consists of mainly ants and termites, which they capture using their long tongues. \
They tend to be solitary animals, meeting only to mate and produce a litter of one to three offspring, which they raise for about two years.";
var textParagraph = document.getElementById("textParagraph");
textParagraph.innerHTML = text;
// Execute when the window is loaded
window.onload = function () {
var contentDiv = document.getElementById('content');
let ptag = contentDiv.querySelector('p');
var text = ptag.innerHTML.trim();
var words = text.split(/\s+|(?=<br><br>)/);
// Populate wordDictionary with words and create span elements for each word
for (var i = 0; i < words.length; i++) {
wordDictionary[i] = words[i];
}
for (var i = 0; i < words.length; i++) {
var wordElement = document.createElement('span');
wordElement.textContent = words[i] + ' ';
wordElement.dataset.index = i;
wordElement.addEventListener('mousedown', handleMouseDown);
if (words[i] == '<br><br>') {
wordElement.textContent = ' ';
wordElement.classList.add('line-break')
}
contentDiv.appendChild(wordElement);
}
// Add mouseup event listener for handling mouse up events
document.addEventListener('mouseup', handleMouseUp);
};
// Function to get a random highlight color
function getRandomColor() {
if (availableColors.length === 0) {
availableColors = [...usedColors];
usedColors.clear();
}
var randomIndex = Math.floor(Math.random() * availableColors.length);
var color = availableColors.splice(randomIndex, 1)[0];
usedColors.add(color);
return color;
}
// Function to handle mouse down event on words
function handleMouseDown(event) {
isMouseDown = true;
var index = event.target.dataset.index;
var word = event.target.textContent.trim();
if (!isHighlighted(index)) {
selectedWords = {};
selectedWords.startIndex = index;
selectedWords.endIndex = index;
selectedWords[index] = word;
// console.log(selectedWords)
currentColor = getRandomColor();
event.target.style.backgroundColor = currentColor;
event.preventDefault();
document.addEventListener('mousemove', handleMouseMove);
}
}
// Function to handle mouse up event
function handleMouseUp(event) {
if (isMouseDown) {
document.removeEventListener('mousemove', handleMouseMove);
var highlightedWords = {};
for (var index in selectedWords) {
if (index !== 'startIndex' && index !== 'endIndex') {
highlightedWords[index] = selectedWords[index];
}
}
eventCounter++;
highlights[eventCounter] = highlightedWords;
// console.log(highlights);
}
isMouseDown = false;
}
// Function to handle mouse move event (word selection)
function handleMouseMove(event) {
if (isMouseDown) {
var currentIndex = event.target.dataset.index;
var startIndex = selectedWords.startIndex;
var endIndex = selectedWords.endIndex;
var contentDiv = document.getElementById('content');
var newStartIndex = Math.min(startIndex, currentIndex);
var newEndIndex = Math.max(endIndex, currentIndex);
clearPreviousSelection();
for (var i = newStartIndex; i <= newEndIndex; i++) {
selectedWords[i] = wordDictionary[i];
}
for (var i = newStartIndex + 1; i <= newEndIndex + 1; i++) {
contentDiv.children[i].style.backgroundColor = currentColor;
}
selectedWords.startIndex = newStartIndex;
selectedWords.endIndex = newEndIndex;
}
}
// Function to clear previously selected words
function clearPreviousSelection() {
var contentDiv = document.getElementById('content');
for (var i in selectedWords) {
if (i !== 'startIndex' && i !== 'endIndex') {
contentDiv.children[i].style.backgroundColor = '';
delete selectedWords[i];
}
}
}
// Function to check if a word is already highlighted
function isHighlighted(index) {
for (var eventKey in highlights) {
var highlightedWords = highlights[eventKey];
for (var wordIndex in highlightedWords) {
if (wordIndex === index) {
return true;
}
}
}
return false;
}
// Function to clear all selections and reset
function clearSelections() {
var contentDiv = document.getElementById('content');
var wordElements = contentDiv.getElementsByTagName('span');
for (var i = 0; i < wordElements.length; i++) {
wordElements[i].style.backgroundColor = '';
}
highlights = {};
eventCounter = 0;
}
// Function to undo the last selection
function undoSelection() {
if (eventCounter > 0) {
var lastHighlight = highlights[eventCounter];
for (var index in lastHighlight) {
var wordIndex = parseInt(index);
var contentDiv = document.getElementById('content');
if (!isNaN(wordIndex)) {
contentDiv.children[wordIndex + 1].style.backgroundColor = '';
}
}
delete highlights[eventCounter];
eventCounter--;
}
}
// Add event listeners to the clear and undo buttons
document.getElementById("removeHighlight").addEventListener("click", clearSelections);
document.getElementById("undoHighlight").addEventListener("click", undoSelection);
#buttons {
margin-top: 30px;
}
.line-break {
display: block;
margin: 15px;
}
#trial_display {
display: block;
padding: 50px;
}
#title{
text-align: center;
font-size: 20px;
}
#content {
display: block;
border: 2px solid gray;
padding: 50px;
}
<div id="trial_display">
<div id="content">
<p id="textParagraph" style="display: none"></p>
</div>
<div id="buttons">
<button id="removeHighlight">Clear</button>
<button id="undoHighlight">Undo</button>
</div>
</div>
It seems that what you want, is already achieved natively with browser's selection API.
So, you could handle it all via selection, by getting elements from it and then changing their background color via their data-index, as well as changing the default selection highlight background color, so that they are in congruence.
Try this (code key points):
sel.getRangeAt(i).cloneContents()
document.querySelector(`[data-index="${index}"]`).style.backgroundColor = currentColor;
sheet.insertRule(`#content span::selection { background-color: ${currentColor}; }`, sheet.cssRules.length);
EDIT
I've also changed undo
and clear
features so that now I just store highlighted indexes in an array, and then on undo just pop last element, and loop spans and remove their background. If you need to store the words, you can also get them from the elements via index.
Steps with complete newly added code:
extract elements from the selection
function getSelectionElements(e) {
const sel = window.getSelection();
if (sel.rangeCount) {
// container to store all seleccted elements
const container = document.createElement('div');
for (let i = 0, len = sel.rangeCount; i < len; ++i) {
// append elements
// if only single text node, then append element from event.target
if (sel.getRangeAt(i).cloneContents().childNodes.length > 1) {
container.appendChild(sel.getRangeAt(i).cloneContents());
} else {
container.appendChild(e.target.cloneNode());
}
}
return container;
}
}
loop each element, get index and then change its background
// get elements from selection
// loop and add background color to each
document.addEventListener('mouseup', (e) => {
const selectedElements = getSelectionElements(e);
if (selectedElements)
selectedElements.childNodes.forEach(el => {
let index = el.dataset.index;
let word = el.textContent.trim();
if (!isHighlighted(index)) {
selectedWords = {};
selectedWords.startIndex = index;
selectedWords.endIndex = index;
selectedWords[index] = word;
document.querySelector(`[data-index="${index}"]`).style.backgroundColor = currentColor;
}
});
});
when each selection starts, remove previous selection rule, and add new with new color
// remove previous selection style
// add new highlight color
document.addEventListener('mousedown', (e) => {
const sheet = document.styleSheets[0];
const rules = sheet.cssRules;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
if (rule.selectorText.includes('::selection')) sheet.deleteRule(i);
}
currentColor = getRandomColor();
sheet.insertRule(`#content span::selection { background-color: ${currentColor}; }`, sheet.cssRules.length);
});
EDIT
// remove background from last element, if any
if(previousHiglight.length > 0) {
previousHiglight.pop().forEach(word=>{
contentDiv.querySelector(`[data-index="${word}"]`).style.backgroundColor = '';
});
}
contentDiv.querySelectorAll('span').forEach(word=>{
word.style.backgroundColor = '';
});
previousHiglight.length = 0;
// Execute when the window is loaded
window.onload = function() {
// Initialize data structures and variables
var wordDictionary = {}; // Dictionary to store words
var selectedWords = {}; // Currently selected words
var isMouseDown = false; // Flag for mouse state
var currentColor = ''; // Current highlight color
var usedColors = new Set(); // Set of used colors
// Available highlight colors
var availableColors = ["yellow", "red", "blue", "green", "orange"];
var highlights = {}; // Store highlighted words for each event
var eventCounter = 0; // Counter for events
var text = "Pangolins, sometimes known as scaly anteaters, are mammals of the order Pholidota. \
The one extant family, the Manidae, has three genera: Manis, Phataginus, and Smutsia. \
Manis comprises four species found in Asia, while Phataginus and Smutsia include two species each, all found in sub-Saharan Africa. \
These species range in size from 30 to 100 cm (12 to 39 in). \
A number of extinct pangolin species are also known. \
In September 2023, nine species were reported.<br><br> \
Pangolins have large, protective keratin scales, similar in material to fingernails and toenails, covering their skin; \
they are the only known mammals with this feature. \
They live in hollow trees or burrows, depending on the species. \
Pangolins are nocturnal, and their diet consists of mainly ants and termites, which they capture using their long tongues. \
They tend to be solitary animals, meeting only to mate and produce a litter of one to three offspring, which they raise for about two years.";
var textParagraph = document.getElementById("textParagraph");
textParagraph.innerHTML = text;
var contentDiv = document.getElementById('content');
let ptag = contentDiv.querySelector('p');
var text = ptag.innerHTML.trim();
var words = text.split(/\s+|(?=<br><br>)/);
// Populate wordDictionary with words and create span elements for each word
for (var i = 0; i < words.length; i++) {
wordDictionary[i] = words[i];
}
for (var i = 0; i < words.length; i++) {
var wordElement = document.createElement('span');
wordElement.textContent = words[i] + ' ';
wordElement.dataset.index = i;
//wordElement.addEventListener('mousedown', handleMouseDown);
if (words[i] == '<br><br>') {
wordElement.textContent = ' ';
wordElement.classList.add('line-break')
}
contentDiv.appendChild(wordElement);
}
// Add mouseup event listener for handling mouse up events
//document.addEventListener('mouseup', handleMouseUp);
// Function to get a random highlight color
function getRandomColor() {
if (availableColors.length === 0) {
availableColors = [...usedColors];
usedColors.clear();
}
var randomIndex = Math.floor(Math.random() * availableColors.length);
var color = availableColors.splice(randomIndex, 1)[0];
usedColors.add(color);
return color;
}
// Function to handle mouse down event on words
function handleMouseDown(event) {
isMouseDown = true;
var index = event.target.dataset.index;
var word = event.target.textContent.trim();
if (!isHighlighted(index)) {
selectedWords = {};
selectedWords.startIndex = index;
selectedWords.endIndex = index;
selectedWords[index] = word;
// console.log(selectedWords)
currentColor = getRandomColor();
event.target.style.backgroundColor = currentColor;
event.preventDefault();
//document.addEventListener('mousemove', handleMouseMove);
}
}
// Function to handle mouse up event
function handleMouseUp(event) {
if (isMouseDown) {
document.removeEventListener('mousemove', handleMouseMove);
var highlightedWords = {};
for (var index in selectedWords) {
if (index !== 'startIndex' && index !== 'endIndex') {
highlightedWords[index] = selectedWords[index];
}
}
eventCounter++;
highlights[eventCounter] = highlightedWords;
// console.log(highlights);
}
isMouseDown = false;
}
// Function to handle mouse move event (word selection)
function handleMouseMove(event) {
if (isMouseDown) {
var currentIndex = event.target.dataset.index;
var startIndex = selectedWords.startIndex;
var endIndex = selectedWords.endIndex;
var contentDiv = document.getElementById('content');
var newStartIndex = Math.min(startIndex, currentIndex);
var newEndIndex = Math.max(endIndex, currentIndex);
clearPreviousSelection();
for (var i = newStartIndex; i <= newEndIndex; i++) {
selectedWords[i] = wordDictionary[i];
}
for (var i = newStartIndex + 1; i <= newEndIndex + 1; i++) {
contentDiv.children[i].style.backgroundColor = currentColor;
}
selectedWords.startIndex = newStartIndex;
selectedWords.endIndex = newEndIndex;
}
}
// Function to clear previously selected words
function clearPreviousSelection() {
var contentDiv = document.getElementById('content');
for (var i in selectedWords) {
if (i !== 'startIndex' && i !== 'endIndex') {
contentDiv.children[i].style.backgroundColor = '';
delete selectedWords[i];
}
}
}
// Function to check if a word is already highlighted
function isHighlighted(index) {
for (var eventKey in highlights) {
var highlightedWords = highlights[eventKey];
for (var wordIndex in highlightedWords) {
if (wordIndex === index) {
return true;
}
}
}
return false;
}
// Function to clear all selections and reset
function clearSelections() {
var contentDiv = document.getElementById('content');
var wordElements = contentDiv.getElementsByTagName('span');
for (var i = 0; i < wordElements.length; i++) {
wordElements[i].style.backgroundColor = '';
}
highlights = {};
eventCounter = 0;
}
// Function to undo the last selection
function undoSelection() {
if (eventCounter > 0) {
var lastHighlight = highlights[eventCounter];
for (var index in lastHighlight) {
var wordIndex = parseInt(index);
var contentDiv = document.getElementById('content');
if (!isNaN(wordIndex)) {
contentDiv.children[wordIndex + 1].style.backgroundColor = '';
}
}
delete highlights[eventCounter];
eventCounter--;
}
}
// Add event listeners to the clear and undo buttons
document.getElementById("removeHighlight").addEventListener("click", clearSelectionsNew);
document.getElementById("undoHighlight").addEventListener("click", undoSelectionNew);
// remove selection highlight
function clearHighlight() {
const sel = window.getSelection();
if (sel.rangeCount > 0) {
sel.removeAllRanges();
}
}
// remove background from all elements
function clearSelectionsNew() {
clearHighlight();
contentDiv.querySelectorAll('span').forEach(word => {
word.style.backgroundColor = '';
});
previousHiglight.length = 0;
}
// previous indexes store
const previousHiglight = [];
function undoSelectionNew() {
clearHighlight();
// remove background from last element, if any
if (previousHiglight.length > 0) {
previousHiglight.pop().forEach(word => {
contentDiv.querySelector(`[data-index="${word}"]`).style.backgroundColor = '';
});
}
}
function getSelectionElements(e) {
const sel = window.getSelection();
if (sel.rangeCount) {
// container to store all seleccted elements
const container = document.createElement('div');
for (let i = 0, len = sel.rangeCount; i < len; ++i) {
// append elements
// if only single text node, then append element from event.target
if (sel.getRangeAt(i).cloneContents().childNodes.length > 1) {
container.appendChild(sel.getRangeAt(i).cloneContents());
} else {
container.appendChild(e.target.cloneNode());
}
}
return container;
}
}
// remove previous selection style
// add new highlight color
contentDiv.addEventListener('mousedown', (e) => {
const sheet = document.styleSheets[0];
const rules = sheet.cssRules;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
if (rule.selectorText.includes('::selection')) sheet.deleteRule(i);
}
currentColor = getRandomColor();
sheet.insertRule(`#content span::selection { background-color: ${currentColor}; }`, sheet.cssRules.length);
});
// get elements from selection
// loop and add background color to each
contentDiv.addEventListener('mouseup', (e) => {
const selectedElements = getSelectionElements(e);
if (selectedElements) {
const indexes = [];
selectedElements.childNodes.forEach(el => {
let index = el.dataset.index;
let word = el.textContent.trim();
if (!isHighlighted(index)) {
selectedWords = {};
selectedWords.startIndex = index;
selectedWords.endIndex = index;
selectedWords[index] = word;
contentDiv.querySelector(`[data-index="${index}"]`).style.backgroundColor = currentColor;
indexes.push(index);
}
});
previousHiglight.push(indexes);
}
});
};
#buttons {
margin-top: 30px;
}
.line-break {
display: block;
margin: 15px;
}
#trial_display {
display: block;
padding: 50px;
}
#title {
text-align: center;
font-size: 20px;
}
#content {
display: block;
border: 2px solid gray;
padding: 50px;
}
<div id="trial_display">
<div id="content">
<p id="textParagraph" style="display:none"></p>
</div>
<div id="buttons">
<button id="removeHighlight">Clear</button>
<button id="undoHighlight">Undo</button>
</div>
</div>