640 lines
18 KiB
JavaScript
640 lines
18 KiB
JavaScript
/*
|
|
* atd.core.js - A building block to create a front-end for AtD
|
|
* Author : Raphael Mudge, Automattic
|
|
* License : LGPL
|
|
* Project : http://www.afterthedeadline.com/developers.slp
|
|
* Contact : raffi@automattic.com
|
|
*/
|
|
|
|
/* jshint sub: true, devel: true, onevar: false, smarttabs: true */
|
|
/* exported EXPORTED_SYMBOLS, atd_sprintf */
|
|
|
|
/* EXPORTED_SYMBOLS is set so this file can be a JavaScript Module */
|
|
var EXPORTED_SYMBOLS = ['AtDCore'];
|
|
|
|
function AtDCore() {
|
|
/* these are the categories of errors AtD should ignore */
|
|
this.ignore_types = ['Bias Language', 'Cliches', 'Complex Expression', 'Diacritical Marks', 'Double Negatives', 'Hidden Verbs', 'Jargon Language', 'Passive voice', 'Phrases to Avoid', 'Redundant Expression'];
|
|
|
|
/* these are the phrases AtD should ignore */
|
|
this.ignore_strings = {};
|
|
|
|
/* Localized strings */
|
|
// Back-compat, not used
|
|
this.i18n = {};
|
|
}
|
|
|
|
/*
|
|
* Internationalization Functions
|
|
*/
|
|
|
|
AtDCore.prototype.getLang = function( key, defaultk ) {
|
|
return ( window.AtD_l10n_r0ar && window.AtD_l10n_r0ar[key] ) || defaultk;
|
|
};
|
|
|
|
AtDCore.prototype.addI18n = function( obj ) {
|
|
// Back-compat
|
|
window.AtD_l10n_r0ar = obj;
|
|
};
|
|
|
|
/*
|
|
* Setters
|
|
*/
|
|
|
|
AtDCore.prototype.setIgnoreStrings = function(string) {
|
|
var parent = this;
|
|
|
|
this.map(string.split(/,\s*/g), function(string) {
|
|
parent.ignore_strings[string] = 1;
|
|
});
|
|
};
|
|
|
|
AtDCore.prototype.showTypes = function(string) {
|
|
var show_types = string.split(/,\s*/g);
|
|
var types = {};
|
|
|
|
/* set some default types that we want to make optional */
|
|
|
|
/* grammar checker options */
|
|
types['Double Negatives'] = 1;
|
|
types['Hidden Verbs'] = 1;
|
|
types['Passive voice'] = 1;
|
|
types['Bias Language'] = 1;
|
|
|
|
/* style checker options */
|
|
types['Cliches'] = 1;
|
|
types['Complex Expression'] = 1;
|
|
types['Diacritical Marks'] = 1;
|
|
types['Jargon Language'] = 1;
|
|
types['Phrases to Avoid'] = 1;
|
|
types['Redundant Expression'] = 1;
|
|
|
|
var ignore_types = [];
|
|
|
|
this.map(show_types, function(string) {
|
|
types[string] = undefined;
|
|
});
|
|
|
|
this.map(this.ignore_types, function(string) {
|
|
if (types[string] !== undefined) {
|
|
ignore_types.push(string);
|
|
}
|
|
});
|
|
|
|
this.ignore_types = ignore_types;
|
|
};
|
|
|
|
/*
|
|
* Error Parsing Code
|
|
*/
|
|
|
|
AtDCore.prototype.makeError = function(error_s, tokens, type, seps/*, pre*/) {
|
|
var struct = {};
|
|
struct.type = type;
|
|
struct.string = error_s;
|
|
struct.tokens = tokens;
|
|
|
|
if (new RegExp('\\b' + error_s + '\\b').test(error_s)) {
|
|
struct.regexp = new RegExp('(?!'+error_s+'<)\\b' + error_s.replace(/\s+/g, seps) + '\\b');
|
|
}
|
|
else if (new RegExp(error_s + '\\b').test(error_s)) {
|
|
struct.regexp = new RegExp('(?!'+error_s+'<)' + error_s.replace(/\s+/g, seps) + '\\b');
|
|
}
|
|
else if (new RegExp('\\b' + error_s).test(error_s)) {
|
|
struct.regexp = new RegExp('(?!'+error_s+'<)\\b' + error_s.replace(/\s+/g, seps));
|
|
}
|
|
else {
|
|
struct.regexp = new RegExp('(?!'+error_s+'<)' + error_s.replace(/\s+/g, seps));
|
|
}
|
|
|
|
struct.used = false; /* flag whether we've used this rule or not */
|
|
|
|
return struct;
|
|
};
|
|
|
|
AtDCore.prototype.addToErrorStructure = function(errors, list, type, seps) {
|
|
var parent = this;
|
|
|
|
this.map(list, function(error) {
|
|
var tokens = error['word'].split(/\s+/);
|
|
var pre = error['pre'];
|
|
var first = tokens[0];
|
|
|
|
if (errors['__' + first] === undefined) {
|
|
errors['__' + first] = {};
|
|
errors['__' + first].pretoks = {};
|
|
errors['__' + first].defaults = [];
|
|
}
|
|
|
|
if (pre === '') {
|
|
errors['__' + first].defaults.push(parent.makeError(error['word'], tokens, type, seps, pre));
|
|
} else {
|
|
if (errors['__' + first].pretoks['__' + pre] === undefined) {
|
|
errors['__' + first].pretoks['__' + pre] = [];
|
|
}
|
|
|
|
errors['__' + first].pretoks['__' + pre].push(parent.makeError(error['word'], tokens, type, seps, pre));
|
|
}
|
|
});
|
|
};
|
|
|
|
AtDCore.prototype.buildErrorStructure = function(spellingList, enrichmentList, grammarList) {
|
|
var seps = this._getSeparators();
|
|
var errors = {};
|
|
|
|
this.addToErrorStructure(errors, spellingList, 'hiddenSpellError', seps);
|
|
this.addToErrorStructure(errors, grammarList, 'hiddenGrammarError', seps);
|
|
this.addToErrorStructure(errors, enrichmentList, 'hiddenSuggestion', seps);
|
|
return errors;
|
|
};
|
|
|
|
AtDCore.prototype._getSeparators = function() {
|
|
var re = '', i;
|
|
var str = '"s!#$%&()*+,./:;<=>?@[\\]^_{|}';
|
|
|
|
// Build word separator regexp
|
|
for (i=0; i<str.length; i++) {
|
|
re += '\\' + str.charAt(i);
|
|
}
|
|
|
|
return '(?:(?:[\xa0' + re + '])|(?:\\-\\-))+';
|
|
};
|
|
|
|
AtDCore.prototype.processXML = function(responseXML) {
|
|
|
|
/* types of errors to ignore */
|
|
var types = {};
|
|
|
|
this.map(this.ignore_types, function(type) {
|
|
types[type] = 1;
|
|
});
|
|
|
|
/* save suggestions in the editor object */
|
|
this.suggestions = [];
|
|
|
|
/* process through the errors */
|
|
var errors = responseXML.getElementsByTagName('error');
|
|
|
|
/* words to mark */
|
|
var grammarErrors = [];
|
|
var spellingErrors = [];
|
|
var enrichment = [];
|
|
|
|
for (var i = 0; i < errors.length; i++) {
|
|
if (errors[i].getElementsByTagName('string').item(0).firstChild !== null) {
|
|
var errorString = errors[i].getElementsByTagName('string').item(0).firstChild.data;
|
|
var errorType = errors[i].getElementsByTagName('type').item(0).firstChild.data;
|
|
var errorDescription = errors[i].getElementsByTagName('description').item(0).firstChild.data;
|
|
|
|
var errorContext;
|
|
|
|
if (errors[i].getElementsByTagName('precontext').item(0).firstChild !== null) {
|
|
errorContext = errors[i].getElementsByTagName('precontext').item(0).firstChild.data;
|
|
} else {
|
|
errorContext = '';
|
|
}
|
|
|
|
/* create a hashtable with information about the error in the editor object, we will use this later
|
|
to populate a popup menu with information and suggestions about the error */
|
|
|
|
if (this.ignore_strings[errorString] === undefined) {
|
|
var suggestion = {};
|
|
suggestion['description'] = errorDescription;
|
|
suggestion['suggestions'] = [];
|
|
|
|
/* used to find suggestions when a highlighted error is clicked on */
|
|
suggestion['matcher'] = new RegExp('^' + errorString.replace(/\s+/, this._getSeparators()) + '$');
|
|
|
|
suggestion['context'] = errorContext;
|
|
suggestion['string'] = errorString;
|
|
suggestion['type'] = errorType;
|
|
|
|
this.suggestions.push(suggestion);
|
|
|
|
if (errors[i].getElementsByTagName('suggestions').item(0) !== null) {
|
|
var suggestions = errors[i].getElementsByTagName('suggestions').item(0).getElementsByTagName('option');
|
|
for (var j = 0; j < suggestions.length; j++) {
|
|
suggestion['suggestions'].push(suggestions[j].firstChild.data);
|
|
}
|
|
}
|
|
|
|
/* setup the more info url */
|
|
if (errors[i].getElementsByTagName('url').item(0) !== null) {
|
|
var errorUrl = errors[i].getElementsByTagName('url').item(0).firstChild.data;
|
|
suggestion['moreinfo'] = errorUrl + '&theme=tinymce';
|
|
}
|
|
|
|
if (types[errorDescription] === undefined) {
|
|
if (errorType === 'suggestion') {
|
|
enrichment.push({ word: errorString, pre: errorContext });
|
|
}
|
|
|
|
if (errorType === 'grammar') {
|
|
grammarErrors.push({ word: errorString, pre: errorContext });
|
|
}
|
|
}
|
|
|
|
if (errorType === 'spelling' || errorDescription === 'Homophone') {
|
|
spellingErrors.push({ word: errorString, pre: errorContext });
|
|
}
|
|
|
|
if (errorDescription === 'Cliches') {
|
|
suggestion['description'] = 'Clichés'; /* done here for backwards compatability with current user settings */
|
|
}
|
|
|
|
if (errorDescription === 'Spelling') {
|
|
suggestion['description'] = this.getLang('menu_title_spelling', 'Spelling');
|
|
}
|
|
|
|
if (errorDescription === 'Repeated Word') {
|
|
suggestion['description'] = this.getLang('menu_title_repeated_word', 'Repeated Word');
|
|
}
|
|
|
|
if (errorDescription === 'Did you mean...') {
|
|
suggestion['description'] = this.getLang('menu_title_confused_word', 'Did you mean...');
|
|
}
|
|
} // end if ignore[errorString] == undefined
|
|
} // end if
|
|
} // end for loop
|
|
|
|
var errorStruct;
|
|
var ecount = spellingErrors.length + grammarErrors.length + enrichment.length;
|
|
|
|
if (ecount > 0) {
|
|
errorStruct = this.buildErrorStructure(spellingErrors, enrichment, grammarErrors);
|
|
} else {
|
|
errorStruct = undefined;
|
|
}
|
|
|
|
/* save some state in this object, for retrieving suggestions later */
|
|
return { errors: errorStruct, count: ecount, suggestions: this.suggestions };
|
|
};
|
|
|
|
AtDCore.prototype.findSuggestion = function(element) {
|
|
var text = element.innerHTML;
|
|
var context = ( this.getAttrib(element, 'pre') + '' ).replace(/[\\,!\\?\\."\s]/g, '');
|
|
if (this.getAttrib(element, 'pre') === undefined) {
|
|
alert(element.innerHTML);
|
|
}
|
|
|
|
var errorDescription;
|
|
var len = this.suggestions.length;
|
|
|
|
for (var i = 0; i < len; i++) {
|
|
if ((context === '' || context === this.suggestions[i]['context']) && this.suggestions[i]['matcher'].test(text)) {
|
|
errorDescription = this.suggestions[i];
|
|
break;
|
|
}
|
|
}
|
|
return errorDescription;
|
|
};
|
|
|
|
/*
|
|
* TokenIterator class
|
|
*/
|
|
|
|
function TokenIterator(tokens) {
|
|
this.tokens = tokens;
|
|
this.index = 0;
|
|
this.count = 0;
|
|
this.last = 0;
|
|
}
|
|
|
|
TokenIterator.prototype.next = function() {
|
|
var current = this.tokens[this.index];
|
|
this.count = this.last;
|
|
this.last += current.length + 1;
|
|
this.index++;
|
|
|
|
/* strip single quotes from token, AtD does this when presenting errors */
|
|
if (current !== '') {
|
|
if (current[0] === '\'') {
|
|
current = current.substring(1, current.length);
|
|
}
|
|
|
|
if (current[current.length - 1] === '\'') {
|
|
current = current.substring(0, current.length - 1);
|
|
}
|
|
}
|
|
|
|
return current;
|
|
};
|
|
|
|
TokenIterator.prototype.hasNext = function() {
|
|
return this.index < this.tokens.length;
|
|
};
|
|
|
|
TokenIterator.prototype.hasNextN = function(n) {
|
|
return (this.index + n) < this.tokens.length;
|
|
};
|
|
|
|
TokenIterator.prototype.skip = function(m, n) {
|
|
this.index += m;
|
|
this.last += n;
|
|
|
|
if (this.index < this.tokens.length) {
|
|
this.count = this.last - this.tokens[this.index].length;
|
|
}
|
|
};
|
|
|
|
TokenIterator.prototype.getCount = function() {
|
|
return this.count;
|
|
};
|
|
|
|
TokenIterator.prototype.peek = function(n) {
|
|
var peepers = [];
|
|
var end = this.index + n;
|
|
for (var x = this.index; x < end; x++) {
|
|
peepers.push(this.tokens[x]);
|
|
}
|
|
return peepers;
|
|
};
|
|
|
|
/*
|
|
* code to manage highlighting of errors
|
|
*/
|
|
AtDCore.prototype.markMyWords = function(container_nodes, errors) {
|
|
var seps = new RegExp(this._getSeparators()),
|
|
nl = [],
|
|
ecount = 0, /* track number of highlighted errors */
|
|
parent = this,
|
|
bogus = this._isTinyMCE ? ' data-mce-bogus="1"' : '',
|
|
emptySpan = '<span class="mceItemHidden"' + bogus + '> </span>',
|
|
textOnlyMode;
|
|
|
|
/**
|
|
* Split a text node into an ordered list of siblings:
|
|
* - text node to the left of the match
|
|
* - the element replacing the match
|
|
* - text node to the right of the match
|
|
*
|
|
* We have to leave the text to the left and right of the match alone
|
|
* in order to prevent XSS
|
|
*
|
|
* @return array
|
|
*/
|
|
function splitTextNode( textnode, regexp, replacement ) {
|
|
var text = textnode.nodeValue,
|
|
index = text.search( regexp ),
|
|
match = text.match( regexp ),
|
|
captured = [],
|
|
cursor;
|
|
|
|
if ( index < 0 || ! match.length ) {
|
|
return [ textnode ];
|
|
}
|
|
|
|
if ( index > 0 ) {
|
|
// capture left text node
|
|
captured.push( document.createTextNode( text.substr( 0, index ) ) );
|
|
}
|
|
|
|
// capture the replacement of the matched string
|
|
captured.push( parent.create( match[0].replace( regexp, replacement ) ) );
|
|
|
|
cursor = index + match[0].length;
|
|
|
|
if ( cursor < text.length ) {
|
|
// capture right text node
|
|
captured.push( document.createTextNode( text.substr( cursor ) ) );
|
|
}
|
|
|
|
return captured;
|
|
}
|
|
|
|
function _isInPre( node ) {
|
|
if ( node ) {
|
|
while ( node.parentNode ) {
|
|
if ( node.nodeName === 'PRE' ) {
|
|
return true;
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/* Collect all text nodes */
|
|
/* Our goal--ignore nodes that are already wrapped */
|
|
|
|
this._walk( container_nodes, function( n ) {
|
|
if ( n.nodeType === 3 && ! parent.isMarkedNode( n ) && ! _isInPre( n ) ) {
|
|
nl.push( n );
|
|
}
|
|
});
|
|
|
|
/* walk through the relevant nodes */
|
|
|
|
var iterator;
|
|
|
|
this.map( nl, function( n ) {
|
|
var v;
|
|
|
|
if ( n.nodeType === 3 ) {
|
|
v = n.nodeValue; /* we don't want to mangle the HTML so use the actual encoded string */
|
|
var tokens = n.nodeValue.split( seps ); /* split on the unencoded string so we get access to quotes as " */
|
|
var previous = '';
|
|
|
|
var doReplaces = [];
|
|
|
|
iterator = new TokenIterator(tokens);
|
|
|
|
while ( iterator.hasNext() ) {
|
|
var token = iterator.next();
|
|
var current = errors['__' + token];
|
|
|
|
var defaults;
|
|
|
|
if ( current !== undefined && current.pretoks !== undefined ) {
|
|
defaults = current.defaults;
|
|
current = current.pretoks['__' + previous];
|
|
|
|
var done = false;
|
|
var prev, curr;
|
|
|
|
prev = v.substr(0, iterator.getCount());
|
|
curr = v.substr(prev.length, v.length);
|
|
|
|
var checkErrors = function( error ) {
|
|
if ( error !== undefined && ! error.used && foundStrings[ '__' + error.string ] === undefined && error.regexp.test( curr ) ) {
|
|
foundStrings[ '__' + error.string ] = 1;
|
|
doReplaces.push([ error.regexp, '<span class="'+error.type+'" pre="'+previous+'"' + bogus + '>$&</span>' ]);
|
|
|
|
error.used = true;
|
|
done = true;
|
|
}
|
|
}; // jshint ignore:line
|
|
|
|
var foundStrings = {};
|
|
|
|
if (current !== undefined) {
|
|
previous = previous + ' ';
|
|
parent.map(current, checkErrors);
|
|
}
|
|
|
|
if (!done) {
|
|
previous = '';
|
|
parent.map(defaults, checkErrors);
|
|
}
|
|
}
|
|
|
|
previous = token;
|
|
} // end while
|
|
|
|
/* do the actual replacements on this span */
|
|
if ( doReplaces.length > 0 ) {
|
|
var newNode = n;
|
|
|
|
for ( var x = 0; x < doReplaces.length; x++ ) {
|
|
var regexp = doReplaces[x][0], result = doReplaces[x][1];
|
|
|
|
/* it's assumed that this function is only being called on text nodes (nodeType == 3), the iterating is necessary
|
|
because eventually the whole thing gets wrapped in an mceItemHidden span and from there it's necessary to
|
|
handle each node individually. */
|
|
var bringTheHurt = function( node ) {
|
|
var span, splitNodes;
|
|
|
|
if ( node.nodeType === 3 ) {
|
|
ecount++;
|
|
|
|
/* sometimes IE likes to ignore the space between two spans, solution is to insert a placeholder span with
|
|
a non-breaking space. The markup removal code substitutes this span for a space later */
|
|
if ( parent.isIE() && node.nodeValue.length > 0 && node.nodeValue.substr(0, 1) === ' ' ) {
|
|
return parent.create( emptySpan + node.nodeValue.substr( 1, node.nodeValue.length - 1 ).replace( regexp, result ), false );
|
|
} else {
|
|
if ( textOnlyMode ) {
|
|
return parent.create( node.nodeValue.replace( regexp, result ), false );
|
|
}
|
|
|
|
span = parent.create( '<span />' );
|
|
if ( typeof textOnlyMode === 'undefined' ) {
|
|
// cache this to avoid adding / removing nodes unnecessarily
|
|
textOnlyMode = typeof span.appendChild !== 'function';
|
|
if ( textOnlyMode ) {
|
|
parent.remove( span );
|
|
return parent.create( node.nodeValue.replace( regexp, result ), false );
|
|
}
|
|
}
|
|
|
|
// "Visual" mode
|
|
splitNodes = splitTextNode( node, regexp, result );
|
|
for ( var i = 0; i < splitNodes.length; i++ ) {
|
|
span.appendChild( splitNodes[i] );
|
|
}
|
|
|
|
node = span;
|
|
return node;
|
|
}
|
|
}
|
|
else {
|
|
var contents = parent.contents(node);
|
|
|
|
for ( var y = 0; y < contents.length; y++ ) {
|
|
if ( contents[y].nodeType === 3 && regexp.test( contents[y].nodeValue ) ) {
|
|
var nnode;
|
|
|
|
if ( parent.isIE() && contents[y].nodeValue.length > 0 && contents[y].nodeValue.substr(0, 1) === ' ') {
|
|
nnode = parent.create( emptySpan + contents[y].nodeValue.substr( 1, contents[y].nodeValue.length - 1 ).replace( regexp, result ), true );
|
|
} else {
|
|
nnode = parent.create( contents[y].nodeValue.replace( regexp, result ), true );
|
|
}
|
|
|
|
parent.replaceWith( contents[y], nnode );
|
|
parent.removeParent( nnode );
|
|
|
|
ecount++;
|
|
|
|
return node; /* we did a replacement so we can call it quits, errors only get used once */
|
|
}
|
|
}
|
|
|
|
return node;
|
|
}
|
|
}; // jshint ignore:line
|
|
|
|
newNode = bringTheHurt(newNode);
|
|
}
|
|
|
|
parent.replaceWith(n, newNode);
|
|
}
|
|
}
|
|
});
|
|
|
|
return ecount;
|
|
};
|
|
|
|
AtDCore.prototype._walk = function(elements, f) {
|
|
var i;
|
|
for (i = 0; i < elements.length; i++) {
|
|
f.call(f, elements[i]);
|
|
this._walk(this.contents(elements[i]), f);
|
|
}
|
|
};
|
|
|
|
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.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';
|
|
};
|
|
|
|
// TODO: this doesn't seem used anywhere in AtD, moved here from install_atd_l10n.js for eventual back-compat
|
|
/* a quick poor man's sprintf */
|
|
function atd_sprintf(format, values) {
|
|
var result = format;
|
|
for (var x = 0; x < values.length; x++) {
|
|
result = result.replace(new RegExp('%' + (x + 1) + '\\$', 'g'), values[x]);
|
|
}
|
|
return result;
|
|
}
|