Rangy: word under caret (again) - javascript

I'm trying to create a typeahead code to add to a wysihtml5 rich text editor.
Basically, I need to be able to insert People/hashtag references like Twitter/Github/Facebook... do.
I found some code of people trying to achieve the same kind of thing.
http://jsfiddle.net/A9z3D/
This works pretty fine except it only do suggestions for the last word and has some bugs. And I want a select box like Twitter, not a simple "selection switching" using the tab key.
For that I tried to detect the currently typed word.
getCurrentlyTypedWord: function(e) {
var iframe = this.$("iframe.wysihtml5-sandbox").get(0);
var sel = rangy.getSelection(iframe);
var word;
if (sel.rangeCount > 0 && sel.isCollapsed) {
console.debug("Rangy: ",sel);
var initialCaretPositionRange = sel.getRangeAt(0);
var rangeToExpand = initialCaretPositionRange.cloneRange();
var newStartOffset = rangeToExpand.startOffset > 0 ? rangeToExpand.startOffset - 1 : 0;
rangeToExpand.setStart(rangeToExpand.startContainer,newStartOffset);
sel.setSingleRange(rangeToExpand);
sel.expand("word", {
trim: true,
wordOptions: {
includeTrailingSpace: true,
//wordRegex: /([a-z0-9]+)*/gi
wordRegex: /[a-z0-9]+('[a-z0-9]+)*/gi
// wordRegex: /([a-z0-9]+)*/gi
}
});
word = sel.text();
sel.removeAllRanges();
sel.setSingleRange(initialCaretPositionRange);
} else {
word = "noRange";
}
console.debug("WORD=",word);
return word;
This is only triggered when the selection is collapsed.
Notice I had to handle a backward move of the start offset because if the caret is at the end of the word (like it is the case most of the time when an user is typing), then the expand function doesn't expand around the currently typed word.
This works pretty nicely until now, the problem is that it uses the alpha release of Rangy 1.3 which has the TextRangeModule. The matter is that I noticed wysihtml5 is also using Rangy in a different and incompatible version (1.2.2) (problem with rangy.dom that probably has been removed).
As Rangy uses a global window.rangy variable, I think I'll have to use version 1.2.2 anyway.
How can I do an equivalent of the expand function, using only rangy 1.2.2?
Edit: by the way, is there any other solution than using the expand function? I think it is a bit strange and hakish to modify the current selection and revert it back just to know which word is currently typed. Isn't there a solution that doesn't involve selecting the currently typed word? I mean just based on ranges once we know the initial caret collapsed range?

As Rangy uses a global window.rangy variable, I think I'll have to use version 1.2.2 anyway.
Having read Rangy's code, I had the intuition that probably it would be feasible to load two versions of Rangy in the same page. I did a google search and found I was right. Tim Down (creator of Rangy) explained it in an issue report. He gave this example:
<script type="text/javascript" src="/rangy-1.0.1/rangy-core.js"></script>
<script type="text/javascript" src="/rangy-1.0.1/rangy-cssclassapplier.js"></script>
<script type="text/javascript">
var rangy1 = rangy;
</script>
<script type="text/javascript" src="/rangy-1.1.2/rangy-core.js"></script>
<script type="text/javascript" src="/rangy-1.1.2/rangy-cssclassapplier.js"></script>
So you could load the version of Rangy that your code wants. Rename it and use this name in your code, and then load what wysihtml5 wants and leave this version as rangy.
Otherwise, having to implement expand yourself in a way that faithfully replicates what Rangy 1.3 does is not a simple matter.
Here's an extremely primitive implementation of code that would expand selections to word boundaries. This code is going to be tripped by elements starting or ending within words.
var word_sep = " ";
function expand() {
var sel = rangy.getSelection();
var range = sel.getRangeAt(0);
var start_node = range.startContainer;
if (start_node.nodeType === Node.TEXT_NODE) {
var sep_at = start_node.nodeValue.lastIndexOf(word_sep, range.startOffset);
range.setStart(start_node, (sep_at !== -1) ? sep_at + 1 : 0);
}
var end_node = range.endContainer;
if (end_node.nodeType === Node.TEXT_NODE) {
var sep_at = end_node.nodeValue.indexOf(word_sep, range.endOffset);
range.setEnd(end_node, (sep_at !== -1) ? sep_at : range.endContainer.nodeValue.length);
}
sel.setSingleRange(range);
}
Here's a fiddle for it. This should work in rangy 1.2.2. (It would even work without rangy.)

For those interested, based in #Louis suggestions, I made this JsFiddle that shows a wysihtml5 integration to know the currently typed word.
It doesn't need the use of the expand function that is in rangy 1.3 which is still an alpha release.
http://jsfiddle.net/zPxSL/2/
$(function () {
$('#txt').wysihtml5();
var editor = $('#txt').data("wysihtml5").editor;
$(".wysihtml5-sandbox").contents().find("body").click(function(e) {
getCurrentlyTypedWord();
});
$(".wysihtml5-sandbox").contents().find("body").keydown(function(e) {
getCurrentlyTypedWord();
});
function getCurrentlyTypedWord() {
var iframe = this.$("iframe.wysihtml5-sandbox").get(0);
var sel = rangy.getIframeSelection(iframe);
var wordSeparator = " ";
if (sel.rangeCount > 0) {
var selectedRange = sel.getRangeAt(0);
var isCollapsed = selectedRange.collapsed;
var isTextNode = (selectedRange.startContainer.nodeType === Node.TEXT_NODE);
var isSimpleCaret = (selectedRange.startOffset === selectedRange.endOffset);
var isSimpleCaretOnTextNode = (isCollapsed && isTextNode && isSimpleCaret);
// only trigger this behavior when the selection is collapsed on a text node container,
// and there is an empty selection (this means just a caret)
// this is definitely the case when an user is typing
if (isSimpleCaretOnTextNode) {
var textNode = selectedRange.startContainer;
var text = textNode.nodeValue;
var caretIndex = selectedRange.startOffset;
// Get word begin boundary
var startSeparatorIndex = text.lastIndexOf(wordSeparator, caretIndex);
var startWordIndex = (startSeparatorIndex !== -1) ? startSeparatorIndex + 1 : 0;
// Get word end boundary
var endSeparatorIndex = text.indexOf(wordSeparator, caretIndex);
var endWordIndex = (endSeparatorIndex !== -1) ? endSeparatorIndex : text.length
// Create word range
var wordRange = selectedRange.cloneRange();
wordRange.setStart(textNode, startWordIndex);
wordRange.setEnd(textNode, endWordIndex);
console.debug("Word range:", wordRange.toString());
return wordRange;
}
}
}
});

Related

Firing an event when the caret gets within a particular div/span/a tag and also, when the caret leaves the tag

The idea is this -
There is a contenteditable element with some text in it. Am trying to build out a tagging mechanism (kind of like twitter's people tagging when you type '#'). Whenever a user types '#', it shows up a popover with suggestions and filters when they continue typing. Until here it's easy and I have got it figured out. The problem comes when I need to show the popover if/only if the caret is over the element containing the tag.
<div contenteditable="">
<p>Some random text before
<a href="javascript:;"
class="name-suggest"
style="color:inherit !important;text-decoration:inherit !important">#samadams</a>
Some random text after</p>
</div>
Now, whenever the user moves the caret over the a tag / clicks on it, I want to trigger an event that shows the popover, and remove it whenever the caret leaves the a tag. (kind of like focus / blur but they don't seem to work). onmousedown works but there is no way to tell if the cursor has been moved into the anchor tag with the keyboard.
Also, am doing this in angularjs, so, any solution targeted towards that would be preferable but not necessary.
Have been trying to get this to work for a day and any help is greatly appreciated.
This will let you know when your caret position is in an anchor node containing an #
$('#content').on('mouseup keydown keyup', function (event) {
var sel = getSelection();
if (sel.type === "Caret") {
var anchorNodeVal = sel.anchorNode.nodeValue;
if ( anchorNodeVal.indexOf('#') >= 0) {
$('#pop').show()
} else {
$('#pop').hide()
}
}
})
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div id="content" contenteditable="">
<p>Some random text before
<a href="javascript:;"
class="name-suggest"
style="color:inherit !important;text-decoration:inherit !important">#samadams</a>
Some random text after</p>
</div>
<div id="pop" style="display:none">Twitter node found</div>
You could add some regex to further validate the selection.
There is a weird move with RegExps and offset calculation in the code below, but let me explain why it's a better solution.
I've been building a complicated editor using contenteditable about a year ago. It wasn't just a disaster. It was a fucking disaster. There is no cover-all-the-cases spec. Browsers behave differently in every possible detail and it changes frequently. Put a caret before # char and you will get this is Gecko:
<a href="#">|#name
And this in WebKit:
|<a href="#">#name
Well, unless <a> is paragraph's first child. Then result would be the same as in Gecko. Try to put caret after the nickname and both will tell it's inside the link. Start typing, and caret will pop out the element - a year ago Gecko wasn't doing it.
I've used native Selection & Range APIs in this example, they are IE9+. You may want to use Rangy instead.
$el = $('#content');
var showTip = function (nickname) {
// ...
console.log('Show: ' + nickname);
};
var dismissTip = function () {
// ...
console.log('Hide');
};
// I'm sure there is a better RegExp for this :)
var nicknameRegexp = /(^|\b|\s)\#(\w+)(\s|\b|$)/g;
var trackSelection = function () {
var selection = window.getSelection(),
range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
if (range == null || $el[0].contains(range.commonAncestorContainer) == false) {
return dismissTip();
}
var comparer = range.cloneRange();
comparer.setStart($el[0], 0);
var offset = comparer.toString().length;
var match, from, to;
while (match = nicknameRegexp.exec($el[0].textContent)) {
from = match.index + match[1].length;
to = match.index + match[1].length + match[2].length + 1;
if (offset >= from && offset <= to) {
// Force rewind, otherwise next time result might be incorrect
nicknameRegexp.lastIndex = 0;
return showTip(match[2]);
}
}
return dismissTip();
};
$el.on({
// `mousedown` can happen outside #content
'mousedown': function (e) {
$(document).one('mouseup', function (e) {
// Calling function without a tiny delay will lead to a wrong selection info
setTimeout(trackSelection, 5);
});
},
'keyup': trackSelection
});
Just looked at Fire event when caret enters span element which led me here, pretending your case was quite similar except finding if current word is specifically beginning with # for the modal to show...
The thing you need is a way to get the word we're on at the moment we move or type, then check the first character and hide/show the modal pane accordingly will be pretty easy.
function getSelectedWord(grab=document.getSelection()) {
var i = grab.focusOffset, node = grab.focusNode, // find cursor
text = node.data || node.innerText, // get focus-node text
a = text.substr(0, i), p = text.substr(i); // split on caret
return a.split(/\s/).pop() + p.split(/\s/)[0]} // cut-out at spaces
Now you can listen for keydown or selectionchange events and show your pane knowning what have already been written of the current/selected word.
editor.addEventListener('keydown', ev => {
if (ev.key.substr(0, 5) != 'Arrow') // react when we move caret or
if (ev.key != '#') return; // react when we type an '#' or quit
var word = getSelectedWord(); // <-- checking value
if (word[0] == '#') showModal(word.substr(1)); // pass without '#'
});
Note that social networks and code completion usually stops at caret position while I did check for word tail... You can go usual by removing p off of getSelectedWord function definition if desired.
Hope this still helps; Happy coding ! ;)

Get HTML of selection in a specific div

I have found a code snippet (can't remember where), and it's working fine - almost :-)
The problem is, that it copies the selection no matter where the selection is made on the entire website, and it must only copy the selection if it is in a specific div - but how is that done?
function getHTMLOfSelection () {
var range;
if (document.selection && document.selection.createRange) {
range = document.selection.createRange();
return range.htmlText;
}
else if (window.getSelection) {
var selection = window.getSelection();
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
var clonedSelection = range.cloneContents();
var div = document.createElement('div');
div.appendChild(clonedSelection);
return div.innerHTML;
} else {
return '';
}
} else {
return '';
}
}
$(document).ready(function() {
$("#test").click(function() {
var kopitekst = document.getElementById("replytekst");
var kopitjek=getHTMLOfSelection(kopitekst);
if (kopitjek=='')
{
alert("Please select some content");
}
else
{
alert(kopitjek);
}
});
});
I have made a Jsfiddle
This is my first post here. Hopefully I done it right :-)
That's because it checks the entire document with:
if (document.selection && document.selection.createRange) {
range = document.selection.createRange();
return range.htmlText;
}
Not a specific section. If you want to check specific sections for selected text, you need to identify that you are searching for them in the search selection, something that nails your range down to a particular div:
range = $('#replytekst');
Specify a particular DOM element instead of using document object.
var oDiv = document.getElementById( 'selDiv' );
then use
if ( oDiv.selection && oDiv.selection.createRange ) {
range = oDiv.selection.createRange();
return range.htmlText;
}
You need to check if the section contains the selection. This is separate from getting the selection. There is a method for doing this in this answer: How to know if selected text is inside a specific div
I've updated your fiddle
Basically you need to check the id of the parent/ascendant of the selected text node.
selection.baseNode.parentElement.id or selection.baseNode.parentElement.parentElement.id will give you that.
Edit: I've thought of another, somewhat hack-y, way of doing it.
If
kopitekst.innerHTML.indexOf(kopitjek) !== -1
gives true, you've selected the right text.
DEMO1
DEMO2
(these work in Chrome and Firefox, but you might want to restructure the getHTMLOfSelection function a little)
If it possible for you I recommend to use rangy framework. Then your code might look like this:
// get the selection
var sel = rangy.getSelection();
var ranges = sel.getAllRanges();
if (!sel.toString() || !sel.toString().length)
return;
// create range for element, where selection is allowed
var cutRange = rangy.createRange();
cutRange.selectNode(document.getElementById("replytekst"));
// make an array of intersections of current selection ranges and the cutRange
var goodRanges = [];
$.each(ranges, function(j, tr) {
var rr = cutRange.intersection(tr);
if (rr)
goodRanges.push(rr);
});
sel.setRanges(goodRanges);
// do what you want with corrected selection
alert(sel.toString());
// release
sel.detach();
In this code if text was selected in your specific div then it will be kept, if there was selection where other elements take part too, these selection ranges will be cut off.

Select previous and next words when double clicking blank space in HTML with Javascript

I was trying to change the behaviour of an HTML page with Javascript so whenever I click a blank space in a text (not textArea) between two words, instead of selecting that blank space, it selects the words before and after the blank space. I was trying to do it this way, but I am not able to do it:
function getBothWords() {
if (window.getSelection()) {
var sel = window.getSelection();
var blank = " ";
if(sel == blank) {
...
}
}
}
I also was trying to play with:
window.getSelection().getRangeAt(0)
But still nothing. Any ideas? Thanks :)
When you only click on a blank space, the selection will be collapsed. You can try something like this:
function getBothWords() {
var sel,
range = document.getSelection().getRangeAt(0);
if (range.collapsed) {
range.setEnd(range.startContainer, range.startOffset + 1);
}
sel = range.toString();
if (sel === ' ') {
...
}
}
A live demo at jsFiddle.
You should introspect the selection object and see that there is an anchorNode element available and an anchorIndex.
See the MDN Docs on selection.
The short is you need to look at your anchorNode and anchorOffset.
sel.anchorNode.nodeValue[sel.anchorNode.anchorOffset] might be the first character of your selection. Log the sel object to the console and start poking around. Some simple math should solve the issue from there. Of course it might not be a text node.
Be sure to read the definitions on the page since there are some gotchas that can be confusing.
More to the point, something like this is a dirty hack job:
var subLen = s.focusNode.nodeValue.substr(s.focusOffset).trim().indexOf(' ')+2;
var selectedWordWitSurroundingSpaces = s.focusNode.nodeValue.substr(s.focusOffset, subLen);
There is an amazing method in class Selection called modify() which is created for that purpose. In my case, the solution would be:
function select() {
if (window.getSelection) {
var s = window.getSelection();
var blank = " ";
if(s == blank) {
selectBothWords(s);
}
}
}
function selectBothWords(s) {
s.modify("move", "backward", "word");
s.modify("extend", "forward", "word");
s.modify("extend", "forward", "word");
}
The function select() checks that the selection is a 'blank space' (loosely). Then, the function selectBothWords() uses modify to move the selection one word backwards, then, extend it two words forward.

Window text selection in a contenteditable

the text selection in a contenteditable causing me big problems...
I'm tryin to get begin and end selection point in the same way of this code part :
http://jsfiddle.net/TjXEG/1/
(Because in the contenteditable, there is differents tags and i need to reselect after a loss of focus the visible selected text (text node ?)
I'm really lost with that, someone know a tutorial or another thing to understand the selection in a web browser ?
Thanks,
Yeppao
Because I only use Chrome, I chopped off the else since it doesn't apply. So here's the Chrome solution:
Non-editable text. Editable is below:
<div id="test" contenteditable="true">Hello, some <b>bold</b> and <i>italic and <b>bold</b></i> text</div>
<div id="caretPos"></div>
<div id="caretPost"></div>
<script type="text/javascript">
function getCaretCharacterOffsetWithin(element) {
var begin = 0;
var end = 0;
if (typeof window.getSelection != "undefined") {
var range = window.getSelection().getRangeAt(0);
var preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.startContainer, range.startOffset);
begin = preCaretRange.toString().length;
preCaretRange.setEnd(range.endContainer, range.endOffset);
end = preCaretRange.toString().length;
}
return "Selection start: " + begin + "<br>Selection end: " + end;
}
function showCaretPos() {
var el = document.getElementById("test");
var caretPosEl = document.getElementById("caretPos");
caretPosEl.innerHTML = getCaretCharacterOffsetWithin(el);
}
document.body.onkeyup = showCaretPos;
document.body.onmouseup = showCaretPos;
</script>
Once you understand it, the logic is pretty simple. There is a start and end container. If it is plain text: they will be the same; however, HTML tags fragment the sentence and will make start contain a portion and end contain a different portion.
If you use setStart() where I'm using setEnd() and reverse the parameters as well, it'll be the same as reversing the index (i.e. the end will be 0 instead of the beginning).
So in order to fetch the beginning, you still use setEnd(), except with the start parameters.

Clean Microsoft Word Pasted Text using JavaScript

I am using a 'contenteditable' <div/> and enabling PASTE.
It is amazing the amount of markup code that gets pasted in from a clipboard copy from Microsoft Word. I am battling this, and have gotten about 1/2 way there using Prototypes' stripTags() function (which unfortunately does not seem to enable me to keep some tags).
However, even after that, I wind up with a mind-blowing amount of unneeded markup code.
So my question is, is there some function (using JavaScript), or approach I can use that will clean up the majority of this unneeded markup?
Here is the function I wound up writing that does the job fairly well (as far as I can tell anyway).
I am certainly open for improvement suggestions if anyone has any. Thanks.
function cleanWordPaste( in_word_text ) {
var tmp = document.createElement("DIV");
tmp.innerHTML = in_word_text;
var newString = tmp.textContent||tmp.innerText;
// this next piece converts line breaks into break tags
// and removes the seemingly endless crap code
newString = newString.replace(/\n\n/g, "<br />").replace(/.*<!--.*-->/g,"");
// this next piece removes any break tags (up to 10) at beginning
for ( i=0; i<10; i++ ) {
if ( newString.substr(0,6)=="<br />" ) {
newString = newString.replace("<br />", "");
}
}
return newString;
}
Hope this is helpful to some of you.
You can either use the full CKEditor which cleans on paste, or look at the source.
I am using this:
$(body_doc).find('body').bind('paste',function(e){
var rte = $(this);
_activeRTEData = $(rte).html();
beginLen = $.trim($(rte).html()).length;
setTimeout(function(){
var text = $(rte).html();
var newLen = $.trim(text).length;
//identify the first char that changed to determine caret location
caret = 0;
for(i=0;i < newLen; i++){
if(_activeRTEData[i] != text[i]){
caret = i-1;
break;
}
}
var origText = text.slice(0,caret);
var newText = text.slice(caret, newLen - beginLen + caret + 4);
var tailText = text.slice(newLen - beginLen + caret + 4, newLen);
var newText = newText.replace(/(.*(?:endif-->))|([ ]?<[^>]*>[ ]?)|( )|([^}]*})/g,'');
newText = newText.replace(/[ยท]/g,'');
$(rte).html(origText + newText + tailText);
$(rte).contents().last().focus();
},100);
});
body_doc is the editable iframe, if you are using an editable div you could drop out the .find('body') part. Basically it detects a paste event, checks the location cleans the new text and then places the cleaned text back where it was pasted. (Sounds confusing... but it's not really as bad as it sounds.
The setTimeout is needed because you can't grab the text until it is actually pasted into the element, paste events fire as soon as the paste begins.
How about having a "paste as plain text" button which displays a <textarea>, allowing the user to paste the text in there? that way, all tags will be stripped for you. That's what I do with my CMS; I gave up trying to clean up Word's mess.
You can do it with regex
Remove head tag
Remove script tags
Remove styles tag
let clipboardData = event.clipboardData || window.clipboardData;
let pastedText = clipboardData.getData('text/html');
pastedText = pastedText.replace(/\<head[^>]*\>([^]*)\<\/head/g, '');
pastedText = pastedText.replace(/\<script[^>]*\>([^]*)\<\/script/g, '');
pastedText = pastedText.replace(/\<style[^>]*\>([^]*)\<\/style/g, '');
// pastedText = pastedText.replace(/<(?!(\/\s*)?(b|i|u)[>,\s])([^>])*>/g, '');
here the sample : https://stackblitz.com/edit/angular-u9vprc
I did something like that long ago, where i totally cleaned up the stuff in a rich text editor and converted font tags to styles, brs to p's, etc, to keep it consistant between browsers and prevent certain ugly things from getting in via paste. I took my recursive function and ripped out most of it except for the core logic, this might be a good starting point ("result" is an object that accumulates the result, which probably takes a second pass to convert to a string), if that is what you need:
var cleanDom = function(result, n) {
var nn = n.nodeName;
if(nn=="#text") {
var text = n.nodeValue;
}
else {
if(nn=="A" && n.href)
...;
else if(nn=="IMG" & n.src) {
....
}
else if(nn=="DIV") {
if(n.className=="indent")
...
}
else if(nn=="FONT") {
}
else if(nn=="BR") {
}
if(!UNSUPPORTED_ELEMENTS[nn]) {
if(n.childNodes.length > 0)
for(var i=0; i<n.childNodes.length; i++)
cleanDom(result, n.childNodes[i]);
}
}
}
This works great to remove any comments from HTML text, including those from Word:
function CleanWordPastedHTML(sTextHTML) {
var sStartComment = "<!--", sEndComment = "-->";
while (true) {
var iStart = sTextHTML.indexOf(sStartComment);
if (iStart == -1) break;
var iEnd = sTextHTML.indexOf(sEndComment, iStart);
if (iEnd == -1) break;
sTextHTML = sTextHTML.substring(0, iStart) + sTextHTML.substring(iEnd + sEndComment.length);
}
return sTextHTML;
}
Had a similar issue with line-breaks being counted as characters and I had to remove them.
$(document).ready(function(){
$(".section-overview textarea").bind({
paste : function(){
setTimeout(function(){
//textarea
var text = $(".section-overview textarea").val();
// look for any "\n" occurences and replace them
var newString = text.replace(/\n/g, '');
// print new string
$(".section-overview textarea").val(newString);
},100);
}
});
});
Could you paste to a hidden textarea, copy from same textarea, and paste to your target?
Hate to say it, but I eventually gave up making TinyMCE handle Word crap the way I want. Now I just have an email sent to me every time a user's input contains certain HTML (look for <span lang="en-US"> for example) and I correct it manually.

Categories