I'm trying to write a basic text editor using contenteditable. In this MCVE, it only has one function, which is that selected text is given a red highlight.†
The code I'm using is here:
function createSpan() {
let selection = document.getSelection();
let range = selection.getRangeAt(0);
let element = document.createElement("span");
element.className = "inline-equation";
range.surroundContents(element);
let newRange = new Range();
newRange.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(newRange);
}
$("button").click(createSpan)
.inline-equation {
background-color: red;
display: inline-block;
}
#editor {
width: 100%;
height: 100%;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<button>
Create Span
</button>
<div id="editor" contenteditable="true">
This is a contenteditable area.
</div>
I'm having trouble with the idea that the user may move out of the highlight area and continue typing in unformatted text. To experience this issue:
Run the Stack Snippet above
Select the text from somewhere in the middle til the end, then click Create Span
Type some new text at the end of the line
This new text has the red highlight too, even if you attempt to move out of the inserted span by pressing the right arrow key.
I'd still like to give the user the option to append new text which is formatted, but then also allow the user to navigate out of the span so that they may continue to type normal text.
In other words, the span should act as a completely separate editable object which may be moved into or out of. This includes the ability to move out of the span even if it's at the end of the document, so that the user can continue typing in non-formatted text.
The best example I am able to give of what I'd like is Microsoft Word's inline equations. Notice how, in the GIF below, the equation acts as a separate object, which I may navigate out of so that I can type normal text to the right of it. The is how I'd like my span to act.
I've tried replacing the span with a div with inline-block formatting to see if that affected the behaviour, but it didn't. How should I achieve the effect I'm looking for?
† In the actual use case, the 'highlight' actually denotes LaTeX-formatted mathematics which are rendered later. I'm writing what is essentially an editor for a proprietary markup language which supports inline LaTeX.
The issue is that you need something editable at end for this to work. There are lot of existing SO thread for the same. You can see below
Why Is My Contenteditable Cursor Jumping to the End in Chrome?
contenteditable put caret outside inserted span
contenteditable with nested span. Who has the focus?
Focusing on nested contenteditable element
Combining knowledge from above thread the simplest thing I could think of was adding below keyup handler
$("#editor").on('keyup',(e) => {
var editor = $("#editor").get(0)
var cn = editor.childNodes;
if (cn[cn.length - 1].nodeType !== Node.TEXT_NODE)
{
empty = document.createTextNode( '\uFEFF' );
editor.appendChild(empty);
}
if (cn[0].nodeType !== Node.TEXT_NODE)
{
empty = document.createTextNode( '\uFEFF' );
editor.prepend(empty);
}
})
Which makes sure there is one text node to step out of the div. You can do the same thing for the starting div if you want. Below is JSFiddle for the same
https://jsfiddle.net/4tcLr0qa/1/
Related
The behavior I'm looking to implement would wrap each word the user types in <div class="word"></div> as they type their message. The parent div they're typing into has contenteditable="true".
One complicating factor is that for this use case one "word" div may contain two words (imagine a name would be considered one "word" so e.g. <div class="word">Bob Smith</div> may occur). This means I can't just grab all of the textContent when the user presses space and split(" ") into an array to build the div.word DOM elements.
I'm thinking when the user presses space I can get all the child nodes of the contenteditable div and loop through them to check which is a textNode and which is not (i.e. a word already wrapped in div.word). Then for the text nodes only I can build a div.word DOM element and append all of these to the contenteditable div.
I hope that's clear. Here's sort of where I'm at:
<div id="editor" contenteditable></div>
#editor {
border: 1px solid #333;
padding: 10px;
}
#editor .word {
background: yellow;
display: inline-block;
}
const editorElement = document.getElementById('editor');
function handleSpacebarPress() {
// Get all editor child nodes
let editorChildNodes = [...editor.childNodes];
// Clear editor of all child nodes
editor.innerHTML = '';
editorChildNodes.forEach(node => {
// If node is a text node (not a div.word element)
if (node.nodeType === 3) {
// Create div.word
let wordDiv = document.createElement('div');
wordDiv.className = 'word';
wordDiv.textContent = node.textContent;
editor.appendChild(wordDiv);
}
// Else node is already a div.word element
else {
editor.appendChild(node);
}
});
// Return caret to end of editor
const editorLength = editor.childNodes.length;
const lastNode = editor.childNodes[editorLength - 1]; // Last editor node
const range = document.createRange();
const selection = window.getSelection();
range.setStart(lastNode, 1);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
editorElement.addEventListener('keydown', e => {
if (e.code === 'Space') {
handleSpacebarPress();
}
});
You can view this on jsfiddle here.
Right now it seems to just place all words directly into the one div.word element and I can't seem to handle creating a new one for each word.
Any ideas?
I guess you want to achieve something like this:
Note how the spaces between the words are not highlighted and therefore have to be inside the main editor. You could fix this (as you pointed out in the comments) by adding spaces after each word in the editor and trim()-ming each word so that there are no spaces at the start or end of inner nodes.
You might to rethink your code, because it has some disadvantages.
Basically you are reimplementing what basic editors with custom syntax highlighting already do.
Manually setting the cursor position does not work, if you are editing in the middle of the editor.
Copy & pasting text wont trigger handleSpacebarPress
If you want to program the editor yourself, I suggest you use a function highlightWords that gets triggered after every change event (maybe with some artificial delay). In that function you can use a regular expression to find all the words according to your criteria and replace them with <span class=word>$1</span>.
var range = document.createRange();
//start range at a point somewhere in the first #text node
range.setStart(document.getElementById('my_textarea').childNodes[0], 4);
//end range outside of span
range.setEnd(document.getElementById('my_textarea').childNodes[2], 0);
range.startContainer.innerHTML = "hi";
<div id="my_textarea" contenteditable>
000000000<span id="test_span" style="font-weight:bold;">000000000</span>
</div>
I have a range and I would like to insert an opening span tag in the startContainer at startOffset. I am unsure of how to even alter the html of the startContainer. My problem may be arising because startContainer is #text which is a weird browser implementation, not sure. The following code has no effect:
range.startContainer.outerHTML = "<b>This is an example of modifying the startContainers outerHTML</b>";
This does not produce the expected results. I have a codepen however the issue may be a little difficult to recreate if your not familiar with the code and also it may only work in firefox, have yet to test it in other browsers.
https://codepen.io/justinhdevelopment/pen/GRZzEom
Sorry about all the if statements but the area of interest would be:
} //End for loop
}else if(range.commonAncestorContainer == textarea && (range.startContainer.nodeName === "#text" && range.endContainer.nodeName === "#text")){
if(range.startContainer.parentNode.nodeName === "SPAN"){ console.log("Start Container Parent Node is SPAN");}else{
console.log("OOOOOOOOOOOOOMMMMMMMGGGGGGGGG");
To recreate the problem, type a string (example: 00000000000000000000000) highlight a portion of the end and click bold. Then highlight another portion of the end that contains the bold part as well as non bold text ( the point here is to make a selection that fully contains the bold span and also contains non styled text) This will set the commonAncestorContainer to the textarea and the start and end container to #text node. Now with this Range I can splice in an opening span tag and end tag, but I can't seem to alter the HTML of the startContainer. I apologize if I dont make sense, but if any clarification is needed I will gratefully explain. Thank you for your help and time.
Since the text node doesn't contain any HTML, outerHTML does nothing for the text node.
Consider using a span instead.
var span = document.getElementById("span");
span.outerHTML = '<span class="blue">changed!</span>'
.blue {
color: blue;
}
<body style="font-family:sans-serif;margin:0">
<p>Let's change <span id="span">this</span> word blue.</p>
</body>
Looking at the methods available on Range, it seems like what you want to do is deleteContents(), then insertNode() to add your new text.
var range = document.createRange();
//start range at a point somewhere in the first #text node
range.setStart(document.getElementById('my_textarea').childNodes[0], 4);
//end range outside of span
range.setEnd(document.getElementById('my_textarea').childNodes[2], 0);
range.deleteContents();
range.insertNode(document.createTextNode("hi"));
<div id="my_textarea" contenteditable>
000000000<span id="test_span" style="font-weight:bold;">000000000</span>
</div>
If, however, you just want to surround the contents of the range with a tag, use surroundContents()
var range = document.createRange();
//start range at a point somewhere in the first #text node
range.setStart(document.getElementById('my_textarea').childNodes[0], 4);
//end range outside of span
range.setEnd(document.getElementById('my_textarea').childNodes[2], 0);
var newSpan = document.createElement("span");
newSpan.classList.add("red");
range.surroundContents(newSpan);
.red { color: red; }
<div id="my_textarea" contenteditable>
000000000<span id="test_span" style="font-weight:bold;">000000000</span>
</div>
Range.surroundContents()
is the correct answer due to the inability to bypass the node insertion method javascript uses to manipulate the DOM.
surroundContents will not work if your range endContainer or startContainer is a sub-child of the commonAncestorContainer. So you will need to use setStartBefore and setEndAfter to properly surround the range.
I've been working on how to allow visitors to select multiple sections of text wrapped in <p> tags to highlight them, and then click a button to remove all highlights.
Selected text highlights fine with added classList to created <span> elements.
If user selects some text that overlaps highlighted text,
Uncaught DOMException: Failed to execute 'surroundContents' on 'Range': The Range has partially selected a non-Text node.
Removing the classList produces fragmented text with empty spans.
I have tried to remove the child elements from the parent, but that removes the original text as well as the tag element.
I think the DOMException is because of the created span tag, but I'm not sure how to remove them when a new selection overlaps.
I have looked at many SO articles, but they seem to focus on JQuery, which I am not using. There is still a lot I do not understand about JavaScript, so MDN has helped a bit, but I sometimes struggle to apply the concepts.
// HIGHLIGHT SELECTIONS
const elementToHighlight = document.getElementById('higlight-this');
elementToHighlight.addEventListener('mouseup', selection);
function selection() {
let element = document.createElement('span');
element.classList.add('hl');
window.getSelection().getRangeAt(0).surroundContents(element);
}
// REMOVE hl CLASS
const removeClassListFromAll = document.getElementById('remove');
removeClassListFromAll.addEventListener('click', () => {
let grabHighlighted = document.getElementsByClassName('hl');
while (grabHighlighted.length) {
grabHighlighted[0].classList.remove('hl');
// grabHighlighted[0].parentElement.removeChild.(grabHighlighted[0]);
}
});
.hl {
background-color: yellow;
}
<section id="higlight-this">
<p>Some text to test with. Make it look good with highlights!.<br>
If you don't, It won't be useful for you.</p>
</section>
<button id="remove">remove</button>
the main problem is your selection container is not right try to identify your selection using console.log() and add if else or switch like :
if(window.getSelection().getRangeAt(0).commonAncestorContainer.nodeName == "P")
window.getSelection().getRangeAt(0).surroundContents(element);
else { //this case when i select from top to bottom with the button
window.getSelection().getRangeAt(0).commonAncestorContainer.querySelector("#higlight-this").querySelector("p").getRangeAt(0).surroundContents(element);
}
I have this HTML code with pre-written message. My goal is to highlight text between [quote] [/quote] in a yellow background once I focus/click on the text area.
<textarea>
This is a test message.
[quote]Wise man said he is wise.[/quote] There could be more quotes too:
[quote]this is second quote [/quote]
He is correct.
</textarea>
Is it possible to do it with pure Javascript? I think it should be something like:
textarea onfocus="function()">
find text between [quote][/quote]
apply yellow background to found text: background Color='#ffc'
....
(and if there is no [quote] [/quote] found then it should do nothing, ie. no warnings).
Since you cannot do that using <textatea> i'd suggest to take a look at
<div contenteditable>
</div>
here's an example:
var area = document.getElementById("area");
var text = area.innerHTML;
area.innerHTML = text.replace(/\[\s*quote.*\](.*)[^[]*\[\s*\/quote.*\]/ig, "<span>$1</span>");
[contenteditable]{
white-space:pre-wrap;
}
[contenteditable] span{
background:#ffc;
}
<div id="area" contenteditable>
This is a test message.
[quote]Wise man said he is wise.[/quote] There could be more quotes too:
[quote]this is second quote [/quote]
He is correct.
</div>
Otherwise, since you cannot treat HTML elements inside a textarea like actual HTML elements in order to highlight them → you should create an in-memory element with the same size (font-size etc) of your textarea, do the above, calculate the positions of the generated span elements, than apply some higlight overlays over the respective positions over your textarea, take care that they "follow-up" if the window resizes... and the story goes...
Here's a jQuery plugin to achieve the above-mentioned:
http://mistic100.github.io/jquery-highlighttextarea/
Currently I'm investigating two approaches: Highlight Text Inside a Textarea, which describes how this plugin is done: https://github.com/lonekorean/highlight-within-textarea
And syntax higlighter for MediaWiki: source, description of approach.
Both of them use additional element behind textarea with the same font and positioning to show background colors. Textarea background is made transparent. Then on edit and scroll you sync contents and scroll between textarea and element behind.
Here is my simplified code for it: https://codepen.io/bunyk-1472854887/full/RLJbNq/
Core logic of the highlighter is like this (some details skipped):
textarea.addEventListener('keyup', textUpdate);
textarea.addEventListener('scroll', scrollUpdate);
function textUpdate() {
var html = html_escape(textarea.value);
enter code here
html = html.replace(/\[quote\](.*?)\[\/quote\]/g, '[quote]<span class="quote">$1</span>[/quote]');
background.innerHTML = html;
}
function scrollUpdate() {
background.scrollTop = textarea.scrollTop;
};
I'm facing an issue with the combination of using appendChild() and Range.selectNode() in JavaScript.
When attempting to use a range to select the newly-appended <textarea> node, it selects too much of the DOM. Copying and pasting the selection seems to just contain a space.
However, if I put the <textarea> node into the DOM from the start (i.e. don't add it with appendChild()) then it works perfectly well and I can copy and paste the selected text as expected.
Note that the CSS isn't really necessary here, but it highlights the fact that the selection contains more than just the <textarea> (or at least it does in Chrome).
HTML:
<div>
<a class="hoverTrigger">Click to trigger textarea element with selected text</a>
</div>
CSS:
.floating {
position: absolute;
}
JavaScript/jQuery (run on DOM ready):
$(".hoverTrigger").click(createAndSelectStuff);
function createAndSelectStuff() {
var textArea = document.createElement("textarea");
textArea.className = "floating";
textArea.value = "Some dynamic text to select";
this.parentNode.appendChild(textArea);
selectObjectText(textArea);
return false;
}
function selectObjectText(container) {
var range = document.createRange();
range.selectNode(container);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
Here's a jsFiddle.
This is what the resulting selection looks like in Chrome:
How can I stop this happening, and just select the desired text?
Replace your call to selectObjectText with:
container.setSelectionRange(0, container.value.length);
The problem with textarea elements is that they do not hold their contents in DOM nodes. The text value is a property of the element. When you call range.selectNode, what happens is that the range is set so as to encompass the node you pass to the function and the children node of this node, but since a textarea does not store its text in children nodes, then you select only the textarea.
setSelectionRange works with the value of an input element so it does not suffer from this problem. You might want to check the compatibility matrix here to check which browsers support it.