/* * atd.core.js - A building block to create a front-end for AtD * Author : Raphael Mudge, Automattic; Daniel Naber, LanguageTool.org * License : LGPL * Project : http://www.afterthedeadline.com/developers.slp * Contact : raffi@automattic.com * * Note: this has been simplified for use with LanguageTool - it now assumes there's no markup * anymore in the text field (not even bold etc)! */ /* EXPORTED_SYMBOLS is set so this file can be a JavaScript Module */ var EXPORTED_SYMBOLS = ['AtDCore']; // // TODO: // 1. "ignore this error" only works until the next check // 2. Ctrl-Z (undo) makes the error markers go away // // fixed: cursor position gets lost on check // fixed: "ignore all" doesn't work // fixed: current cursor position is ignored when incorrect (it has its own node) // fixed: text with markup (even bold) messes up everything // String.prototype.insert = function (index, string) { if (index > 0) return this.substring(0, index) + string + this.substring(index, this.length); else return string + this; }; function AtDCore() { /* Localized strings */ this.i18n = {}; /* We have to mis-use an existing valid HTML attribute to get our meta information * about errors in the text: */ this.surrogateAttribute = "onkeypress"; this.surrogateAttributeDelimiter = "---#---"; this.ignoredRulesIds = []; this.ignoredSpellingErrors = []; } /* * Internationalization Functions */ AtDCore.prototype.getLang = function(key, defaultk) { if (this.i18n[key] == undefined) return defaultk; return this.i18n[key]; }; AtDCore.prototype.addI18n = function(localizations) { this.i18n = localizations; }; /* * Setters */ AtDCore.prototype.getDetectedLanguageFromXML = function(responseXML) { var languages = responseXML.getElementsByTagName('language'); if (languages.length != 1) { // shouldn't happen, LT falls back to English instead alert('Sorry, could not detect language'); } var langName = languages[0].getAttribute('name'); return langName.replace(/\(.*?\)/, ""); // hack: LT doesn't actually detect the variant, so remove it }; AtDCore.prototype.processXML = function(responseXML) { this.suggestions = []; var errors = responseXML.getElementsByTagName('error'); for (var i = 0; i < errors.length; i++) { var suggestion = {}; // I didn't manage to make the CSS break the text, so we add breaks with Javascript: suggestion["description"] = this._wordwrap(errors[i].getAttribute("msg"), 50, "
"); suggestion["suggestions"] = []; var suggestionsStr = errors[i].getAttribute("replacements"); if (suggestionsStr) { suggestion["suggestions"] = suggestionsStr; } var errorOffset = parseInt(errors[i].getAttribute("offset")); var errorLength = parseInt(errors[i].getAttribute("errorlength")); suggestion["offset"] = errorOffset; suggestion["errorlength"] = errorLength; suggestion["type"] = errors[i].getAttribute("category"); suggestion["ruleid"] = errors[i].getAttribute("ruleId"); suggestion["subid"] = errors[i].getAttribute("subId"); suggestion["its20type"] = errors[i].getAttribute("locqualityissuetype"); var url = errors[i].getAttribute("url"); if (url) { suggestion["moreinfo"] = url; } this.suggestions.push(suggestion); } return this.suggestions; }; // Wrapper code by James Padolsey // Source: http://james.padolsey.com/javascript/wordwrap-for-javascript/ // License: "This is free and unencumbered software released into the public domain.", // see http://james.padolsey.com/terms-conditions/ AtDCore.prototype._wordwrap = function(str, width, brk, cut) { brk = brk || '\n'; width = width || 75; cut = cut || false; if (!str) { return str; } var regex = '.{1,' +width+ '}(\\s|$)' + (cut ? '|.{' +width+ '}|.+$' : '|\\S+?(\\s|$)'); return str.match( new RegExp(regex, 'g') ).join( brk ); }; // End of wrapper code by James Padolsey AtDCore.prototype.findSuggestion = function(element) { var metaInfo = element.getAttribute(this.surrogateAttribute); var errorDescription = {}; errorDescription["id"] = this.getSurrogatePart(metaInfo, 'id'); errorDescription["subid"] = this.getSurrogatePart(metaInfo, 'subid'); errorDescription["description"] = this.getSurrogatePart(metaInfo, 'description'); errorDescription["coveredtext"] = this.getSurrogatePart(metaInfo, 'coveredtext'); var suggestions = this.getSurrogatePart(metaInfo, 'suggestions'); if (suggestions) { errorDescription["suggestions"] = suggestions.split("#"); } else { errorDescription["suggestions"] = ""; } var url = this.getSurrogatePart(metaInfo, 'url'); if (url) { errorDescription["moreinfo"] = url; } return errorDescription; }; /* * code to manage highlighting of errors */ AtDCore.prototype.markMyWords = function() { var ed = tinyMCE.activeEditor; var textWithCursor = this.getPlainTextWithCursorMarker(); var cursorPos = textWithCursor.indexOf("\ufeff"); var newText = this.getPlainText(); var previousSpanStart = -1; // iterate backwards as we change the text and thus modify positions: for (var suggestionIndex = this.suggestions.length-1; suggestionIndex >= 0; suggestionIndex--) { var suggestion = this.suggestions[suggestionIndex]; if (!suggestion.used) { var spanStart = suggestion.offset; var spanEnd = spanStart + suggestion.errorlength; if (previousSpanStart != -1 && spanEnd > previousSpanStart) { // overlapping errors - these are not supported by our underline approach, // as we would need overlapping s for that, so skip the error: continue; } previousSpanStart = spanStart; var ruleId = suggestion.ruleid; if (this.ignoredRulesIds.indexOf(ruleId) !== -1) { continue; } var cssName; if (ruleId.indexOf("SPELLER_RULE") >= 0 || ruleId.indexOf("MORFOLOGIK_RULE") == 0 || ruleId == "HUNSPELL_NO_SUGGEST_RULE" || ruleId == "HUNSPELL_RULE") { cssName = "hiddenSpellError"; } else if (suggestion.its20type === 'style' || suggestion.its20type === 'locale-violation' || suggestion.its20type === 'register') { cssName = "hiddenSuggestion"; } else { cssName = "hiddenGrammarError"; } var delim = this.surrogateAttributeDelimiter; var coveredText = newText.substring(spanStart, spanEnd); if (this.ignoredSpellingErrors.indexOf(coveredText) !== -1) { continue; } var metaInfo = ruleId + delim + suggestion.subid + delim + suggestion.description + delim + suggestion.suggestions + delim + coveredText; if (suggestion.moreinfo) { metaInfo += delim + suggestion.moreinfo; } metaInfo = metaInfo.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'") .replace(//g, ">"); // escape HTML newText = newText.substring(0, spanStart) + '' + newText.substring(spanStart, spanEnd) + '' + newText.substring(spanEnd); suggestion.used = true; } } // now insert a span into the location of the original cursor position, // only considering real text content of course: newText = this._insertCursorSpan(newText, cursorPos); newText = newText.replace(/^\n/, ""); newText = newText.replace(/^\n/, ""); newText = newText.replace(/\n/g, "
"); ed.setContent(newText); // now place the cursor where it was: ed.selection.select(ed.dom.select('span#caret_pos_holder')[0]); ed.dom.remove(ed.dom.select('span#caret_pos_holder')[0]); }; AtDCore.prototype._insertCursorSpan = function(text, cursorPos) { var newTextParts = text.split(/([<>])/); var inTag = 0; var textPos = 0; var stringPos = 0; for (var i = 0; i < newTextParts.length; i++) { if (newTextParts[i] == "<" || newTextParts[i] == ">") { if (newTextParts[i] == "<") { inTag++; } else { inTag--; } } else if (inTag == 0) { var partLength = newTextParts[i].length; if (cursorPos >= textPos && cursorPos <= textPos + partLength) { var relativePos = cursorPos - textPos; text = text.insert(stringPos + relativePos, ""); break; } textPos += partLength; } stringPos += newTextParts[i].length; } return text; }; AtDCore.prototype.getSurrogatePart = function(surrogateString, part) { var parts = surrogateString.split(this.surrogateAttributeDelimiter); if (part == 'id') { return parts[0]; } else if (part == 'subid') { return parts[1]; } else if (part == 'description') { return parts[2]; } else if (part == 'suggestions') { return parts[3]; } else if (part == 'coveredtext') { return parts[4]; } else if (part == 'url' && parts.length >= 5) { return parts[5]; } console.log("No part '" + part + "' found in surrogateString: " + surrogateString); return null; }; AtDCore.prototype.getPlainTextWithCursorMarker = function() { return this._getPlainText(false); }; AtDCore.prototype.getPlainText = function() { return this._getPlainText(true); }; AtDCore.prototype._getPlainText = function(removeCursor) { var plainText = tinyMCE.activeEditor.getContent({ format: 'raw' }) .replace(/

/g, "\n\n") .replace(/
/g, "\n") .replace(//g, "\n") .replace(/<.*?>/g, "") .replace(/&/g, "&") .replace(/</g, "<") // TODO: using '>' still gets converted to '>' for the user - with this line the HTML gets messed up when '<' or '>' are used in the text to check: //.replace(/>/g, ">") .replace(/ /g, " "); // see issue #10 if (removeCursor) { plainText = plainText.replace(/\ufeff/g, ""); // feff = 65279 = cursor code } return plainText; }; AtDCore.prototype.removeWords = function(node, w) { var count = 0; var parent = this; this.map(this.findSpans(node).reverse(), function(n) { if (n && (parent.isMarkedNode(n) || parent.hasClass(n, 'mceItemHidden') || parent.isEmptySpan(n)) ) { if (n.innerHTML == ' ') { var nnode = document.createTextNode(' '); /* hax0r */ parent.replaceWith(n, nnode); } else if (!w || n.innerHTML == w) { parent.removeParent(n); count++; } } }); return count; }; AtDCore.prototype.removeWordsByRuleId = function(node, ruleId, coveredText) { var count = 0; var parent = this; this.map(this.findSpans(node).reverse(), function(n) { if (n && (parent.isMarkedNode(n) || parent.hasClass(n, 'mceItemHidden') || parent.isEmptySpan(n)) ) { if (n.innerHTML == ' ') { var nnode = document.createTextNode(' '); /* hax0r */ parent.replaceWith(n, nnode); } else { var surrogate = n.getAttribute(parent.surrogateAttribute); var textIsRelevant = coveredText ? parent.getSurrogatePart(surrogate, 'coveredtext') == coveredText : true; if (textIsRelevant && (surrogate && parent.getSurrogatePart(surrogate, 'id') == ruleId)) { parent.removeParent(n); count++; } } } }); return count; }; AtDCore.prototype.isEmptySpan = function(node) { return (this.getAttrib(node, 'class') == "" && this.getAttrib(node, 'style') == "" && this.getAttrib(node, 'id') == "" && !this.hasClass(node, 'Apple-style-span') && this.getAttrib(node, 'mce_name') == ""); }; AtDCore.prototype.isMarkedNode = function(node) { return (this.hasClass(node, 'hiddenGrammarError') || this.hasClass(node, 'hiddenSpellError') || this.hasClass(node, 'hiddenSuggestion')); }; /* * Context Menu Helpers */ AtDCore.prototype.applySuggestion = function(element, suggestion) { if (suggestion == '(omit)') { this.remove(element); } else { var node = this.create(suggestion); this.replaceWith(element, node); this.removeParent(node); } }; /* * Check for an error */ AtDCore.prototype.hasErrorMessage = function(xmlr) { return (xmlr != undefined && xmlr.getElementsByTagName('message').item(0) != null); }; AtDCore.prototype.getErrorMessage = function(xmlr) { return xmlr.getElementsByTagName('message').item(0); }; /* this should always be an error, alas... not practical */ AtDCore.prototype.isIE = function() { return navigator.appName == 'Microsoft Internet Explorer'; }; /* * TinyMCE Writing Improvement Tool Plugin * Original Author: Raphael Mudge (raffi@automattic.com) * Heavily modified by Daniel Naber for LanguageTool (http://www.languagetool.org) * * http://www.languagetool.org * http://www.afterthedeadline.com * * Distributed under the LGPL * * Derived from: * $Id: editor_plugin_src.js 425 2007-11-21 15:17:39Z spocke $ * * @author Moxiecode * @copyright Copyright (C) 2004-2008, Moxiecode Systems AB, All rights reserved. * * Moxiecode Spell Checker plugin released under the LGPL with TinyMCE */ (function() { var JSONRequest = tinymce.util.JSONRequest, each = tinymce.each, DOM = tinymce.DOM; tinymce.create('tinymce.plugins.AfterTheDeadlinePlugin', { getInfo : function() { return ({ longname : 'After The Deadline / LanguageTool', author : 'Raphael Mudge, Daniel Naber', authorurl : 'http://blog.afterthedeadline.com', infourl : 'http://www.afterthedeadline.com', version : tinymce.majorVersion + "." + tinymce.minorVersion }); }, /* initializes the functions used by the AtD Core UI Module */ initAtDCore : function(editor, plugin) { var core = new AtDCore(); core.map = each; core.getAttrib = function(node, key) { return editor.dom.getAttrib(node, key); }; core.findSpans = function(parent) { if (parent == undefined) return editor.dom.select('span'); else return editor.dom.select('span', parent); }; core.hasClass = function(node, className) { return editor.dom.hasClass(node, className); }; core.contents = function(node) { return node.childNodes; }; core.replaceWith = function(old_node, new_node) { return editor.dom.replace(new_node, old_node); }; core.create = function(node_html) { return editor.dom.create('span', { 'class': 'mceItemHidden' }, node_html); }; core.removeParent = function(node) { editor.dom.remove(node, 1); return node; }; core.remove = function(node) { editor.dom.remove(node); }; core.getLang = function(key, defaultk) { return editor.getLang("AtD." + key, defaultk); }; return core; }, /* called when the plugin is initialized */ init : function(ed, url) { var t = this; var plugin = this; var editor = ed; var core = this.initAtDCore(editor, plugin); this.url = url; this.editor = ed; this.menuVisible = false; ed.core = core; /* add a command to request a document check and process the results. */ editor.addCommand('mceWritingImprovementTool', function(languageCode) { if (plugin.menuVisible) { plugin._menu.hideMenu(); } /* checks if a global var for click stats exists and increments it if it does... */ if (typeof AtD_proofread_click_count != "undefined") AtD_proofread_click_count++; /* create the nifty spinny thing that says "hizzo, I'm doing something fo realz" */ plugin.editor.setProgressState(1); /* remove the previous errors */ plugin._removeWords(); /* send request to our service */ var textContent = plugin.editor.core.getPlainText(); plugin.sendRequest('', textContent, languageCode, function(data, request, jqXHR) { /* turn off the spinning thingie */ plugin.editor.setProgressState(0); document.checkform._action_checkText.disabled = false; if (jqXHR.responseText.substr(0, 5) !== 'Error: Did not get XML response from service. Please try again in one minute."); return; } $('#feedbackErrorMessage').html(""); // no severe errors, so clear that error area var results = core.processXML(jqXHR.responseXML); if (languageCode === "auto") { var detectedLang = core.getDetectedLanguageFromXML(jqXHR.responseXML); /*var langDiv = $("#lang"); langDiv.find('option[value="auto"]').remove(); langDiv.prepend($("

').text(val.corrections[0]).html(); ruleHtml += " "; ruleHtml += val.sentence.replace(/(.*?)<\/marker>/, escapedCorr) + "
"; } ruleHtml += ""; ruleHtml += "
"; exampleCount++; } }); if (exampleCount === 0) { ruleHtml += "

" + noRuleExamples + "

"; t._trackEvent('ShowExamples', 'NoExamples', errorDescription["id"]); } ruleHtml += "

" + ruleImplementation + "

"; var $dialog = $("#dialog"); $dialog.html(ruleHtml); $dialog.dialog("open"); }).fail(function(e) { var $dialog = $("#dialog"); $dialog.html("Sorry, could not get rules. Server returned error code " + e.status + "."); $dialog.dialog("open"); t._trackEvent('ShowExamples', 'ServerError'); }).always(function() { plugin.editor.setProgressState(0); t._trackEvent('ShowExamples', 'ShowExampleSentences'); }); } }); } /* show the menu please */ ed.selection.select(e.target); p1 = dom.getPos(e.target); var xPos = p1.x; // moves popup a bit down to not overlap text: //TODO: why is this needed? why does the text (tinyMCE content) have a slightly lower start position in Firefox? var posWorkaround = 0; if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) { posWorkaround = 10; } else { posWorkaround = 2; } m.showMenu(xPos, p1.y + e.target.offsetHeight - vp.y + posWorkaround); this.menuVisible = true; var menuDiv = $('#menu_checktext_spellcheckermenu_co'); if (menuDiv) { var menuWidth = menuDiv.width(); var textBoxWidth = $('#checktextpara').width(); // not sure why we cannot directly use the textarea's width if (xPos + menuWidth > textBoxWidth) { // menu runs out of screen, move it to the left var diff = xPos + menuWidth - textBoxWidth; menuDiv.css({ left: '-' + diff + 'px' }); } else { menuDiv.css({ left: '0px' }); } } return tinymce.dom.Event.cancel(e); } else { m.hideMenu(); this.menuVisible = false; } }, /* loop through editor DOM, call _done if no mce tags exist. */ _checkDone : function() { var t = this, ed = t.editor, dom = ed.dom, o; this.menuVisible = false; each(dom.select('span'), function(n) { if (n && dom.hasClass(n, 'mceItemHidden')) { o = true; return false; } }); if (!o) { t._done(); } }, /* remove all tags, hide the menu, and fire a dom change event */ _done : function() { var plugin = this; //plugin._removeWords(); if (plugin._menu) { plugin._menu.hideMenu(); this.menuVisible = false; } plugin.editor.nodeChanged(); }, sendRequest : function(file, data, languageCode, success) { var url = this.editor.getParam("languagetool_rpc_url", "{backend}"); var plugin = this; if (url == '{backend}') { this.editor.setProgressState(0); document.checkform._action_checkText.disabled = false; alert('Please specify: languagetool_rpc_url'); return; } var langParam = ""; if (languageCode === "auto") { langParam = "&autodetect=1"; } else { langParam = "&language=" + encodeURI(languageCode); } var t = this; // There's a bug somewhere in AtDCore.prototype.markMyWords which makes // multiple spaces vanish - thus disable that rule to avoid confusion: var postData = "disabled=WHITESPACE_RULE&text=" + encodeURI(data).replace(/&/g, '%26').replace(/\+/g, '%2B') + "&language=" + langParam; jQuery.ajax({ url: url, type: "POST", data: postData, success: success, error: function(jqXHR, textStatus, errorThrown) { // try again t._serverLog("Error on first try, trying again..."); setTimeout(function() { jQuery.ajax({ url: url, type: "POST", data: postData, success: success, error: function(jqXHR, textStatus, errorThrown) { plugin.editor.setProgressState(0); document.checkform._action_checkText.disabled = false; var errorText = jqXHR.responseText; if (!errorText) { errorText = "Error: Did not get response from service. Please try again in one minute."; } $('#feedbackErrorMessage').html("
" + errorText + "
"); t._trackEvent('CheckError', 'ErrorWithException', errorText); t._serverLog(errorText + " (second try)"); } }); }, 500); } }); /* this causes an OPTIONS request to be send as a preflight - LT server doesn't support that, thus we're using jQuery.ajax() instead tinymce.util.XHR.send({ url : url, content_type : 'text/xml', type : "POST", data : "text=" + encodeURI(data).replace(/&/g, '%26').replace(/\+/g, '%2B') + langParam // there's a bug somewhere in AtDCore.prototype.markMyWords which makes // multiple spaces vanish - thus disable that rule to avoid confusion: + "&disabled=WHITESPACE_RULE", async : true, success : success, error : function( type, req, o ) { plugin.editor.setProgressState(0); document.checkform._action_checkText.disabled = false; var errorMessage = "
Error: Could not send request to\n" + o.url + "\nError: " + type + "\nStatus code: " + req.status + "\nPlease make sure your network connection works.
"; $('#feedbackErrorMessage').html(errorMessage); } });*/ } }); // Register plugin tinymce.PluginManager.add('AtD', tinymce.plugins.AfterTheDeadlinePlugin); })();