Skip to content

Instantly share code, notes, and snippets.

@slorber
Created March 10, 2014 09:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save slorber/9461922 to your computer and use it in GitHub Desktop.
Save slorber/9461922 to your computer and use it in GitHub Desktop.
onEditorKeyUp: function(e) {
var intercepted = this.interceptKeyUpDownEventsForSuggestionView(e,false);
if ( !intercepted ) {
this.handleModifiedSuggestionsUnwrapping();
this.startAutosaveTimerIfWordKey(e);
// /!\ The suggestion dropdown must absolutely be bound to keyup (and not keydown)
// because the currently typed char is not yet inserted in the editor!
this.maybeShowSuggestionsDropdownView();
}
},
onEditorKeyDown: function(e) {
var intercepted = this.interceptKeyUpDownEventsForSuggestionView(e,true);
if ( !intercepted ) {
this.handleSpaceAndEnterKeyDownAtEndOfSuggestionNode(e);
}
},
onEditorClick: function(e) {
this.maybeShowSuggestionsDropdownView();
},
onEditorBlur: function(e) {
var self = this;
// TODO this sucks :s remove the dropdown directly and a click event on it won't ever be fired
// maybe use a trick like that? http://stackoverflow.com/a/4432048/82609
setTimeout(function() {
self.removeSuggestionsDropdownView();
},200);
},
getAllDropdownSuggestions: function() {
var dropdownSuggestions = this.editorSuggestionsCollection.models.map(function(suggestionModel) {
return suggestionModel.getAsSuggestionDropdownModel();
})
return dropdownSuggestions;
},
// For now we just suggest the words that starts with the current word
getSuggestionsForText: function(text) {
if ( !text || text.length == 0 ) throw new Error("No text provided! -> "+text);
var allDropdownSuggestions = this.getAllDropdownSuggestions();
var textLower = text.toLowerCase();
var suggestions = _.filter(allDropdownSuggestions, function(suggestion) {
var suggestionTextLower = suggestion.text.toLowerCase();
return suggestionTextLower.indexOf(textLower) == 0;
});
// if the user already typed the unique suggestion text, then there's nothing interesting to suggest...
if ( suggestions.length == 1 && suggestions[0].text.toLowerCase() == text.toLowerCase() ) {
return []
} else {
return suggestions;
}
},
// permits to use keyboard shortcuts to drive the editor's suggestion dropdown if present
// returns true if the event has been intercepted
// we cancel keyup events and trigger the view on keydown only
interceptKeyUpDownEventsForSuggestionView: function(e,keydown) {
if ( this.suggestionView ) {
var isTabKeyEvent = (e.keyCode === 9);
var isEnterKeyEvent = (e.keyCode === 13);
var isUpKeyEvent = (e.keyCode === 38);
var isDownKeyEvent = (e.keyCode === 40);
if ( isEnterKeyEvent || isTabKeyEvent ) {
if (keydown) this.suggestionView.selectPreselectedSuggestion();
cancelDefaults(e);
return true;
}
if ( isUpKeyEvent ) {
if (keydown) this.suggestionView.preselectPrevious();
cancelDefaults(e);
return true;
}
if ( isDownKeyEvent ) {
if (keydown) this.suggestionView.preselectNext();
cancelDefaults(e);
return true;
}
}
return false;
},
removeSuggestionsDropdownView: function() {
if ( this.suggestionView ) {
this.suggestionView.remove();
delete this.suggestionView;
}
},
getSuggestionSpanNodeIfCaretAtEndOfSpan: function() {
var textUnderCaret = this.getTextUnderCaret();
if ( textUnderCaret ) {
var $suggestionSpan = $(textUnderCaret.textNode).parent("span.editor-suggestion");
if ( $suggestionSpan.length == 1 ) {
var text = textUnderCaret.textNode.nodeValue;
var caretIndex = textUnderCaret.caretIndex;
var isCaretAtEndOfSuggestionSpanNode = (text.length == caretIndex);
if ( isCaretAtEndOfSuggestionSpanNode ) {
return $suggestionSpan[0];
}
}
}
},
// If we press enter/space at the end of a suggestion node, we don't want that
// to be added inside the suggestion node or it will modify the suggestion.
// instead we add the appropriate node just after
handleSpaceAndEnterKeyDownAtEndOfSuggestionNode: function(e) {
var isSpaceKey = (e.keyCode === 32);
var isEnterKeyEvent = (e.keyCode === 13);
if ( isSpaceKey || isEnterKeyEvent ) {
var spanNode = this.getSuggestionSpanNodeIfCaretAtEndOfSpan();
if ( spanNode ) {
if ( isSpaceKey ) {
var spaceNode = this.createSpaceTextNode();
$(spanNode).after(spaceNode);
this.setCaretAfterNode(spaceNode);
cancelDefaults(e);
}
else if ( isEnterKeyEvent ) {
var lineBreakNode = this.createLineBreakNode();
$(spanNode).after(lineBreakNode);
this.setCaretAfterNode(lineBreakNode);
cancelDefaults(e);
}
else throw new Error("Unknown case");
}
}
},
setCaretAfterNode: function(node) {
var range = this.getSelectionRange();
range.setStartAfter(node);
range.setEndAfter(node);
this.setSingleRangeSelection(range);
return range;
},
setSingleRangeSelection: function(range) {
var iframe = self.$("iframe.wysihtml5-sandbox").get(0);
var sel = rangy.getIframeSelection(iframe);
sel.setSingleRange(range);
},
getSelectionRange: function() {
var iframe = self.$("iframe.wysihtml5-sandbox").get(0);
var sel = rangy.getIframeSelection(iframe);
return sel.getRangeAt(0);
},
// When some suggestions are inserted, we do not allow the user to modify them.
// So we regularly check that the suggestion spans are never modified.
// If they are, we "unspan" them as regular text.
handleModifiedSuggestionsUnwrapping: function() {
var self = this;
var $editorBody = this.$("iframe.wysihtml5-sandbox").contents().find("body");
$editorBody[0].normalize(); // merge consecutive text nodes without loosing caret position
$editorBody.find(".editor-suggestion").each(function() {
var spanNode = this;
var $contents = $(spanNode).contents();
if ( $contents.length != 1 || $contents[0].nodeType != Node.TEXT_NODE ) {
console.debug("Normalizing span node!",$contents);
var textString = $(spanNode).text();
$(spanNode).text(textString);
}
var singleTextNode = $(spanNode).contents()[0];
var text = singleTextNode.nodeValue;
var originalSuggestionText = $(spanNode).attr("data-original-suggestion-text");
if ( !originalSuggestionText ) throw new Error("Unexpected: no original suggestion text set:",spanNode);
if ( originalSuggestionText && originalSuggestionText != text ) {
console.debug("The suggestion span node has been modified! unwrapping it!",originalSuggestionText,spanNode);
var textUnderCaret = self.getTextUnderCaret();
var caretIndexInTextNode;
if ( textUnderCaret && textUnderCaret.textNode === singleTextNode) {
caretIndexInTextNode = textUnderCaret.caretIndex;
//console.debug("CATCHED: caretIndexInTextNode",caretIndexInTextNode);
}
$(singleTextNode).unwrap();
// If we had a caret position, we put it back just after the unwrapping operation
// because the caret position is lost if we don't do this
if ( caretIndexInTextNode ) {
//console.debug("caretIndexInTextNode",caretIndexInTextNode);
var iframe = self.$("iframe.wysihtml5-sandbox").get(0);
var sel = rangy.getIframeSelection(iframe);
var range = sel.getRangeAt(0);
range.setStart(singleTextNode,caretIndexInTextNode);
range.setEnd(singleTextNode,caretIndexInTextNode);
sel.setSingleRange(range);
}
}
else if ( !originalSuggestionText ) {
throw new Error("Unexpected: no original suggestion text set:",spanNode);
//console.error("??? suggestion span without mandatory attribute: data-original-suggestion-text");
//$(spanNode).remove();
}
});
},
// Only show the suggestion dropdown if the caret is under a word
// and there are existing suggestions for this word
// otherwise do not display suggestions and remove the eventually previously displayed suggestion box
maybeShowSuggestionsDropdownView: function() {
var currentWordRangeUnderCaret = this.getWordRangeUnderCaret();
if ( !currentWordRangeUnderCaret ) {
this.removeSuggestionsDropdownView();
return;
}
var currentWordRange = currentWordRangeUnderCaret.wordRange;
var wordText = currentWordRange.toString();
if ( !wordText || wordText.length == 0 ) {
this.removeSuggestionsDropdownView();
return;
}
var suggestions = this.getSuggestionsForText(wordText);
if ( suggestions.length == 0 ) {
this.removeSuggestionsDropdownView();
return;
}
this.showSuggestionsDropdownView(currentWordRange,suggestions);
},
showSuggestionsDropdownView: function(wordRange,suggestions) {
var self = this;
var position = this.computeWordRangeOffsetInParentDocument(wordRange);
this.removeSuggestionsDropdownView();
// We only show the 3 first suggestions
this.suggestionView = new StampleEditorSuggestionView({
maxSuggestionNumber: 5, // TODO is 5 a good limitation?
text: wordRange.toString(),
suggestions: suggestions,
position: position,
onSuggestionSelected: function(suggestion) {
console.debug("Suggestion selected!!!",suggestion);
self.replaceCurrentWordBySuggestion(suggestion);
self.removeSuggestionsDropdownView();
// TODO maybe this should be done a bit later, on user inactivity, with a visual effect?
self.addHashtagSuggestionSelectedToKeywords(suggestion);
}
});
this.suggestionView.render();
$("body").append(this.suggestionView.$el);
},
addHashtagSuggestionSelectedToKeywords: function(suggestion) {
if ( suggestion.type == "hashtag" ) {
var hashtagKeyword = suggestion.text;
this.addKeywordIfNotPresent(hashtagKeyword)
}
},
addKeywordIfNotPresent: function(keyword) {
var actualKeywordString = $("#keywords").val();
var actualKeywordArray = actualKeywordString.split(" ");
if ( !_.contains(actualKeywordArray,keyword) ) {
var newKeywordString = actualKeywordString.trim() + " " + keyword;
$("#keywords").val(newKeywordString);
}
},
// Replace the word currently under the caret, by the suggestion text surrounded by a span element
/*
replaceCurrentWordBySuggestion: function(suggestion) {
var currentWordRange = this.getWordRangeUnderCaret();
currentWordRange.deleteContents();
var spanClassName = "editor-valid-suggestion-" + suggestion.type;
var suggestionSpanHtml = '<span class="'+spanClassName+'">'+suggestion.text+'</span> ';
var editor = this.getEditor();
editor.focus();
editor.composer.commands.exec("insertHTML", suggestionSpanHtml); },
*/
createSuggestionSpanNode: function(suggestion) {
var iframe = this.$("iframe.wysihtml5-sandbox").get(0);
var iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
var spanClassValue = "editor-suggestion" + " " + "suggestion-"+suggestion.type;
var editor = this.getEditor();
var suggestionSpanHtml = '<span class="'+spanClassValue+'">'+suggestion.text+'</span>';
var node = $.parseHTML(suggestionSpanHtml,iframeDoc)[0];
$(node).attr("data-original-suggestion-text",suggestion.text);
$(node).attr("data-suggestion-type",suggestion.type);
return node;
},
createTextNode: function(text) {
var iframe = this.$("iframe.wysihtml5-sandbox").get(0);
var iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
return iframeDoc.createTextNode(text);
},
createSpaceTextNode: function() {
var nbspWordSeparator = "\u00a0";
return this.createTextNode(nbspWordSeparator);
},
createLineBreakNode: function() {
var iframe = this.$("iframe.wysihtml5-sandbox").get(0);
var iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
return $.parseHTML("<br>",iframeDoc)[0];
},
replaceCurrentWordBySuggestion: function(suggestion) {
var iframe = this.$("iframe.wysihtml5-sandbox").get(0);
// Replace current word range text
var currentWordRangeUnderCaret = this.getWordRangeUnderCaret();
var currentWordRange = currentWordRangeUnderCaret.wordRange;
currentWordRange.deleteContents();
var spanNode = this.createSuggestionSpanNode(suggestion);
currentWordRange.insertNode(spanNode);
// set the caret at the end of the newly inserted span node just after the space
// so the user can continue writing
this.$("iframe.wysihtml5-sandbox").contents().find("body").focus();
currentWordRange.setStartAfter(spanNode);
currentWordRange.setEndAfter(spanNode);
this.setSingleRangeSelection(currentWordRange);
},
// getBoundingClientRect retrieves the offset of a range in the current document (ie the wysihtml5 iframe)
// so we need to add the iframe offset to have the appropriate final offset
computeWordRangeOffsetInParentDocument: function(wordRange) {
var rangePositionInIframe = wordRange.nativeRange.getBoundingClientRect();
var iframeOffset = this.$(".wysihtml5-sandbox").offset();
return {
top: iframeOffset.top + rangePositionInIframe.top,
left: iframeOffset.left + rangePositionInIframe.left
};
},
// /!\ Notice that RANGY 1.2.2 is packaged with wysihtml5: do not try to load another version of rangy or it could produce conflicts
//
// return the word range that is under the current caret,
// or undefined (no caret but user selection or something else ?)
getWordRangeUnderCaret: function() {
var textUnderCaret = this.getTextUnderCaret();
if ( textUnderCaret ) {
var textNode = textUnderCaret.textNode;;
var text = textNode.nodeValue;
var caretIndex = textUnderCaret.caretIndex;
var wordBoundaries = this.computeWordBoundariesAtIndex(text,caretIndex);
var caretIndexInWordRange = caretIndex - wordBoundaries.start;
// Create word range
var wordRange = textUnderCaret.range.cloneRange();
wordRange.setStart(textNode,wordBoundaries.start);
wordRange.setEnd(textNode,wordBoundaries.end);
//console.debug("getWordRangeUnderCaret:","["+wordRange.toString()+"]",wordRange);
return {
wordRange: wordRange,
caretIndex: caretIndexInWordRange,
textUnderCaret: textUnderCaret
};
}
},
// return the text range that is under the current caret,
// or undefined (no caret but user selection or something else ?)
getTextUnderCaret: function() {
var iframe = this.$("iframe.wysihtml5-sandbox").get(0);
var sel = rangy.getIframeSelection(iframe);
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 caretIndex = selectedRange.startOffset;
var textNode = selectedRange.startContainer;
return {
range: selectedRange,
textNode: textNode,
caretIndex: caretIndex
};
}
}
},
computeWordBoundariesAtIndex: function(text,index) {
var startWordIndex = this.computeWordStartIndex(text,index);
var endWordIndex = this.computeWordEndIndex(text,index);
if ( startWordIndex > index ) throw new Error("Illegal state! " + startWordIndex + " > "+index);
if ( endWordIndex < index ) throw new Error("Illegal state! " + endWordIndex + " < "+index);
return {
start: startWordIndex,
end: endWordIndex
}
},
// TODO this may be refactored using a list of separators I guess
computeWordStartIndex: function(text,caretIndex) {
var wordSeparator = " ";
var nbspWordSeparator = "\u00a0";
var lookupIndex = caretIndex > 0 ? caretIndex -1 : 0;
if ( caretIndex == 0 ) {
return 0;
}
//
var startSeparatorIndex = text.lastIndexOf(wordSeparator,lookupIndex);
var startWordIndex = (startSeparatorIndex !== -1) ? startSeparatorIndex + 1 : 0;
//
var startSeparatorIndex2 = text.lastIndexOf(nbspWordSeparator,lookupIndex);
var startWordIndex2 = (startSeparatorIndex2 !== -1) ? startSeparatorIndex2 + 1 : 0;
//
return Math.max(startWordIndex,startWordIndex2);
},
computeWordEndIndex: function(text,caretIndex) {
var wordSeparator = " ";
var nbspWordSeparator = "\u00a0";
//
var endSeparatorIndex = text.indexOf(wordSeparator,caretIndex);
var endWordIndex = (endSeparatorIndex !== -1) ? endSeparatorIndex : text.length
//
var endSeparatorIndex2 = text.indexOf(nbspWordSeparator,caretIndex);
var endWordIndex2 = (endSeparatorIndex2 !== -1) ? endSeparatorIndex2 : text.length
//
return Math.min(endWordIndex,endWordIndex2);
},
@sdkindustries
Copy link

This is exactly what I'm looking for. How do I implement this? Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment