blob: a37381e12c4e8e34baa8e507880c3445db55ddf9 [file] [log] [blame]
AJS.log("autocomplete editor_plugin_src starting");
/**
* Autocomplete dropdown appears when you press a trigger character the editor.
*/
(function() {
tinymce.create('tinymce.plugins.AutoComplete', {
init : function(ed) {
ed.addCommand('mceConfAutocompleteLink', function() {
tinymce.confluence.Autocompleter.Manager.shortcutFired("[");
});
ed.addCommand('mceConfAutocompleteImage', function() {
tinymce.confluence.Autocompleter.Manager.shortcutFired("!");
});
ed.addShortcut("ctrl+shift+k", ed.getLang("AutoComplete"), "mceConfAutocompleteLink");
ed.addShortcut("ctrl+shift+m", ed.getLang("AutoComplete"), "mceConfAutocompleteImage");
var addAutocompleteHandlers = function (settings) {
if (settings["confluence.prefs.editor.disable.autocomplete"]) {
return;
}
AJS.log("Autocomplete enabled, adding keyPress listener");
// Certain keys prompt the autocomplete, e.g. typing [ goes into "link auto-complete" mode
ed.onKeyPress.addToTop(tinymce.confluence.Autocompleter.Manager.triggerListener);
};
ed.onPostRender.add(function() {
// The DOM might not necessarily be ready on editor post render (see similar code in the contextmenu plugin)
AJS.$(function() {
if (AJS.params.remoteUser) {
AJS.$.getJSON(tinyMCE.settings.plugin_action_base_path + "/get-wysiwyg-settings.action", {}, addAutocompleteHandlers);
} else {
// Always enabled for anonymous users
addAutocompleteHandlers({});
}
});
});
},
getInfo : function() {
return {
longname : 'Auto Complete',
author : 'Atlassian',
authorurl : 'http://www.atlassian.com/',
version : tinymce.majorVersion + "." + tinymce.minorVersion
};
}
});
// Register plugin
tinymce.PluginManager.add('autocomplete', tinymce.plugins.AutoComplete);
})();
tinymce.confluence.Autocompleter = {};
/**
* Settings that each Autocomplete will be initialized on, depending on the trigger character used to activate the
* autocomplete.
*/
AJS.toInit(function ($) {
AJS.log("tinyMce-autocomplete-settings initialising");
tinymce.confluence.Autocompleter.Settings = {};
});
/**
* Custom logging function allows for more structured output. log4javascript on the horizon.
* @param owner the "class" this logger is for
*
* Params accepted by the returned log function:
* - caller : name of the calling method
* - desc : the actual log body
* - obj : an object or string to be rendered
*/
tinymce.confluence.Autocompleter.log = function (owner) {
return function (caller, desc, obj) {
// Log string objects on the same line, else on the next line
var objIsStr = (obj != null && typeof obj != "object");
var objStr = obj != null ? (objIsStr ? (" = " + obj) : " >") : "";
AJS.log(owner + " - " + caller + " : " + (desc || null) + objStr);
obj && !objIsStr && AJS.log(obj);
};
};
AJS.log("tinyMce-autocomplete-util starting");
tinymce.confluence.Autocompleter.Util = (function() {
var loadData = function (json, query, callback, field) {
var hasErrors = json.statusMessage;
var matrix;
if (hasErrors) {
matrix = [[{html: json.statusMessage, className: "error"}]];
} else {
var restMatrix = query ? AJS.REST.makeRestMatrixFromSearchData(json) : AJS.REST.makeRestMatrixFromData(json, field);
matrix = AJS.REST.convertFromRest(restMatrix);
}
// do conversion
callback(matrix, query);
};
return {
/**
* Returns the HTML of a AJS.dropdown link with an icon span. The icon span is required in the dropdown if we
* want to use a sprite background for the link icon.
* @param text escaped text of the dropdown item
* @param className class name to be added to the link
* @param iconClass class name to be added on the icon span
* @return HTML string for the dropdown link
*/
// we should remove this once AUI dropdown supports sprite icons
dropdownLink : function(text, className, iconClass) {
return "<a href='#' class='" + (className || "" ) + "'><span class='icon " + (iconClass || "") + "'></span><span>" + text + "</span></a>";
},
getRestData : function (autoCompleteControl, getUrl, getParams, val, callback, suggestionField) {
var url = getUrl(val);
if (url) {
AJS.$.ajax({
type: "GET",
url: url,
data: getParams(autoCompleteControl, val),
success: function (json) {
loadData.call(autoCompleteControl, json, val, callback, suggestionField);
},
dataType: "json",
global: false,
timeout: 5000,
error: function (xml, status) { // ajax error handler
if (status == "timeout") {
loadData.call(autoCompleteControl, {statusMessage: "Timeout", query: val}, val, callback, suggestionField);
}
}
});
} else {
// If no url, default items may be displayed - run the callback with no data.
callback([], val);
}
}
};
})(AJS.$);
AJS.log("tinyMce-autocomplete-control starting");
/**
* Selects the word at the cursor and returns the word and the left/top location of the
* bottom-left corner of the first word.
*
* @param options An options map including:
* - leadingChar: trigger character used to launch autocomplete
* - dontSuggest: Don't search based on text typed in the autocomplete span
* - backWords: the number of words to search backwards for
*/
tinymce.confluence.Autocompleter.Control = function(ed, options) {
var log = tinymce.confluence.Autocompleter.log("Autocompleter.Control");
/**
* The Control to be returned.
*/
var control = {},
/**
* This element wraps the search text and the trigger (if present).
*/
AUTOCOMPLETE_ID = "autocomplete",
/**
* This element wraps the trigger character (e.g. @, [, !)
*/
AUTOCOMPLETE_TRIGGER_ID = "autocomplete-trigger",
/**
* This element contains the text the user is searching for - it should always hold the cursor.
*/
AUTOCOMPLETE_SEARCH_TEXT_ID = "autocomplete-search-text",
adaptor = AJS.Editor.Adapter,
rng = adaptor.getRange(),
cursorPos = rng.startOffset,
node = rng.startContainer,
nodeText = node.nodeValue,
leadingChar = options.leadingChar,
selection = ed.selection,
doc = ed.getDoc(),
backWords = options.backWords || 0;
if (AJS.$("#" + AUTOCOMPLETE_ID, doc).length) {
log("init", "Autocomplete already exists, returning null.");
return null;
}
control.backWords = backWords;
control.maxResults = options.maxResults || 10;
// Cursor may be in a <p> just outside of a TextNode, check for child node at startOffset
if (nodeText == null && rng.collapsed && cursorPos && node.childNodes[cursorPos - 1]) {
node = node.childNodes[cursorPos - 1]; // to the LEFT of the cursor
nodeText = node.nodeValue;
cursorPos = (nodeText && nodeText.length) || 0;
}
var text = "";
// Cursor may still not be in a Text node, in which leave the text empty.
if (nodeText != null) {
text = (nodeText + "").substring(0, cursorPos);
var pnode = node.previousSibling;
while (pnode && pnode.nodeType == 3) {
// add the text from any previous TextNodes
text = pnode.nodeValue + text;
pnode = pnode.previousSibling;
}
}
// Disable trigger chars at the end of the certain strings e.g. “Hi there!”.
// The regex allows "'<( left" and left' before the [ trigger and space, zero-width space, &nbsp; and em-dash
// before all triggers.
if (!backWords && text && !(/(["'<\(\u201c\u2018]\[|[\ufeff\u2014\s\xa0].)$/).test(text + leadingChar)) {
log("init", "Cursor is in wrong word location to start autocomplete, returning null.");
return null;
}
var $node = AJS.$(node);
if ($node.closest("div.code, a[href], img, div.wysiwyg-macro-starttag, div.wysiwyg-macro-endtag").length || AJS.$("#property-panel:visible").length) {
log("init", "Cursor is in wrong node to start autocomplete, returning null.");
return null;
}
if (!leadingChar && nodeText == null) {
log("init", "No text available for suggestion, range is", rng);
// TODO - handle this (and weird TextNodes)
nodeText = "";
}
// TODO - not this. See http://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expre
function regexLastIndexOf(str, regex, startpos) {
!regex.global && (regex = new RegExp(regex.source, "g" + "i".slice(0, regex.ignoreCase) + "m".slice(0, regex.multiLine)));
if (startpos == null) {
startpos = str.length;
} else if (startpos < 0) {
startpos = 0;
}
var stringToWorkWith = str.substring(0, startpos + 1),
lastIndexOf = -1,
nextStop = 0;
while ((result = regex.exec(stringToWorkWith)) != null) {
lastIndexOf = result.index;
regex.lastIndex = ++nextStop;
}
return lastIndexOf;
}
/**
* Returns a jQuery-wrapped reference to the autocomplete container.
*/
control.getContainer = function () {
return AJS.$("#" + AUTOCOMPLETE_ID, doc);
};
/**
* Starting at the given endpoint, search backward through text nodes until the requested number of words are
* found.
* @param node
* @param offset
* @param backWords
*/
function findRangeStart(node, offset, backWords) {
var nodeText, pNode;
for (var i = 0; i < backWords; i++) {
nodeText = node.nodeValue.substring(0, offset);
offset = regexLastIndexOf(nodeText, (/\s+/), offset);
while (offset == -1) {
pNode = node.previousSibling;
if (pNode && pNode.nodeType == 3) {
node = pNode;
nodeText = pNode.nodeValue;
if (nodeText) {
offset = regexLastIndexOf(nodeText, (/\s+/), nodeText.length);
}
} else {
i = backWords; // no point looking further
break;
}
}
}
return {
node: node,
offset: offset + 1
};
}
var suggestionHtml = "";
if (rng.collapsed && backWords && nodeText) {
var rangeStart = findRangeStart(node, cursorPos, backWords);
// Select from the cursor back to the start of the first word
if (tinymce.isIE && backWords == 1) {
var range = selection.getRng();
range.moveStart("character", rangeStart.offset - cursorPos);
range.select();
} else {
range = adaptor.createRange();
range.setStart(rangeStart.node, rangeStart.offset);
range.setEnd(node, cursorPos);
selection.setRng(range);
}
}
// Use the existing selection as the search term
// TODO - html format is failing due to our preProcess on serializer. Fix that.
suggestionHtml = selection.getContent({format : 'text'});
log("init", "suggestionHtml", suggestionHtml);
var el = AJS("span").attr("id", AUTOCOMPLETE_ID);
if (leadingChar) {
el.append(AJS("span").attr("id", AUTOCOMPLETE_TRIGGER_ID).text(leadingChar));
}
var $searchTextSpan = AJS("span").attr("id", AUTOCOMPLETE_SEARCH_TEXT_ID);
$searchTextSpan.text(adaptor.HIDDEN_CHAR);
el.append($searchTextSpan);
selection.setNode(el[0]);
var autocompleteSpan = control.getContainer();
control.previousSearchText = "";
control.settings = tinymce.confluence.Autocompleter.Settings[leadingChar || "["]; // default to link
// Put the cursor inside the new span, at the end.
var searchNode = AJS.$("#" + AUTOCOMPLETE_SEARCH_TEXT_ID, control.getContainer()),
searchTextNode = searchNode[0].firstChild,
cursorPosition = searchTextNode.nodeValue.length,
selNode = AJS.$(doc.createElement("span")).text(suggestionHtml || adaptor.HIDDEN_CHAR);
searchNode.empty().append(selNode);
selection.select(selNode[0]);
selection.collapse();
var position = tinymce.DOM.getPos(autocompleteSpan[0]),
height = autocompleteSpan.height();
log("init", "position", position);
log("init", "pixel offset", autocompleteSpan.offset());
// Events
var before = function (e) {
if (control.onBeforeKey && !control.onBeforeKey(e, control.text())) {
e.stopPropagation();
e.preventDefault();
log("after", "blocked by onBeforeKey");
return false;
}
},
after = function (e) {
var rng = adaptor.getRange(),
span = control.getContainer(),
node = rng.startContainer,
parent = node.parentNode;
node.nodeType == 3 && (parent = parent.parentNode);
var grandpa = parent.parentNode,
outsideSearchSpan = parent != span[0] && grandpa != span[0];
if (e.keyCode == 27 || outsideSearchSpan) {
log("after", "dying because of: " + outsideSearchSpan ? "outside search span" : "escape pressed");
control.die();
} else if (control.onAfterKey && !control.onAfterKey(e, control.text())) {
e.stopPropagation();
e.preventDefault();
log("after", "blocked by onAfterKey");
return false;
}
},
press = function (e) {
if (control.onKeyPress && !control.onKeyPress(e, control.text())) {
e.stopPropagation();
e.preventDefault();
log("after", "blocked by onKeyPress");
return false;
}
},
click = function (e) {
if (control.getContainer()[0] != e.target.parentNode) {
log("click", "Clicked outside of autocomplete, closing.");
control.die();
}
};
AJS.$(doc).keydown(before).keyup(after).keypress(press).click(click);
// For Recent History and certain other searches, ignore the selected text for searching.
control.word = "";
if (!options.keepAlias) {
control.word = suggestionHtml;
} else {
log("init", "No suggestion based on previous or selected text");
}
control.left = position.x;
control.top = position.y + height;
control.text = function (text) {
var span = AJS.$("#" + AUTOCOMPLETE_SEARCH_TEXT_ID, control.getContainer());
if (text != null) {
span.html(text);
return this;
} else {
text = AJS.escapeEntities(span.text());
return text.replace(adaptor.HIDDEN_CHAR, "");
}
};
/**
* Replaces the autocomplete component with the given text, which may be empty.
* If the given text IS empty, it will always be collapsed.
* If the collapse parameter is true, the range will be collapsed at the end of the text.
* @param text string to replace autocomplete with
* @param collapse if true, collapse range to end of text, else select text
*/
var replaceWithTextAndGetRange = function(text, collapse) {
adaptor.replaceWithTextAndGetRange(control.getContainer(), text, collapse);
control.die();
return rng;
};
control.replaceWithSelectedSearchText = function () {
// Get the autocomplete search text and select the entire autocomplete
var replaceText = control.text();
log("replaceWithSelectedSearchText", replaceText);
replaceWithTextAndGetRange(replaceText, false);
return replaceText;
};
control.die = function (notrigger) {
if (control.dying) {
log("die", "Already dying, returning.");
return;
}
control.dying = true;
var container = control.getContainer();
if (container.length) {
log("die", "Tearing down autocomplete, cleaning up autocompleter");
// Replace autocomplete span with its current text
var replaceText = ((notrigger || options.backWords) ? "" : control.settings.ch) + control.text();
rng = replaceWithTextAndGetRange(replaceText, true);
}
AJS.$(doc).unbind("keydown", before).unbind("keyup", after).unbind("click", click).unbind("keypress", press);
AJS.Editor.Adapter.unbindScroll("autocomplete");
AJS.$(document).unbind("click.autocomplete-outside");
this.onDeath && this.onDeath();
};
AJS.Editor.Adapter.bindScroll("autocomplete", function () {
control.die();
});
AJS.$(document).bind("click.autocomplete-outside", function (e) {
if (!AJS.$(e.target).closest("#autocomplete-dropdown").length) {
control.die();
}
});
control.update = function (data) {
AJS.Editor.Adapter.storeCurrentSelectionState();
replaceWithTextAndGetRange("", true);
this.settings.update(this,data);
control.die();
};
control.removeSpan = function () {
control.getContainer().remove();
};
return control;
};
AJS.log("tinyMce-autocomplete-manager starting");
tinymce.confluence.Autocompleter.Manager = (function ($) {
var log = tinymce.confluence.Autocompleter.log("Autocompleter.Manager");
/**
* There will only be one autoCompleteControl active at a time so a reference to it can be shared across methods.
*/
var autoCompleteControl;
/**
* The input driven dropdown component that does most of the work.
*/
var idd;
/**
* Called when the user hits a key combination at the end of some text to autocomplete.
* If there is no text at the cursor, the user's Recent History is displayed instead.
*
* options include:
* - leadingChar - determines the type of autocomplete, e.g. [ , !
* - backWords - the number of words to search backwards for
*/
var startAutoComplete = function (options) {
log("startAutoComplete", "Started");
autoCompleteControl = tinymce.confluence.Autocompleter.Control(AJS.Editor.Adapter.getEditor(), options);
if (!autoCompleteControl) {
return false;
}
var selectionHandler = function (e, selection) {
e.preventDefault();
var result = AJS.$.data(selection[0], "properties");
if (result && typeof result.callback == "function") {
result.callback(autoCompleteControl);
} else if (result.className != "menu-header") {
log("selectionHandler", "Inserting link from dropdown selection");
autoCompleteControl.update(result);
}
};
var moveHandler = function (selection, dir) {
var current = AJS.dropDown.current;
if (selection && selection.find("a").is(".menu-header")) {
dir == "up" ? current.moveUp(): current.moveDown();
}
};
var winWidth = AJS.$(window).width();
idd = AJS.inputDrivenDropdown({
onShow : function (dd) {
log("onShow", "Post-processing the dropdown");
dd.find("ol:has(a.menu-header)").addClass("top-menu-item");
$("#autocomplete-dropdown ol:empty").hide();
var iframe = AJS.Editor.Adapter.getEditorFrame();
iframe.shim && iframe.shim.hide();
dd.find("a.menu-header").unbind().click(function (e) {
e.preventDefault();
autoCompleteControl.die();
});
this.reset();
},
dropdownPlacement : function (dd) {
var parent = $("#autocomplete-dropdown"),
anchor = autoCompleteControl.getContainer();
if (!parent.length) {
parent = AJS("div").addClass("aui-dd-parent quick-nav-drop-down").attr("id", "autocomplete-dropdown").appendTo("body");
}
var offset = AJS.Editor.Adapter.offset(anchor),
overlap = parent.width() + offset.left - winWidth + 10,
gapForArrowY = 10,
gapForArrowX = 0,
top = offset.top + anchor.height() + gapForArrowY,
left = offset.left - (overlap > 0 ? overlap : 0) - gapForArrowX;
parent.append(dd).css({
position: "absolute",
top: top,
left: left
});
if (window.Raphael) {
if (idd.raphaelArrow) {
idd.raphaelArrow.canvas.style.left = offset.left + 4 + "px";
idd.raphaelArrow.canvas.style.top = top - 5 + "px";
} else {
var r = Raphael(offset.left + 4, top - 5, 12, 6);
r.path("http://10.20.160.198/wiki/s/en/2166/34/3.5.9/_/download/batch/com.atlassian.confluence.tinymceplugin:editor-autocomplete-resources/M0.001,6.001l6.001-6.001,6.001,6.001").attr({
fill: "#f0f0f0",
stroke: "#bbb"
});
r.canvas.style.zIndex = 3000;
idd.raphaelArrow = r;
}
}
},
onDeath : function () {
$("#autocomplete-dropdown").remove();
idd.raphaelArrow && idd.raphaelArrow.remove && idd.raphaelArrow.remove();
},
ajsDropDownOptions: {
selectionHandler: selectionHandler,
moveHandler: moveHandler,
className : "autocomplete " + autoCompleteControl.settings.dropDownClassName
},
getDataAndRunCallback: function(val) {
autoCompleteControl.settings.getDataAndRunCallback &&
autoCompleteControl.settings.getDataAndRunCallback(autoCompleteControl, val,
function(matrix, query) {
matrix.unshift([
{
className: "menu-header dropdown-prevent-highlight",
href: "#",
name: autoCompleteControl.settings.getHeaderText(autoCompleteControl, val)
}
]);
matrix.push(autoCompleteControl.settings.getAdditionalLinks(autoCompleteControl, val));
// If the idd control is still active update it with the new data.
idd && idd.show(matrix, query, [query]);
}
);
}
});
autoCompleteControl.onBeforeKey = function (e, text) {
if (e.keyCode == 40 || e.keyCode == 38 || e.keyCode == 13) {
var current = AJS.dropDown.current;
if (!current) {
log("autoCompleteControl.onBeforeKey", "key caught before dropdown ready, ignoring");
return false;
}
if (current.getFocusIndex() == -1 && e.keyCode == 13) { // user hit enter when nothing selected
reset();
return true;
}
return current.moveFocus(e);
}
if (e.keyCode == 27 || e.keyCode == 9 || (e.keyCode == 8 && !text)) {
// User has key-downed backspace but text is *already* blank - close autocomplete.
log("autoCompleteControl.onBeforeKey", "killing autoCompleteControl and returning false");
autoCompleteControl.die(e.keyCode == 8);
return false;
}
return true;
};
// Blocker for browser default actions for up and down keys
autoCompleteControl.onKeyPress = function (e, text) {
var ch = AJS.$.browser.msie ? e.keyCode : e.which,
character = String.fromCharCode(ch); // charCode back to '@'
if (e.keyCode == 40 || e.keyCode == 38 || e.keyCode == 13) {
tinymce.dom.Event.cancel(e);
return false;
}
var endCharIndex = AJS.indexOf(autoCompleteControl.settings.endChars,character);
if (endCharIndex != -1) {
log("autoCompleteControl.onKeyPress", "caught autocomplete-closing char " + character + ", closing");
autoCompleteControl.die();
}
return true;
};
var twoLetters = /\S{2,}/;
autoCompleteControl.onAfterKey = function (e, text) {
// User deleted back to zero characters - should display default suggestions again.
var forceUpdate = (e.keyCode == 8 && !text);
if (forceUpdate || twoLetters.test(text)) {
log("onAfterKey", "Changed search string to “" + text + "”");
idd.change(text, forceUpdate);
}
return true;
};
autoCompleteControl.onDeath = function () {
log("onDeath", "autoCompleteControl onDeath called");
if (idd) {
idd.remove();
idd.closing = true;
}
AJS.Editor.Adapter.onHideEditor = onHideEditor;
};
var onHideEditor = AJS.Editor.Adapter.onHideEditor;
AJS.Editor.Adapter.onHideEditor = function () {
onHideEditor();
reset();
};
// Start the dropdown with no text entered, to display the default suggestions.
idd.change(autoCompleteControl.word, "force");
return true;
};
var reset = function () {
autoCompleteControl.die();
autoCompleteControl = null;
};
return {
getInputDrivenDropdown: function() {
return idd;
},
// keyPress used so we can capture composite keystrokes like Sh-2 == @
triggerListener: function(ed, e) {
var returnValue = true,
ch = AJS.$.browser.msie ? e.keyCode : e.which;
if (idd) {
// We need this listener because the autoCompleteControl's keypress listener may have been unbound by the
// autoCompleteControl being taken down on enter *keydown*.
if (ch == 13) { // enter
tinymce.dom.Event.cancel(e);
returnValue = false;
}
}
idd && idd.closing && (idd = null);
if (!returnValue) {
return false;
}
var character = String.fromCharCode(ch); // charCode back to '@'
if (!idd && character in tinymce.confluence.Autocompleter.Settings) {
log("triggerListener", "Auto-complete initiated: trigger is ", character);
// Add the suggestion span and kill the event - we'll add the letter manually
startAutoComplete({
leadingChar: character
}) && tinymce.dom.Event.cancel(e);
}
return returnValue;
},
/**
* Called when a Ctrl-Sh-K or Ctrl-Sh-M shortcut is fired, selects the previous word.
*
* Multiple shortcuts will select more previous words to narrow the search.
*/
shortcutFired: function(leadingChar) {
var backWords = 1;
idd && idd.closing && (idd = null);
if (idd) {
backWords = autoCompleteControl.backWords + 1;
log("shortcutFired", "autocomplete active, increasing word selection to: " + backWords);
// the shortcut itself will be closing the previous autocomplete
reset();
}
return startAutoComplete({
leadingChar: leadingChar,
backWords: backWords
});
}
};
})(AJS.$);