getSelection add html, but prevent nesting of html - javascript

I have a content editable field, in which I can enter and html format som text (select text and click a button to add <span class="word"></span> around it).
I use the following function:
function highlightSelection() {
if (window.getSelection) {
let sel = window.getSelection();
if (sel.rangeCount > 0) {
if(sel.anchorNode.parentElement.classList.value) {
let range = sel.getRangeAt(0).cloneRange();
let newParent = document.createElement('span');
newParent.classList.add("word");
range.surroundContents(newParent);
sel.removeAllRanges();
sel.addRange(range);
} else {
console.log("Already in span!");
// end span - start new.
}
}
}
}
But in the case where I have:
Hello my
<span class="word">name is</span>
Benny
AND I select "is" and click my button I need to prevent the html from nesting, so instead of
Hello my
<span class="word">name <span class="word">is</span></span> Benny
I need:
Hello my
<span class="word">name</span>
<span class="word">is</span>
Benny
I try to check the parent class, to see if it is set, but how do I prevent nested html - close span tag at caret start position, add and at the end of caret position add so the html will not nest?
It should also take into account if there are elements after selection which are included in the span:
So:
Hello my
<span class="word">name is Benny</span>
selecting IS again and clicking my button gives:
Hello my
<span class="word">name</span>
<span class="word">is</span>
<span class="word">Benny</span>
Any help is appreciated!
Thanks in advance.

One way would be to do this in multiple pass.
First you wrap your content, almost blindly like you are currently doing.
Then, you check if in this content there were some .word content. If so, you extract its content inside the new wrapper you just created.
Then, you check if your new wrapper is itself in a .word container.
If so, you get the content that was before the selection and wrap it in its own new wrapper. You do the same with the content after the selection.
At this stage we may have three .word containers inside the initial one. We thus have to extract the content of the initial one, and remove it. Our three wrappers are now independent.
function highlightSelection() {
if (window.getSelection) {
const sel = window.getSelection();
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0).cloneRange();
if (range.collapsed) { // nothing to do
return;
}
// first pass, wrap (almost) carelessly
wrapRangeInWordSpan(range);
// second pass, find the nested .word
// and wrap the content before and after it in their own spans
const inner = document.querySelector(".word .word");
if (!inner) {
// there is a case I couldn't identify correctly
// when selecting two .word start to end, where the empty spans stick around
// we remove them here
range.startContainer.parentNode.querySelectorAll(".word:empty")
.forEach((node) => node.remove());
return;
}
const parent = inner.closest(".word:not(:scope)");
const extractingRange = document.createRange();
// wrap the content before
extractingRange.selectNode(parent);
extractingRange.setEndBefore(inner);
wrapRangeInWordSpan(extractingRange);
// wrap the content after
extractingRange.selectNode(parent);
extractingRange.setStartAfter(inner);
wrapRangeInWordSpan(extractingRange);
// finally, extract all the contents from parent
// to its own parent and remove it, now that it's empty
moveContentBefore(parent)
}
}
}
document.querySelector("button").onclick = highlightSelection;
function wrapRangeInWordSpan(range) {
if (
!range.toString().length && // empty text content
!range.cloneContents().querySelector("img") // and not an <img>
) {
return; // empty range, do nothing (collapsed may not work)
}
const content = range.extractContents();
const newParent = document.createElement('span');
newParent.classList.add("word");
newParent.appendChild(content);
range.insertNode(newParent);
// if our content was wrapping .word spans,
// move their content in the new parent
// and remove them now that they're empty
newParent.querySelectorAll(".word").forEach(moveContentBefore);
}
function moveContentBefore(parent) {
const iterator = document.createNodeIterator(parent);
let currentNode;
// walk through all nodes
while ((currentNode = iterator.nextNode())) {
// move them to the grand-parent
parent.before(currentNode);
}
// remove the now empty parent
parent.remove();
}
.word {
display: inline-block;
border: 1px solid red;
}
[contenteditable] {
white-space: pre; /* leading spaces will get ignored once highlighted */
}
<button>highlight</button>
<div contenteditable
>Hello my <span class="word">name is</span> Benny</div>
But beware, this is just a rough proof of concept, I didn't do any heavy testings and there may very well be odd cases where it will just fail (content-editable is a nightmare).
Also, this doesn't handle cases where one would copy-paste or drag & drop HTML content.

I modified your code to a working approach:
document.getElementById('btn').addEventListener('click', () => {
if (window.getSelection) {
var sel = window.getSelection(),
range = sel.getRangeAt(0).cloneRange();
sel.anchorNode.parentElement.className != 'word' ?
addSpan(range) : console.log('Already tagged');
}
});
const addSpan = (range) => {
var newParent = document.createElement('span');
newParent.classList.add("word");
range.surroundContents(newParent);
}
.word{color:orange}button{margin:10px 0}
<div id="text">lorem ipsum Benny</div>
<button id="btn">Add span tag to selection</button>

Related

Insert text at highest tree level in contenteditable

I am trying to insert some HTML and some text in a contenteditable.
However, when I'm in this configuration:
Some text <span>A span tag</span>
If I have my caret at the end of the text and try to insert some text like this:
document.execCommand('insertHTML', false, ' my text');
Instead of getting what I expect:
Some text <span>A span tag</span> my text
I get this:
Some text <span>A span tag my text</span>
How can I force insertHTML to insert at the highest level?
You basically need to set the carretPosition at the end of the selection and then insert your html, I made a small snippet demonstrating this behavior
One you set the range based on a node, you can then choose to selection.collapseToEnd() like so:
currentSelection.collapseToEnd();
The snippet uses the mousedown event to make sure the focus stays on the contenteditable item
document.querySelector('#addContentButton').addEventListener('mousedown', (e) => {
var currentSelection = window.getSelection();
// make sure there is a range available
if (currentSelection.rangeCount > 0) {
// get the container from where it started
var target = currentSelection.getRangeAt(0).startContainer;
var range = document.createRange();
// check if it has any childNodes, if so take the last node otherwise choose the container itself
var targetNode = target.childNodes && target.childNodes.length ? target.childNodes[target.childNodes.length - 1] : target;
// select the node
range.selectNode( targetNode );
// remove all ranges from the window
currentSelection.removeAllRanges();
// add the new range
currentSelection.addRange( range );
// set the position of the carret at the end
currentSelection.collapseToEnd();
// focus the element if posible
target.focus && target.focus();
// insert the html at the end
document.execCommand('insertHTML', false, 'test');
}
e.preventDefault();
});
document.querySelector('#insertCurrentPosition').addEventListener('mousedown', (e) => {
var currentSelection = window.getSelection();
// make sure there is a range available
if (currentSelection.rangeCount > 0) {
// get the container from where it started
var target = currentSelection.getRangeAt(0).startContainer;
// focus the element if posible
target.focus && target.focus();
// insert the html at the end
document.execCommand('insertHTML', false, 'test');
}
e.preventDefault();
});
span {
margin: 0 5px;
}
<div contenteditable="true">This is <span>my span</span><span>and another one</span></div>
<button id="addContentButton">Append at end of editable element</button>
<button id="insertCurrentPosition">Insert at current position</button>
Not sure I 100% understand your question / problem, but inserting text into a span element is as simple as this:
HTML:
<span id="mySpan"></span>
JavaScript:
document.getElementById('mySpan').innerHTML = "Text to put inside span";
'contenteditable' simple returns a true/false indicating whether an object can or cannot be edited.
Hope this helped

Getting text selection with javascript, then modify text

I have a text on an HTML page. If the user selects a word, I can retrieve the selected word and know exactly what he or she selected. However, I now want to also modify this word on the screen and make it bold. But I do not know how I would do this, since I only know the word clicked, but do not see an easy way to find the word in the HTML and modify it. I could of course search for it, but there is the risk of a word appearing multiple times.
A different way I thought about would be to give each word a unique idea and have something like this:
<span id="1">The</span>
<span id="2">fox</span>
<span id="3">jumps</span>
<span id="4">over</span>
<span id="5">the</span>
<span id="6">fence</span>
But I do not like this solution. This, too, seems overly complicated, does it not? Any suggestions how else I could access the exact words selected?
You can dynamically create a span surrounding the selected word:
const p = document.querySelector('p');
p.addEventListener('mouseup', () => {
const range = document.getSelection().getRangeAt(0);
do {
const charBefore = range.startContainer.textContent[range.startOffset - 1];
if (charBefore.match(/\s/)) break;
range.setStart(range.startContainer, range.startOffset - 1);
} while (range.startOffset !== 0);
do {
const charAfter = range.endContainer.textContent[range.endOffset];
if (charAfter.match(/\s/)) break;
range.setEnd(range.endContainer, range.endOffset + 1);
} while (range.endOffset !== range.endContainer.textContent.length);
const span = document.createElement('span');
span.style.fontWeight = 'bold';
range.surroundContents(span);
});
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<p>The fox jumps over the fence.</p>
No need of jQuery, also no need of IDs for each <span>.
The idea is to add a class to the span once it is clicked and later you can retrieve all elements with that bolded class.
Here is a solution with pure Javascript:
// comments inline
var spans = document.getElementsByTagName('span'); // get all <span> elements
for(var i=0, l = spans.length; i<l; i++){
spans[i].addEventListener('click', function(){ // add 'click' event listener to all spans
this.classList.toggle('strong'); // add class 'strong' when clicked and remove it when clicked again
});
}
.strong {
font-weight: bold;
}
<span>The</span>
<span>fox</span>
<span>jumps</span>
<span>over</span>
<span>the</span>
<span>fence</span>
Read up: Element.getElementsByTagName() - Web APIs | MDN
$("p").mouseup(function() {
var selection = getSelected().toString();
$(this).html(function(){
return $(this).text().replace(selection, '<strong>' + selection +'</strong>');
});
});
var getSelected = function(){
var t = '';
if(window.getSelection) {
t = window.getSelection();
} else if(document.getSelection) {
t = document.getSelection();
} else if(document.selection) {
t = document.selection.createRange().text;
}
return t;
}
strong{ font-weight: bold; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<p>The fox jumps over the fence.</p>

Insert character at specific point but preserving tags?

Update #2
Okay, more testing ensues. It looks like the code works fine when I use a faux spacer, but the regex eventually fails. Specifically, the following scenarios work:
Select words above or below the a tag
You select only one line either directly above or below the a tag
You select more than one line above/below the a tag
You select more than one line specifically below any a tag
The following scenarios do not work:
You select the line/more lines above the a tag, and then the line/more lines below the a tag
What happens when it "doesn't work" is that it removes the a tag spacer from the DOM. This is probably a problem with the regex...
Basically, it fails when you select text around the a tag.
Update:
I don't need to wrap each line in a p tag, I can instead use an inline element, such as an a, span, or label tag, with display:inline-block and a height + width to act as a new line element (<br />). This should make it easier to modify the code, as the only part that should have to change is where I get the text in between the bounds. I should only have to change that part, selectedText.textContent, to retrieve the HTML that is also within the bounds instead of just the text.
I am creating a Phonegap that requires the user to select text. I need fine control over the text selection, however, and can no longer plop the entire text content in a pre styled p tag. Instead, I need represent a linebreak with something like <a class="space"></a>, so that the correct words can be highlighted precisely. When my text looks like this:
<p class="text">This is line one
Line two
Line three
</p>
And has .text{ white-space:pre-wrap }, the following code allows me to select words, then wrap the text with span elements to show that the text is highlighted:
$("p").on("copy", highlight);
function highlight() {
var text = window.getSelection().toString();
var selection = window.getSelection().getRangeAt(0);
var selectedText = selection.extractContents();
var span = $("<span class='highlight'>" + selectedText.textContent + "</span>");
selection.insertNode(span[0]);
if (selectedText.childNodes[1] != undefined) {
$(selectedText.childNodes[1]).remove();
}
var txt = $('.text').html();
$('.text').html(txt.replace(/<\/span>(?:\s)*<span class="highlight">/g, ''));
$(".output ul").html("");
$(".text .highlight").each(function () {
$(".output ul").append("<li>" + $(this).text() + "</li>");
});
clearSelection();
}
function clearSelection() {
if (document.selection) {
document.selection.empty();
} else if (window.getSelection) {
window.getSelection().removeAllRanges();
}
}
This code works beautifully, but not when each line is separated by a spacer tag. The new text looks like this:
<p class="text">
Line one
<a class="space"></a>
Line two
<a class="space"></a>
Line three
</p>
When I modify the above code to work with have new lines represented by <a class="space"></a>, the code fails. It only retrieves the text of the selection, and not the HTML (selectedText.textContent). I'm not sure if the regex will also fail with an a element acting as a new line either. The a element could be a span or label, or any normally inline positioned element, to trick iOS into allowing me to select letters instead of block elements.
Is there anyway to change the code to preserve the new line elements?
jsFiddle: http://jsfiddle.net/charlescarver/39TZ9/3/
The desired output should look like this:
If the text "Line one" was highlighted:
<p class="text">
<span class="highlight">Line one</span>
<a class="space"></a>
Line two
<a class="space"></a>
Line three
</p>
If the text "Line one Line two" was highlighted:
<p class="text">
<span class="highlight">Line one
<a class="space"></a>
Line two</span>
<a class="space"></a>
Line three
</p>
Of course different parts and individual letters can and will be highlighted as well instead of full lines.
Here is a solution that supports all the features from your requirements:
HTML:
<p class="text">
First Line
<a class="space"></a>
<a class="space"></a>
Second Line
<span class="space"></span>
Third Line
<label class="space"></label>
Forth Line
</p>
<ul class="output"></ul>
CSS:
.space {
display: inline-block;
width: 100%;
}
.highlighting {
background-color: green;
}
JavaScript:
var text,
output,
unwrapContents,
mergeElements,
clearSelection,
clearHighlighting,
mergeHighlighting,
handleCopy;
unwrapContents = function unwrapContents(element) {
while(element.firstChild !== null) {
element.parentNode.insertBefore(element.firstChild, element);
}
element.parentNode.removeChild(element);
};
mergeElements = function mergeElements(startElement, endElement) {
var currentElement;
endElement = endElement.nextSibling;
while((currentElement = startElement.nextSibling) !== endElement) {
startElement.appendChild(currentElement);
}
};
clearSelection = function clearSelection() {
if (document.selection) {
document.selection.empty();
} else if (window.getSelection) {
window.getSelection().removeAllRanges();
}
};
clearHighlighting = function clearHighlighting(target, exception) {
$('.highlighting', target).each(function(index, highlighting) {
if(highlighting !== exception) {
unwrapContents(highlighting);
}
});
target.normalize();
};
mergeHighlighting = function mergeHighlighting() {
var i, j;
// Remove internal highlights
$('.highlighting', text).filter(function() {
return this.parentNode.className === 'highlighting';
}).each(function(index, highlighting) {
unwrapContents(highlighting);
});
text.normalize();
// Merge adjacent highlights
first:
for(i=0; i<text.childNodes.length-1; i++) {
if(text.childNodes[i].className === 'highlighting') {
for(j=i+1; j<text.childNodes.length; j++) {
if(text.childNodes[j].className === 'highlighting') {
mergeElements(text.childNodes[i], text.childNodes[j--]);
unwrapContents(text.childNodes[i].lastChild);
} else {
switch(text.childNodes[j].nodeType) {
case 1:
if(text.childNodes[j].className !== 'space') {
continue first;
}
break;
case 3:
if(text.childNodes[j].textContent.trim() !== '') {
continue first;
}
break;
}
}
}
}
}
};
handleCopy = function handleCopy() {
var range,
highlighting,
item;
// Highlighting
range = window.getSelection().getRangeAt(0);
highlighting = document.createElement('span');
highlighting.className = 'highlighting';
highlighting.appendChild(range.cloneContents());
range.deleteContents();
range.insertNode(highlighting);
// Output
item = document.createElement('li');
item.innerHTML = highlighting.innerHTML;
clearHighlighting(item);
output.appendChild(item);
// Cleanup
mergeHighlighting();
clearSelection();
};
$(function(){
text = $('.text')[0];
output = $('.output')[0];
$(text).on('copy', handleCopy);
});
Here is a working example http://jsbin.com/efohit/3/edit
Well, I came up with a solution, rather straightforward as well.
If .text looks like this:
<p class="text">Line one
<a class="space"></a>Line two
<a class="space"></a>Line three</p>
With the exact markup and line breaks as above, then I can find each \n and replace it with a spacer element.
if (textStr.indexOf("\n") >= 0) {
textStr = textStr.replace(/\n/g, "\n<a class='space'></a>");
}
This isn't versatile at all, and will fail if there are more than one line breaks, if the tags are different, etc. So, I encourage anyone who has a better method to answer the question! It can't be that hard, I figured it out.

How to mimic JS ranges/selections with divs as backgrounds?

I need to mimic ranges/selections (those that highlight content on a website, and when you press for ex CTRL + C you copy the content) with divs as backgrounds. Chances are that the "highlighting divs" will be position:absolute;
<div id="highlight">
<!-- The highlightor divs would go here -->
</div>
<div id="edit">
<!-- The divs to be highlighted (that have text) would go here -->
</div>
Edit: Functionalities such as copying are essential.
PS: If you're curious about "why", refer to this question.
I created a new question because I felt the old one was pretty much answered, and this one differed to much from that one.
Here's the concept, with some code to get you started. Every time text is selected in the page, append that text to a hidden textarea on the page and then select the textarea. Then, wrap the original selection in a span to make it look selected. This way, you have the appearance of a selection, and since the hidden textarea is actually selected, when the user hits "ctrl+c" they are copying the text from the textarea.
To get the full functionality you are looking for, you'll probably want to extend this. Sniff keys for "ctrl+a" (for select all). I don't think you'll be able to override right-click behavior... at least not easily or elegantly. But this much is at least a proof of concept for you to run with.
window.onload = init;
function init()
{
document.getElementById("hidden").value = "";
document.body.ondblclick = interceptSelection;
document.body.onmouseup = interceptSelection;
}
function interceptSelection(e)
{
e = e || window.event;
var target = e.target || e.srcElement;
var hidden = document.getElementById("hidden");
if (target == hidden) return;
var range, text;
if (window.getSelection)
{
var sel = getSelection();
if (sel.rangeCount == 0) return;
range = getSelection().getRangeAt(0);
}
else if (document.selection && document.selection.createRange)
{
range = document.selection.createRange();
}
text = "text" in range ? range.text : range.toString();
if (text)
{
if (range.surroundContents)
{
var span = document.createElement("span");
span.className = "selection";
range.surroundContents(span);
}
else if (range.pasteHTML)
{
range.pasteHTML("<span class=\"selection\">" + text + "</span>")
}
hidden.value += text;
}
hidden.select();
}
Here's the css I used in my test to hide the textarea and style the selected text:
#hidden
{
position: fixed;
top: -100%;
}
.selection
{
background-color: Highlight;
color: HighlightText;
}

Tag-like autocompletion and caret/cursor movement in contenteditable elements

I'm working on a jQuery plugin that will allow you to do #username style tags, like Facebook does in their status update input box.
My problem is, that even after hours of researching and experimenting, it seems REALLY hard to simply move the caret. I've managed to inject the <a> tag with someone's name, but placing the caret after it seems like rocket science, specially if it's supposed work in all browsers.
And I haven't even looked into replacing the typed #username text with the tag yet, rather than just injecting it as I'm doing right now... lol
There's a ton of questions about working with contenteditable here on Stack Overflow, and I think I've read all of them, but they don't really cover properly what I need. So any more information anyone can provide would be great :)
You could use my Rangy library, which attempts with some success to normalize browser range and selection implementations. If you've managed to insert the <a> as you say and you've got it in a variable called aElement, you can do the following:
var range = rangy.createRange();
range.setStartAfter(aElement);
range.collapse(true);
var sel = rangy.getSelection();
sel.removeAllRanges();
sel.addRange(range);
I got interested in this, so I've written the starting point for a full solution. The following uses my Rangy library with its selection save/restore module to save and restore the selection and normalize cross browser issues. It surrounds all matching text (#whatever in this case) with a link element and positions the selection where it had been previously. This is triggered after there has been no keyboard activity for one second. It should be quite reusable.
function createLink(matchedTextNode) {
var el = document.createElement("a");
el.style.backgroundColor = "yellow";
el.style.padding = "2px";
el.contentEditable = false;
var matchedName = matchedTextNode.data.slice(1); // Remove the leading #
el.href = "http://www.example.com/?name=" + matchedName;
matchedTextNode.data = matchedName;
el.appendChild(matchedTextNode);
return el;
}
function shouldLinkifyContents(el) {
return el.tagName != "A";
}
function surroundInElement(el, regex, surrounderCreateFunc, shouldSurroundFunc) {
var child = el.lastChild;
while (child) {
if (child.nodeType == 1 && shouldSurroundFunc(el)) {
surroundInElement(child, regex, surrounderCreateFunc, shouldSurroundFunc);
} else if (child.nodeType == 3) {
surroundMatchingText(child, regex, surrounderCreateFunc);
}
child = child.previousSibling;
}
}
function surroundMatchingText(textNode, regex, surrounderCreateFunc) {
var parent = textNode.parentNode;
var result, surroundingNode, matchedTextNode, matchLength, matchedText;
while ( textNode && (result = regex.exec(textNode.data)) ) {
matchedTextNode = textNode.splitText(result.index);
matchedText = result[0];
matchLength = matchedText.length;
textNode = (matchedTextNode.length > matchLength) ?
matchedTextNode.splitText(matchLength) : null;
surroundingNode = surrounderCreateFunc(matchedTextNode.cloneNode(true));
parent.insertBefore(surroundingNode, matchedTextNode);
parent.removeChild(matchedTextNode);
}
}
function updateLinks() {
var el = document.getElementById("editable");
var savedSelection = rangy.saveSelection();
surroundInElement(el, /#\w+/, createLink, shouldLinkifyContents);
rangy.restoreSelection(savedSelection);
}
var keyTimer = null, keyDelay = 1000;
function keyUpLinkifyHandler() {
if (keyTimer) {
window.clearTimeout(keyTimer);
}
keyTimer = window.setTimeout(function() {
updateLinks();
keyTimer = null;
}, keyDelay);
}
HTML:
<p contenteditable="true" id="editable" onkeyup="keyUpLinkifyHandler()">
Some editable content for #someone or other
</p>
As you say you can already insert an tag at the caret, I'm going to start from there. The first thing to do is to give your tag an id when you insert it. You should then have something like this:
<div contenteditable='true' id='status'>I went shopping with <a href='#' id='atagid'>Jane</a></div>
Here is a function that should place the cursor just after the tag.
function setCursorAfterA()
{
var atag = document.getElementById("atagid");
var parentdiv = document.getElementById("status");
var range,selection;
if(window.getSelection) //FF,Chrome,Opera,Safari,IE9+
{
parentdiv.appendChild(document.createTextNode(""));//FF wont allow cursor to be placed directly between <a> tag and the end of the div, so a space is added at the end (this can be trimmed later)
range = document.createRange();//create range object (like an invisible selection)
range.setEndAfter(atag);//set end of range selection to just after the <a> tag
range.setStartAfter(atag);//set start of range selection to just after the <a> tag
selection = window.getSelection();//get selection object (list of current selections/ranges)
selection.removeAllRanges();//remove any current selections (FF can have more than one)
parentdiv.focus();//Focuses contenteditable div (necessary for opera)
selection.addRange(range);//add our range object to the selection list (make our range visible)
}
else if(document.selection)//IE 8 and lower
{
range = document.body.createRange();//create a "Text Range" object (like an invisible selection)
range.moveToElementText(atag);//select the contents of the a tag (i.e. "Jane")
range.collapse(false);//collapse selection to end of range (between "e" and "</a>").
while(range.parentElement() == atag)//while ranges cursor is still inside <a> tag
{
range.move("character",1);//move cursor 1 character to the right
}
range.move("character",-1);//move cursor 1 character to the left
range.select()//move the actual cursor to the position of the ranges cursor
}
/*OPTIONAL:
atag.id = ""; //remove id from a tag
*/
}
EDIT:
Tested and fixed script. It definitely works in IE6, chrome 8, firefox 4, and opera 11. Don't have other browsers on hand to test, but it doesn't use any functions that have changed recently so it should work in anything that supports contenteditable.
This button is handy for testing:
<input type='button' onclick='setCursorAfterA()' value='Place Cursor After <a/> tag' >
Nico

Categories

Resources