Created
March 10, 2014 09:24
-
-
Save slorber/9461922 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}, |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is exactly what I'm looking for. How do I implement this? Thanks!