Text suggestions on a contenteditable=true element - javascript

I am using bootstrap-wysihtml5 (that is based on wysihtml5) amazing editor and it works fine and everyone is happy. But now I need to offer text suggestions when the user is typing something (like this or this). Those libraries expect a textarea and wysihtml5 uses an iframe with a <body contenteditable="true">.
All I need is some words to auto-complete (or open a popup with the suggestions) in some plain text inside the element.
Before I go deep in this, anyone has a suggestion of an library that could work inside an contenteditable="true" element?
Edit 1:
I created a basic lib that do what I need but I think I used all my poor js skills on this one... it works on a div with contenteditable=true but I'm having a hard time making it work on the wysihtml5 editor. Can some javascript/wysihtml5/rangy ninja give me some help?
Here is my code:
http://jsfiddle.net/5UQfH/
Edit 2:
First working version: http://jsfiddle.net/X9jBM/1/
Edit 3:
slightly better (but not prettier) version (works with multiple editors on same page):
http://jsfiddle.net/X9jBM/18/
Still do not work fine when the suggestion are multiple words (it stops suggesting when there is a space)
Would still like to hear some feedback on this.

I ended up creating a very simple and basic lib to do what I needed. It is not perfect nor tested in anything except the latest chrome version and I probably could eliminate the jQuery dependency quite easilly but since I already have it on my project (the old excuse), for now I will leave it as it is.
ENTER select a word and TAB cycle through the words.
http://jsfiddle.net/X9jBM/19/
The code:
if (typeof String.prototype.startsWith != 'function') {
String.prototype.startsWith = function (str) {
return this.indexOf(str) == 0;
};
}
var SuggestMe = function () {
"use strict";
var self = this;
return {
init: init
};
function init(iframe, words) {
self.list = [];
self.currentIndex = 0;
self.currentWord = "";
self.$iframe = iframe;
self.$editor = $(iframe).contents().find(".wysihtml5-editor");
self.$editor.on("keydown", function (event) {
if (event.keyCode === 13) {
var sel = rangy.getIframeSelection(self.$iframe);
if (!sel.isCollapsed) {
var range = sel.getRangeAt(0);
range.collapse(false);
var textNode = document.createTextNode(" ");
range.insertNode(textNode);
sel.collapseToEnd();
event.preventDefault();
return false;
}
}
if (event.keyCode === 9) {
var sel = rangy.getIframeSelection(self.$iframe);
if (!sel.isCollapsed) {
self.currentIndex++;
var word = self.list[self.currentIndex % self.list.length];
var sel = rangy.getIframeSelection(self.$iframe);
var range = sel.getRangeAt(0);
range.deleteContents();
var term = word.slice(self.currentWord.length, word.length);
var textNode = document.createTextNode(term);
range.insertNode(textNode);
range.selectNodeContents(textNode);
rangy.getSelection().setSingleRange(range);
event.preventDefault();
return false;
}
}
});
self.$editor.on("keyup", function (event) {
var c = String.fromCharCode(event.keyCode);
var isWordcharacter = c.match(/\w/);
if (isWordcharacter && !event.ctrlKey) {
var $editor = this;
self.currentWord = getLastWord($editor);
if (self.currentWord) {
var sel = rangy.getIframeSelection(self.$iframe);
if (sel.rangeCount > 0) {
self.list = [];
self.currentIndex = 0;
$.each(words, function (a) {
var word = words[a].toLowerCase();
if (word.startsWith(self.currentWord.toLowerCase())) {
self.list.push(word);
}
});
}
if (self.list.length) {
var word = self.list[self.currentIndex];
var sel = rangy.getIframeSelection(self.$iframe);
var range = sel.getRangeAt(0);
var term = word.slice(self.currentWord.length, word.length);
var textNode = document.createTextNode(term);
range.insertNode(textNode);
range.selectNodeContents(textNode);
rangy.getSelection().setSingleRange(range);
}
}
}
});
}
function getLastWord(elm) {
var val = elm.innerText.trim();
val = val.replace(/(\r\n|\n|\r)/gm, " ");
var idx = val.lastIndexOf(' ');
var lastWord = val.substring(idx + 1).trim();
console.log(val);
return lastWord;
}
};
The usage:
var suggestions = ["hello", "world", "blue dog", "blue cat"];
$('#txt').wysihtml5();
var editor = $('#txt').data("wysihtml5").editor;
editor.on('load', function () {
var sm = new SuggestMe();
sm.init(this.currentView.iframe, suggestions);
});
There is some refactor to be done but this is the basic idea.

Related

Highlight text fragments selected by user without nesting tags

I have a <div>some test phrase<div> and I need to allow user to select different fragments of text and highlight them in different colors. Also I need to allow user to delete the the highlighting (but keep the text).
I use Angular, but the solution might be in pure JS.
I've got the partial solution received in response to my previous question:
function mark() {
var rng = document.getSelection().getRangeAt(0);
var cnt = rng.extractContents();
var node = document.createElement('MARK');
node.style.backgroundColor = "orange";
node.appendChild(cnt);
rng.insertNode(node);
}
document.addEventListener('keyup', mark);
document.addEventListener('mouseup', mark);
function unmark(e) {
var tgt = e.target;
if (tgt.tagName === 'MARK') {
if (e.ctrlKey) {
var txt = tgt.textContent;
tgt.parentNode.replaceChild(document.createTextNode(txt), tgt);
}
}
}
document.addEventListener('click', unmark);
::selection {
background: orange;
}
<p>some test phrase</p>
However, if user selects some test and test phrase after that, the selections will intersect and the mark tags will be nesting, while I need them to be like this: <mark>some</mark><mark>test phrase</mark>.
So, the general rule is: the last selection always wins, i.e. its color is always on top. How could I achieve this for any number of selections done?
Also deletion seems not to be working from time to time and I don't know why.
UPDATE:
Kind of implemented this, but I won't be surprised if there is a better way to do this.
Here is the code
I think you can start with this. You should thouroughly test it if it satisfy your case. Perhaps you should also refactor it to better fit you needs.
function mark() {
let selection = document.getSelection();
if(selection.type !== 'Range') { return;}
let pos = window.placeOfSelections;
let ranges = [];
let start = 0;
Array.prototype.forEach.call(pos.childNodes, function(chD)
{
ranges.push([start, start + chD.textContent.length, chD.nodeName === 'MARK']);
start += chD.textContent.length;
});
let text = pos.textContent;
let range = selection.getRangeAt(0);
let firstNode = range.startContainer;
let lastNode = range.endContainer;
selection.removeAllRanges();
let firstNodeIndex = Array.prototype.findIndex.call(pos.childNodes, node => node === firstNode || node.firstChild === firstNode);
let lastNodeIndex = Array.prototype.findIndex.call(pos.childNodes, node => node === lastNode || node.firstChild === lastNode);
let newSelectionStart = ranges[firstNodeIndex][0] + range.startOffset;
let newSelectionEnd = ranges[lastNodeIndex][0] + range.endOffset;
pos.innerHTML = text;
range.setStart(pos.childNodes[0], newSelectionStart);
range.setEnd(pos.childNodes[0], newSelectionEnd);
let node = document.createElement('MARK');
let cnt = range.extractContents();
node.appendChild(cnt);
range.insertNode(node);
let marks = ranges.filter(r => r[2]);
while(marks.length != 0)
{
let startEnd = marks.shift();
if(startEnd[0]>= newSelectionStart && startEnd[1] <= newSelectionEnd)
{
continue;
}
if(startEnd[0]>= newSelectionStart && startEnd[0] <= newSelectionEnd)
{
startEnd[0] = newSelectionEnd;
}
else
if(startEnd[1]>= newSelectionStart && startEnd[1] <= newSelectionEnd)
{
startEnd[1] = newSelectionStart;
}
else
if(startEnd[0] <=newSelectionStart && startEnd[1] >= newSelectionEnd)
{
marks.push([newSelectionEnd, startEnd[1]]);
startEnd[1] = newSelectionStart;
}
let tnStart = 0, tnEnd = 0;
let textNode = Array.prototype.find.call(pos.childNodes, function(tn)
{
tnEnd += tn.textContent.length;
if(tnStart <= startEnd[0] && startEnd[1] <= tnEnd )
{
return true;
}
tnStart += tn.textContent.length ;
});
range.setStart(textNode, startEnd[0] - tnStart);
range.setEnd(textNode, startEnd[1] - tnStart);
node = document.createElement('MARK');
node.appendChild(range.extractContents());
range.insertNode(node);
}
}
window.placeOfSelections.addEventListener('keyup', mark);
window.placeOfSelections.addEventListener('mouseup', mark);
function unmark(e) {
var tgt = e.target;
if ((tgt.tagName === 'MARK' || (e.parentNode && e.parentNode.tagName === "MARK")) && e.ctrlKey) {
let txt = tgt.textContent;
tgt.parentNode.replaceChild(document.createTextNode(txt), tgt);
}
}
window.placeOfSelections.addEventListener('mousedown', unmark);
mark {background-color: #BCE937 ;}
<p id="placeOfSelections">some test phrase</p>

Paste from clipboard in overwrite mode - input cursor moves to the end of text node

Our target browser is IE8 as the application is a legacy one and has got some com dependency.We are showing content inside a content-editable div. One of the requirements is to be able to replace texts inside the div when the browser is in "overwrite" mode. Paste is working fine but the input cursor is always moving to the end after the paste. We are using rangy-core, version: 1.3.1 for range/selection related logic. Unable to figure out what is going wrong here. Need help.
The following code is called when the document is loaded:
$("#info").on("paste", function (e) {
var clipboardData = window.clipboardData.getData("Text");
clipboardData = clipboardData.replace(/(^ *)|(\r\n|\n|\r)/gm, "");
if (isOverwriteEnabled()) {
e.preventDefault ? e.preventDefault() : e.returnValue = false;
pasteCopiedData(clipboardData);
}
});
Related code snippets for reference:
function isOverwriteEnabled() {
try {
// try the MSIE way
return document.queryCommandValue("OverWrite");
} catch (ex) {
// not MSIE => not supported
return false;
}
}
function pasteCopiedData(clipboardData) {
var json = getCurrentNodeWithOffset();
handleTextOverwrite(json, clipboardData);
}
function getCurrentNodeWithOffset() {
var json = {};
var selectedObj = rangy.getSelection();
var range = selectedObj.getRangeAt(0);
json.node = selectedObj.anchorNode.nodeType === 3 ? selectedObj.anchorNode : findImmediateTextNode(selectedObj.anchorNode, range.startOffset);
json.offset = selectedObj.anchorNode.nodeType === 3 ? range.startOffset : json.node.nodeValue.length - 1;
return json;
}
function handleTextOverwrite(json, textToReplace) {
var lenToCopy = textToReplace.length;
var offsetPos = json.offset;
var jsonNode = json.node;
try {
while (lenToCopy > 0) {
var toCopy = jsonNode.nodeValue.length - offsetPos;
var startPos = textToReplace.length - lenToCopy;
if (lenToCopy <= toCopy) {
json.node.nodeValue = jsonNode.nodeValue.substr(0, offsetPos) + textToReplace.substr(startPos) + jsonNode.nodeValue.substr(offsetPos + lenToCopy);
lenToCopy = 0;
}
else {
var copiedPos = startPos + toCopy;
jsonNode.nodeValue = jsonNode.nodeValue.substr(0, offsetPos) + textToReplace.substr(startPos, toCopy);
lenToCopy -= copiedPos;
var nextJsonNode = findNextTextNode(jsonNode);
if (!nextJsonNode) {
//do the remaining paste in jsonNode
jsonNode.nodeValue = jsonNode.nodeValue + textToReplace.substr(copiedPos);
lenToCopy = 0;
}
else {
jsonNode = nextJsonNode;
}
}
offsetPos = 0;
}
setCaret(json.node, json.offset);
}
catch (ex) {
}
}
function setCaret(node, pos) {
var el = document.getElementById("info");
var rangyRange = rangy.createRange(el);
var sel = rangy.getSelection();
rangyRange.setStart(node, pos + 1);
rangyRange.setEnd(node, pos + 1);
rangyRange.collapse(true);
sel.removeAllRanges();
sel.addRange(rangyRange);
}
Please let me know if more information is required.

How to I make this not pull up so many tabs at once?

So, I just want the website to pull up what is in the select in one line, which works, but also pops up 2 extra tabs :(. Any suggestions? I have been working really hard on this, I would hate ti see it go :(
var options = [];
var urls = [];
function addOption(name, url) {
var select = document.getElementById('optionlist');
var option = document.createElement("OPTION");
var optionText = document.createTextNode(name);
option.appendChild(optionText);
select.appendChild(option);
if (options.length > 0) {
options.push(name);
urls.push(url)
} else {
options = [name];
urls = [url];
}
}
function createOptions() {
addOption("List of Gemstones", "https://en.wikipedia.org/wiki/List_of_minerals");
addOption("Another Website", "https://www.google.com");
}
document.getElementById('submit').onclick = function() {
var select = document.getElementById('optionlist').value;
for (a = 0; a <= options.length; a++) {
var selected = options[a];
if (selected == selected) {
var toredirect = urls[a];
var win = window.open(toredirect, '_blank');
win.focus();
} else if (selected === selected) {
var toredirect = urls[a];
var win = window.open(toredirect, '_blank');
win.focus();
}
}
}
if (selected == selected) is always true, since you're just comparing a value with itself (an exception is if it contains NaN, but that's not relevant here). So every iteration of the loop will execute the window.open() code.
I think you meant to write if (selected == select). But there's no need to loop to find the selected option, its index is in document.getElementById('optionlist').selectedIndex. So you can simply do:
document.getElementById('submit').onclick = function() {
var index = document.getElementById('optionlist').selectedIndex;
var toredirect = urls[index];
var win = window.open(toredirect, '_blank');
win.focus();
}
Also, in your addOption() function, you don't need to check the length of options. There's nothing wrong with calling push() on an empty array.

JavaScript window.find() does not select search term

When I try to pass text which spreads throughout a few block elements the window.find method dosent work:
HTML:
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8 />
</head>
<body>
<p>search me</p><b> I could be the answer</b>
</body>
</html>
JavaScript:
window.find("meI could be");
Or:
str = "me";
str+= "\n";
str+="I could be t";
window.find(str);
This happens when the <p> element is present between the search term.
The end result should be a GUI selection on the text in the page, I do not want to search if it exists.
I would like to know how to achieve this in Firefox(at least) and internet explorer.
Note: I can't change the dom (e.g. change to display inline).
Edit:
Here is something I tried after #Alexey Lebedev's comment, but it also finds the script (tag [...] text):
Can I make it more simple? (better)?
function nativeTreeWalker(startNode) {
var walker = document.createTreeWalker(
startNode,
NodeFilter.SHOW_TEXT,
null,
false
);
var node;
var textNodesV = [];
var textNodes = [];
node = walker.nextNode();
while(node ) {
if(node.nodeValue.trim()){
textNodes.push(node);
textNodesV.push(node.nodeValue);
//console.log(node.nodeValue);
}
node = walker.nextNode();
}
return [textNodes,textNodesV];
}
var result = nativeTreeWalker(document.body);
var textNodes = result[0];
var textNodesV = result[1];
var param = " Praragraph.Test 3 Praragr";
paramArr = param.split(/(?=[\S])(?!\s)(?=[\W])(?=[\S])/g);
//Fix split PARAM
for(i=0;i<paramArr.length-1;i++){
paramArr[i]= paramArr[i]+paramArr[i+1].charAt(0);
paramArr[i+1] = paramArr[i+1].substring(1,paramArr[i+1].length);
}
//Fix last element PARAM
if(paramArr[paramArr.length-1] === ""){
paramArr.splice(paramArr.length-1,1);
}
//console.log(paramArr);
var startNode,startOffset,sFound=false,
endNode,endOffset;
for(i=0;i<paramArr.length;i++){
for(j=0;j<textNodesV.length;j++){
//Fully Equal
var pos = textNodesV[j].indexOf(paramArr[i]);
if(pos != -1){
if(!sFound){
startNode = textNodes[j];
startOffset = pos;
sFound=true;
}else{
endNode = textNodes[j];
endOffset = pos+paramArr[i].length;
break;
}
}
}
}
console.log(startNode);
console.log(startOffset);
console.log(endNode);
console.log(endOffset);
var range = document.createRange();
range.setStart(startNode,startOffset);
range.setEnd(endNode,endOffset);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
Note: No jQuery (only Raw JS).
JS Bin demo: http://jsbin.com/aqiciv/1/
If you want this to work in IE < 9 you'll need to add MS-specific selection code (nightmare), or use Rangy.js (pretty heavy).
function visibleTextNodes() {
var walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ALL,
function(node) {
if (node.nodeType == 3) {
return NodeFilter.FILTER_ACCEPT;
} else if (node.offsetWidth && node.offsetHeight && node.style.visibility != 'hidden') {
return NodeFilter.FILTER_SKIP;
} else {
return NodeFilter.FILTER_REJECT;
}
},
false
);
for (var nodes = []; walker.nextNode();) {
nodes.push(walker.currentNode);
}
return nodes;
}
// Find the first match, select and scroll to it.
// Case- and whitespace- insensitive.
// For better scrolling to selection see https://gist.github.com/3744577
function highlight(needle) {
needle = needle.replace(/\s/g, '').toLowerCase();
var textNodes = visibleTextNodes();
for (var i = 0, texts = []; i < textNodes.length; i++) {
texts.push(textNodes[i].nodeValue.replace(/\s/g, '').toLowerCase());
}
var matchStart = texts.join('').indexOf(needle);
if (matchStart < 0) {
return false;
}
var nodeAndOffsetAtPosition = function(position) {
for (var i = 0, consumed = 0; consumed + texts[i].length < position; i++) {
consumed += texts[i].length;
}
var whitespacePrefix = textNodes[i].nodeValue.match(/^\s*/)[0];
return [textNodes[i], position - consumed + whitespacePrefix.length];
};
var range = document.createRange();
range.setStart.apply(range, nodeAndOffsetAtPosition(matchStart));
range.setEnd.apply( range, nodeAndOffsetAtPosition(matchStart + needle.length));
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
range.startContainer.parentNode.scrollIntoView();
}
highlight('hello world');

how can I get the Character offset related to body with Range Object and window.getSelection()?

I'm writing a client-side application with Javascript, I'm using the following functions :
function creation() {
var userSelection;
if (window.getSelection) {
userSelection = window.getSelection();
}
else if (document.selection) { // should come last; Opera!
userSelection = document.selection.createRange();
}
var rangeObject = getRangeObject(userSelection);
var startOffset = rangeObject.startOffset;
var endOffset = rangeObject.endOffset;
var startCon = rangeObject.startContainer;
var endCon = rangeObject.endContainer;
var myRange = document.createRange();
myRange.setStart(startCon,rangeObject.startOffset);
myRange.setEnd(endCon, rangeObject.endOffset);
$('#result').text(myRange.toString());
}
function getRangeObject(selectionObject) {
if (selectionObject.getRangeAt) {
var ret = selectionObject.getRangeAt(0);
return ret;
}
else { // Safari!
var range = document.createRange();
range.setStart(selectionObject.anchorNode, selectionObject.anchorOffset);
range.setEnd(selectionObject.focusNode, selectionObject.focusOffset);
return range;
}
}
I need a way to know the character offset related to body element. I found a function with counts the character in an element :
function getCharacterOffsetWithin(range, node) {
var treeWalker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
function (node) {
var nodeRange = document.createRange();
nodeRange.selectNode(node);
return nodeRange.compareBoundaryPoints(Range.END_TO_END, range) < 1 ?
NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
false
);
var charCount = 0;
while (treeWalker.nextNode()) {
charCount += treeWalker.currentNode.length;
}
if (range.startContainer.nodeType == 3) {
charCount += range.startOffset;
}
return charCount;
}
That looks like a function from one of my answers. I overcomplicated it a little; see this answer for a simpler function that works in IE < 9 as well: https://stackoverflow.com/a/4812022/96100. You can just pass in document.body as the node parameter. Also, please read the part in the linked answer about the shortcomings of this approach.
Here's a live demo: http://jsfiddle.net/PzQjA/

Categories

Resources