Selection object behaving strange in Chrome contentEditable element - javascript

I'm working on a realtime syntax highlighter in javascript using contenteditable. When parsing content I extract the text of the div and use regex patterns to style it properly. Then I set the innerHtml of the div to the parsed content. However, this makes the cursor disappear from the screen.
I have created this function to reset the cursor, and it works fine in Firefox. But in Chrome the cursor is moving in a wierd way that is semi-predictable. It's usually set at the first space-char in the document instead of the place it was right before the parsing.
The caret char that is stored in the variable cc is at the place it should be though.
/**
* Put cursor back to its original position after every parsing, and
* insert whitespace to match indentation level of the line above this one.
*/
findString : function()
{
cc = '\u2009'; // carret char
if ( self.find(cc) )
{
var selection = window.getSelection();
var range = selection.getRangeAt(0);
if ( this.do_indent && this.indent_level.length > 0 )
{
var newTextNode = document.createTextNode(this.indent_level);
range.insertNode(newTextNode);
range.setStartAfter(newTextNode);
this.do_indent = false;
}
selection.removeAllRanges();
selection.addRange(range);
}
}
Some facts about calling these functions:
When I uncomment the code that switches the innerHtml content the cursor usually moves to the end of the document instead.
If I uncomment the findString() caller the parsing is done but the cursor disappears until I put focus back in the div.
If I uncomment both lines the div is behaving as one would expect except, ofcourse, for the parsing.
What is causing this misbehaviour in Chrome while it works in Firefox?
EDIT: More information
I was doing some logging on window.getSelection() and noticed that it contained different information when comparing Chrome and Firefox. Which made the script behave just as it should, but with an argument that's wrong.
The really wierd thing is that when I log window.getSelection() as the very first action of the keyhandler script it behaves like this:
Without any modification I get the "wrong" object.
With a breakpoint set right under the console.log() directive, the script pauses and the log shows a "correct" object, just the way I want it.
The logger:
console.log(window.getSelection());
// Do keyhandling stuff...

Try to make a copy of the original range and add it to the selection rather than the original range at the end of the function, since the range can have dynamic boundaries, and you will end up setting back a "wrong" one.

Related

Text losing style (even though css shows it should have it), but not if code is stepped through on console (jsfiddles included)

(I have only confirmed this to happen so far with Safari 12.1.2 on MacOS Mojave)
So I have a contenteditable div with a styled span (red) and some text inside it. Nothing weird yet.
However, when I do some operations on the text (keeping all the text still within the same styled span) some of the text loses its style even though inspecting the DOM shows it should be styled (red).
Here's a jsfiddle illustrating the issue above:
1: https://jsfiddle.net/tx71pqmv/5/
$('#editable').keypress((event) => {
if (event.key != 'Enter' || !event.shiftKey)
return;
event.preventDefault()
let sel = window.getSelection()
let range = sel.getRangeAt(0)
let newLine = $('<span>')
.addClass('br')
.attr('contenteditable', 'false')[0]
range.startContainer.splitText(range.startOffset) // 1
range.insertNode(newLine) // 2
})
The weird thing is that this issue happens under VERY specific conditions.
For example, if I step through the code using the debugger, or use setTimeout to delay the execution by even 1ms (see jsfiddle #2), then everything works as intended.
Or, even just getting rid of the ::before pseudo-element on the containing div (not even the styled span) fixes the issue (see jsfiddle #3). This should be totally unrelated!
Is there a cleaner solution to the problem that doesn't involve compromising on the css or using setTimeout?
Edit: Adding screenshots of the DOM structure after pressing shift+enter on the first jsfiddle, and the second. The DOM structure on both look identical, but the one on the right doesn't have styling applied to one of the text nodes:
Here shift+Enter and Enter behaving differently for the #editable div
Enter is creating a new child (div) of #editable div
means you have more child every time you press shift enter
but Shift + Enter is creating new line inside the child div > span
browsers behave differently for contenteditables
as MDN says
Use of contenteditable across different browsers has been painful for a long time because of the differences in generated markup between browsers.
Read this for more info:
https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content
new div created using enter

ShadowRoot's getSelection().getRangeAt(0) returns incorrect Range object in Google Chrome 35

We're working on an application for our customer that uses dart lang with polymer components. One of our custom components namely datagrid uses <div contenteditable></div> for entering values to the datagrid cells. I also want to provide custom formatting capabilities so I had to override keypress event. The problem araises when I want to create new HTML nodes at the caret position within the content-editable div element in Chrome 35 (possibly all webkit browsers that support Shadow DOM natively, not through polyfills).
When I use window.getSelection().getRangeAt(0) to get current caret position, new nodes are being added to the beginning of the body element instead of the div. In Firefox and IE 11 it works great though (using polyfill).
When I tried this.shadowRoot.getSelection().getRangeAt(0) as suggested here it didn't work as well because it returns incorrect Range object (a bug or a feature?). However when I logged the Selection object, it seems like all offsets are correct so it means that it can be worked around somehow but the guy that asked similar question on SO hasn't posted how he did it.
So I don't know, how to create a Range object from given offset when content-editable div contains multiple HTML nodes as browsers like to create multiple nodes when you put a line break into the middle of the word and delete it immediately (as a side question, is it normal anyways? doesn't it cause memory leaks?). It then looks like this in a Chrome console (#cell-input here has 4 nodes although it just contains continuous string "Marek"):
<div contenteditable="true" id="cell-input">
"M"
"ar"
"ek"
<br>
</div>
I tried to use something like (it's just a pseudocode):
offset = shadowRoot.getSelection().extentOffset;
element = document.querySelector('cell-input');
range = document.createRange();
range.setStart(element.children.first, offset );
range.setEnd(element.children.first, offset);
range.collapse(false);
... but it didn't work properly. Any ideas?
Seems like it is a bug reported by me here and confirmed by the chromium team at the end of the issue thread.
I've created a plunk which shows how we can save shadowRoot selection range and use it when needed.
Problem is this.shadowRoot.getSelection().getRangeAt(0) returns incorrect range, you've to create range from this.shadowRoot.getSelection() by getSelection().setStart, getSelection().setEnd. For more understanding see the plunk
shadowRoot.getSelection.getRangeAt(0) instead use
var selRange=document.createRange();
var shadowRootSelection = this.shadowRoot.getSelection();
selRange.setStart(shadowRootSelection.anchorNode, shadowRootSelection.anchorOffset);
selRange.setEnd(shadowRootSelection.focusNode, shadowRootSelection.focusOffset);

rangy fix boundaries

When selecting text there is some variation on exactly where the selection starts and ends as in sometimes it starts at the end of the previous element and sometimes at the start of the textnode. I'm trying to normalize this so it always starts at the beginning of the element containing the text and ends at the end of the element containing the text and make it consistent across browsers.
e.g. <b>mouse></b><i>cat</i>
When selecting "cat", chrome always seems to do the right thing and return a selection with startContainer cat and startOffset 0. Firefox and occasionally IE8 will often start at the end of the previous element (mouse) with startOffset 5
My crude attempt to fix this has not been successful:
var sr=rangy.getSelection().getRangeAt(0);
var sc=sr.startContainer;
if(sc.nodeType!=3||sr.startOffset==sc.length)
{
sr.setStartAfter(sc); //move start to next node in range
}
rangy.getSelection().setSingleRange(sr);
console.log(sr.inspect());
What am I missing?
Ok I think I've cracked it but I am very open to comments or suggestions on how to improve it. Somehow it lacks elegance and I feel there should be a better way. The problem only seems to need fixing in firefox. Chrome and IE8 never seem to select outside the element the text is contained in. Anyway this is what seems to work for me. (so far)
var sr=rangy.getSelection().getRangeAt(0);
var sc=sr.startContainer,ec=sr.endContainer;
if(sc.nodeType!=3||sr.startOffset==sc.length)
{
sc=(sc.nextSibling)?sc.nextSibling:sc.parentNode.nextSibling;
if(sc.nodeType!=3)sc=sc.firstChild;
sr.setStart(sc,0);
}
if(ec.nodeType!=3||sr.endOffset==0)
{
ec=(ec.previousSibling)?ec.previousSibling:ec.parentNode.previousSibling;
if(ec.nodeType!=3)ec=ec.lastChild;
sr.setEnd(ec,ec.length);
}
rangy.getSelection().setSingleRange(sr);
console.log(sr.inspect());

getting selected text & text indexes in a <pre> tag, where the offset is the start of the pre

So i have a pre tag like so:
<pre> Some content, more content. <span>Coloured content</span>. Some more content</pre>
What i want to do is setup an event using javascript or jquery that binds a mouseup event. When the user selects text, i want to get the indexes offset from the start of the pre, so it ignores the span tags per say. So if someone selects the text after the span tag, it knows to offset from the pre opening.
Is there a way I can do this? It looks like window.getSelection starts it off after the span tag.
Given this HTML
<pre>0<span>1</span>23<span>4<span>56<span><span>7</span></span>8</span></span></pre>
you want to get the first selected digit as output/offset, right?
The basic idea is to navigate to the left in the DOM tree until there is no more node with the same parent. Then climb up to finally reach the pre tag. Whilst navigating through the tree towards the upper left, all characters of the visited elements are counted and added to the final result.
$('pre').on('mouseup', function(){
var selection = window.getSelection();
// Get the offset within the container that holds all of the selection
var offset = selection.anchorOffset;
// A reference to the currently examined node
var currentNode = selection.anchorNode;
// Wander around until we hit the pre
while(currentNode!==this){
if(currentNode.previousSibling){
// If there is a node left of us (with the same parent) navigate to the left and sum up the text length in the left node.
// There is no need to check the children (possibly existent) since these would be included in text's return value
offset = offset + $(currentNode.previousSibling).text().length;
// Navigate to the left node of the current
currentNode = currentNode.previousSibling;
} else {
// There is no left node so climb up towards the pre tag
currentNode = currentNode.parentNode;
}
}
// Print the final result
console.log(offset);
});
The script should output the required number. So if you are selecting 78 you'd get 7 as output.
I did only test this code in Firefox. Other browsers should work as well if they implement HTML Editing API. IE does not support it until version 9. The same applies for getSelection (see MSDN).
This kind of thing gets very complicated, especially when you need to worry about cross-browser implementations (*cough* IE *cough*). I therefore strongly recommend Rangy, a "cross-browser JavaScript range and selection library." I've used it a bit myself and found it to work perfectly.
The library's author, Tim Down, has answered a lot of questions on SO about range and selection issues, and you'll see how complicated they get :)

How can I make a plain text paste in a contentEditable span without breaking undo?

Oddly specific question, but I have a solution already to paste plain text in a <span contentEditable="true"> by using a hidden textarea, which seems to work really well, except that it breaks the browser's undo feature. Right off the bat I'm not worried about a cross-browser solution; I only care about Chrome. My approach looks roughly like this:
$('.editable').live('paste', function()
{
var $this = $(this);
//more code here to remember caret position, etc
$('#clipboard').val('').focus(); //put the focus in the hidden textarea so that, when the paste actually occurs, it's auto-sanitized by the textarea
setTimeout(function() //then this will be executed immediately after the paste actually occurs
{
$this.focus();
document.execCommand('insertHTML', true, $('#clipboard').val());
});
});
So this works -- I can paste anything and it's reduced to plain text before going into the my contentEditable field -- but if I try to undo after pasting:
First undo undoes the paste.
Second undo tries to undo the changes to #clipboard, moving the focus away from my contentEditable.
I've tried everything I can think of to make the browser not try to undo the changes to #clipboard -- toggling display:none when it's not actively in use, toggling readonly and disabled state, destroying it at the end of and recreating it at the beginning of the event above, various other hacks -- but nothing seems to work.
Is this a terrible approach to sanitization? This is the first thing I've managed to really get working -- trying to clean up the markup after the paste occurs didn't work, as there are some things (entire HTML documents) which, when pasted, crash the browser, which I'd like to avoid.
Is there any way to make the #clipboard not undoable, or any other suggestions of how to get this working?
Edit
I managed to improve things a little bit by adding the line
$('#clipboard').val('');
Right after the execCommand line. This seems to neutralize undo completely: the caret no longer leaves the contentEditable field, but nothing gets undone at all. A bit of an improvement, but I'm still searching for a proper solution.
CodeMirror 1 does this by stripping away formatting after text is pasted. CodeMirror 2 does this by actually having an invisible textarea handle everything, and render the text and cursor manually.
CodeMirror's website describes how it works in more detail: http://codemirror.net/internals.html
Beyond that, there's always the CodeMirror source code. You can decide for yourself whether CodeMirror 1 or CodeMirror 2's approach is more suitable for your purposes. :)
Do you try that?
setTimeout(function() //then this will be executed immediately after the paste actually occurs
{
$this.focus();
document.execCommand('insertHTML', true, $('#clipboard').val());
var t = document.body.innerHTML;
document.execCommand("undo");
document.body.innerHTML = t;
});
I think it can help.
But I think you must use event object. Unfortunately there may be a problem cuz security reasons.
In onpaste:
Store the current selection.
var sel = window.getSelection();
var rangeĀ  = selObj.getRangeAt(0).cloneRange;
// Store the range object somewhere.
Modify the selection object to point to your hidden textarea.
Set a timeout with a delay of 0 (occurs immediately after paste).
In the timeout function, grab the data from the hidden textarea, then:
var sel = window.getSelection();
sel.removeAllRanges();
var range = // restore the range object from before.
sel.addRange(range);
document.execCommand("insertHTML", false, /* contents of your textarea here */);
Now if you wanted to do this for actual HTML content, you'd be in a world of hurt....
Insert a <pre contenteditable="true">...</pre>. As I recall that's exactly what I understand you want. (Unfortunately I'm not yet allowed to join everyone in the comments, but I suppose this is an attempt to answer anyway.)

Categories