/**
* editor_plugin_src.js
*
* Copyright 2012, Mijura Pty. Ltd. Released under The MIT License.
*
* About:
* AutoComplete for TinyMCE provides inline autocomplete in a style similar to
* Twitter or Facebook. The text you type in tinyMCE is checked
* against a list of specified options; if there is a match then
* you will see them appear in a list underneath the caret.
*
* Configuration:
* Parameters that we can use in tinyMCE config:
* 1\ autocomplete_delimiters - A CSV list of delimiters (ASCII codes) on which
* to split text entered into tinyMCE. In most cases you will want to
* split text by spaces, in which case you would specify '160,32'. 32 is
* a normal space and 160 is (which is commonly used by tinyMCE).
* Whichever delimiter you specify first will be inserted after you
* select an option. The default is '160,32' for spaces.
* 2\ autocomplete_options - A CSV list of autocomplete options. For
* example, 'john,jane,jwilliam'.
* 3\ autocomplete_trigger - You can specify a trigger character that must
* be type immediately before searching for options. The default
* trigger is '@'
* 4\ autocomplete_end_option - Any text that you want to be added after the
* option. The caret will be placed between the option and this ending
* text. For example, you could specify 'end', in which case selecting
* an autocomplete option would insert: '@jane @end' with the caret
* placed in between (and including the trigger before the end option).
* 5\ autocomplete_min_length - The minimum number of characters a word needs to have
* before the autocomplete activates.
* The default is 1.
* 6\ autocomplete_min_length_ajax - The minimum number of characters a word needs to have
* before the autocomplete activates for AJAX request.
* The default is 4.
* 7\ autocomplete_on_select - A function to call after an option is selected.
* The default is false.
* 8\ autocomplete_on_match - A function to call when text entered match only one option.
* The default is false.
*
* Support:
* You are welcome to use this plugin at your own risk. It is currently
* being maintained on GitHub where you can submit issues / feature requests.
*
* Modified at 2014-02-06 to be compatible with TinyMCE 4 version
*/
(function () {
var autocomplete_data = {};
var DOWN_ARROW_KEY = 40;
var UP_ARROW_KEY = 38;
var ESC_KEY = 27;
var ENTER_KEY = 13;
var END_WORD_KEYS = [32, 59, 186, 188, 190];
function parseOptions(param) {
return param.options == null && typeof param != "boolean" ? param.split(",") : param.options;
}
tinymce.create('tinymce.plugins.AutoCompletePlugin', {
setOptions: function (param) {
autocomplete_data.options = parseOptions(param);
},
getOptions: function () {
return autocomplete_data.options;
},
init: function (ed, url) {
autocomplete_data = {
list: createOptionList(),
visible: false,
cancelEnter: false,
delimiter: ed.getParam('autocomplete_delimiters', '160,32').split(","),
options: parseOptions(ed.getParam('autocomplete_options', '')),
optionsUrl: parseOptions(ed.getParam('autocomplete_options_url', false)),
trigger: ed.getParam('autocomplete_trigger', '@'),
enclosing: ed.getParam('autocomplete_end_option', ''),
minLength: ed.getParam('autocomplete_min_length', '1'),
minLengthAjax: ed.getParam('autocomplete_min_length_ajax', '4'),
onSelect: ed.getParam('autocomplete_on_select', false),
onMatch: ed.getParam('autocomplete_on_match', false)
};
var t = this;
// Setup plugin event
if (autocomplete_data.onSelect) {
t.onSelect = new tinymce.util.Dispatcher(t);
t.onSelect.add(function (ed, selected) {
ed.execCallback('autocomplete_on_select', ed, selected);
});
}
if (autocomplete_data.onMatch) {
t.onMatch = new tinymce.util.Dispatcher(t);
t.onMatch.add(function (ed, match) {
ed.execCallback('autocomplete_on_match', ed, match);
});
}
/**
* Search for autocomplete options after text is entered and display the
* option list if any matches are found.
*/
function keyUpEvent(e) {
if ((!autocomplete_data.visible && e.keyCode != ESC_KEY && e.keyCode != ENTER_KEY) || (e.keyCode != DOWN_ARROW_KEY && e.keyCode != UP_ARROW_KEY && e.keyCode != ENTER_KEY && e.keyCode != ESC_KEY)) {
var currentWord = getCurrentWord(ed);
currentWord = currentWord.replace(autocomplete_data.trigger, "");
var matches = matchingOptions(currentWord);
if (currentWord.length > 0) {
populateList(currentWord);
}
if (currentWord.length == 0 || matches.length == 0) {
hideOptionList();
}
}
}
/**
* Populates autocomplete list with matched words.
*
*/
function populateList(currentWord) {
var wordLessTrigger = currentWord.replace(autocomplete_data.trigger, "");
if( currentWord.length >= autocomplete_data.minLength &&
( currentWord.length < autocomplete_data.minLengthAjax || ! autocomplete_data.optionsUrl ) )
{ // Get matches from static array
matches = matchingOptions(wordLessTrigger);
if (matches.length > 0) {
displayOptionList(matches, wordLessTrigger, ed);
highlightNextOption();
}
}
else
{ // Get matches by AJAX
if (wordLessTrigger.length <= 1)
return false;
var static_options = autocomplete_data.options;
jQuery.ajax({
type: "GET",
dataType: "JSON",
url: autocomplete_data.optionsUrl,
cache: false,
data: "q=" + wordLessTrigger,
success: function (data) {
if( data && typeof( data.users ) != 'undefined' ) {
var options = [];
for( var i in data.users ) {
options.push(data.users[i].login);
}
autocomplete_data.options = options;
matches = matchingOptions(wordLessTrigger);
if (matches.length > 0) {
displayOptionList(matches, wordLessTrigger, ed);
highlightNextOption();
}
autocomplete_data.options = static_options;
}
}
});
autocomplete_data.options = static_options;
}
} // populateList
/**
* Prevent return from adding a new line after selecting an option.
*/
function keyPressEvent(e) {
if (e.keyCode == ENTER_KEY && autocomplete_data.cancelEnter) {
autocomplete_data.cancelEnter = false;
return tinymce.dom.Event.cancel(e);
}
}
/**
* Handle navigation inside the option list when it is visible.
* These events should not propagate to the editor.
*/
function keyDownEvent(e) {
if (autocomplete_data.visible) {
if (e.keyCode == DOWN_ARROW_KEY) {
highlightNextOption();
return tinymce.dom.Event.cancel(e);
}
if (e.keyCode == UP_ARROW_KEY) {
highlightPreviousOption();
return tinymce.dom.Event.cancel(e);
}
if (e.keyCode == ENTER_KEY) {
selectOption(ed, getCurrentWord(ed));
autocomplete_data.cancelEnter = true;
return false; // the enter evet needs to be cancelled on keypress so
// it doesn't register a carriage return
}
if (e.keyCode == ESC_KEY) {
hideOptionList();
return tinymce.dom.Event.cancel(e);
}
// onMatch callback
if (autocomplete_data.onMatch && END_WORD_KEYS.indexOf(e.keyCode)) {
var word = getCurrentWord(ed);
var matches = matchingOptions(word);
var completeMatch = new RegExp("^" + matches[0] + "$", "i");
if (matches.length == 1 && word.match(completeMatch)) {
t.onMatch.dispatch(ed, matches[0]);
}
}
}
}
function clickEvent(e) {
hideOptionList();
}
/**
* Add all the options to the option list and display it right beneath
* the caret where the user is entering text. There didn't appear to be
* an easy way to retrieve the exact pixel position of the caret inside
* tinyMCE so the difficult method had to suffice.
*/
function displayOptionList(matches, matchedText, ed) {
var matchesList = "";
var highlightRegex = new RegExp("(" + matchedText + ")");
for (var i in matches) {
if (matches[i].key != null) {
matchesList += "<li data-value='" + matches[i].key + "'><a>" + matches[i].key.replace(highlightRegex, "<mark>$1</mark>") + " " + matches[i].description + "</a></li>";
}
else if( typeof( matches[i] ) == 'string' ) {
matchesList += "<li data-value='" + matches[i] + "'><a>" + matches[i].replace(highlightRegex, "<mark>$1</mark>") + "</a></li>";
}
}
jQuery(autocomplete_data.list).html(matchesList);
// work out the position of the caret
var iframe_offset = jQuery( ed.getContainer() ).find( 'iframe' ).offset();
var textareaTop = 0;
var textareaLeft = 0;
if (ed.selection.getRng().getClientRects().length > 0) {
textareaTop = ed.selection.getRng().getClientRects()[0].top + ed.selection.getRng().getClientRects()[0].height;
textareaLeft = ed.selection.getRng().getClientRects()[0].left;
} else {
textareaTop = parseInt(jQuery(ed.selection.getNode()).css("font-size")) * 1.3 + nodePosition.top;
textareaLeft = nodePosition.left;
}
jQuery(autocomplete_data.list).css("position", "absolute");
jQuery(autocomplete_data.list).css("top", iframe_offset.top + textareaTop );
jQuery(autocomplete_data.list).css("left", iframe_offset.left + textareaLeft );
jQuery(autocomplete_data.list).css("display", "block");
autocomplete_data.visible = true;
optionListEventHandlers(ed);
}
/**
* Allow a user to select an option by clicking with the mouse and
* highlighting the options on hover.
*/
function optionListEventHandlers(ed) {
jQuery(autocomplete_data.list).find("li").hover(function () {
jQuery(autocomplete_data.list).find("[data-selected=true]").attr("data-selected", "false").removeClass( 'active' );
jQuery(this).attr("data-selected", "true").addClass( 'active' );
});
jQuery(autocomplete_data.list).find("li").click(function () {
selectOption(ed, getCurrentWord(ed));
});
}
function createOptionList() {
var ulContainer = document.createElement("ul");
jQuery(ulContainer).addClass("dropdown-menu textcomplete-dropdown");
document.body.appendChild(ulContainer);
return ulContainer;
}
function hideOptionList() {
jQuery(autocomplete_data.list).css("display", "none");
autocomplete_data.visible = false;
}
function highlightNextOption() {
var current = jQuery(autocomplete_data.list).find("[data-selected=true]");
if (current.size() == 0 || current.next().size() == 0) {
jQuery(autocomplete_data.list).find("li:first-child").attr("data-selected", "true").addClass( 'active' );
} else {
current.next().attr("data-selected", "true").addClass( 'active' );
}
current.attr("data-selected", "false").removeClass( 'active' );
}
function highlightPreviousOption() {
var current = jQuery(autocomplete_data.list).find("[data-selected=true]");
if (current.size() == 0 || current.prev().size() == 0) {
jQuery(autocomplete_data.list).find("li:last-child").attr("data-selected", "true").addClass( 'active' );
} else {
current.prev().attr("data-selected", "true").addClass( 'active' );
}
current.attr("data-selected", "false").removeClass( 'active' );
}
/**
* Select/insert the currently selected option. The option will be inserted at the
* caret position with a delimiter at the end and the option enclosing text. If the
* enclosing text has already been inserted (this would happen when you are editing
* an autocompleted option), then it won't be inserted again.
*/
function selectOption(ed, matchedText) {
var current = jQuery(autocomplete_data.list).find("[data-selected=true]").attr("data-value");
if (current == null) {
current = jQuery(autocomplete_data.list).find("li:first-child").attr("data-value");
}
var content = restOfContent(ed.selection.getSel().anchorNode, "");
var currentNode = ed.selection.getSel().anchorNode.textContent;
// modify the range to replace overwrite the option text that has already been entered
var range = ed.selection.getRng();
range.setStart(range.startContainer, range.startOffset - matchedText.length);
ed.selection.setRng(range);
// insert the trigger, selected option and following delimiter
var delim = "";
if (autocomplete_data.delimiter.length > 0) {
delim = String.fromCharCode(autocomplete_data.delimiter[0]);
}
ed.selection.setContent(autocomplete_data.trigger + current.toString() + delim);
// insert the enclosing text if it has not already been added
if (autocomplete_data.enclosing.length > 0 && !closingTextExists(content, currentNode)) {
var middleBookmark = ed.selection.getBookmark();
ed.selection.setContent(delim + autocomplete_data.trigger + autocomplete_data.enclosing);
ed.selection.moveToBookmark(middleBookmark);
}
hideOptionList();
// onSelect callback
if (autocomplete_data.onSelect) {
t.onSelect.dispatch(ed, current);
}
hideOptionList();
}
/**
* Check if the enclosing string has already been placed past the current node.
*/
function closingTextExists(content, currentNode) {
var enclosed = autocomplete_data.trigger + autocomplete_data.enclosing;
content = content.substr(currentNode.length);
var matches = new RegExp(autocomplete_data.trigger + ".{" + autocomplete_data.enclosing.length + "}", "g").exec(content);
if (matches != null && matches.length > 0 && matches[0] == enclosed) {
return true;
}
return false;
}
/**
* Recursively find all of the content past (and including) the caret node.
* This doesn't appear to be available any other way.
*/
function restOfContent(anchorNode, content) {
content += anchorNode.textContent;
if (anchorNode.nextSibling != null) {
return restOfContent(anchorNode.nextSibling, content);
}
return content;
}
/**
* Find all options whose beginning matches the currently entered text.
*/
function matchingOptions(currentWord) {
var options = autocomplete_data.options;
var matches = [];
for (var i in options) {
if (options[i].key == null && (currentWord.length == 0 || beginningOfWordMatches(currentWord, options[i]))) {
matches.push(options[i]);
}
else if (options[i].key != null && (currentWord.length == 0 || beginningOfWordMatches(currentWord, options[i].key))) {
matches.push(options[i]);
}
}
return matches;
}
function beginningOfWordMatches(beginning, option) {
var test = new RegExp("^" + beginning, "i");
return (typeof( option ) == 'string' ? option.match(test) : '');
}
/**
* Retrieves the 'word' as specified by the first occurrence of a
* delimiter prior to the caret position.
*/
function getCurrentWord(ed) {
var nodeText = ed.selection.getSel().focusNode == null ? "" : ed.selection.getSel().focusNode.nodeValue;
var positionInNode = ed.selection.getSel().focusOffset;
if (nodeText == null || nodeText.length == 0) {
return "";
}
var lastDelimiter = 0;
for (var i = 0; i < positionInNode; i++) {
if (autocomplete_data.delimiter.indexOf(nodeText.charCodeAt(i).toString()) != -1) {
lastDelimiter = i + 1;
}
}
var word = nodeText.substr(lastDelimiter, positionInNode - lastDelimiter);
var retWord = "";
if (autocomplete_data.trigger == '') {
if (word.length >= autocomplete_data.minLength) {
retWord = word;
}
} else {
if (word.length > autocomplete_data.minLength && word.charAt(0).toString() == autocomplete_data.trigger) {
retWord = word;
}
}
return retWord;
}
ed.on( 'keyup', keyUpEvent );
ed.on( 'keydown' , keyDownEvent );
ed.on( 'keypress', keyPressEvent );
ed.on( 'click', clickEvent );
},
getInfo: function () {
return {
longname: 'AutoComplete',
author: 'Mijura Pty Ltd',
authorurl: 'http://mijura.com',
infourl: 'http://blog.mijura.com',
version: tinymce.majorVersion + "." + tinymce.minorVersion
};
}
});
tinymce.PluginManager.add('autocomplete',
tinymce.plugins.AutoCompletePlugin);
})();