UNPKG

accessibility-developer-tools

Version:

This is a library of accessibility-related testing and utility code.

1,324 lines (1,181 loc) 66.4 kB
// Copyright 2006 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview Functions to style text. * * @author nicksantos@google.com (Nick Santos) */ goog.provide('goog.editor.plugins.BasicTextFormatter'); goog.provide('goog.editor.plugins.BasicTextFormatter.COMMAND'); goog.require('goog.array'); goog.require('goog.dom'); goog.require('goog.dom.NodeType'); goog.require('goog.dom.Range'); goog.require('goog.dom.TagName'); goog.require('goog.editor.BrowserFeature'); goog.require('goog.editor.Command'); goog.require('goog.editor.Link'); goog.require('goog.editor.Plugin'); goog.require('goog.editor.node'); goog.require('goog.editor.range'); goog.require('goog.editor.style'); goog.require('goog.iter'); goog.require('goog.iter.StopIteration'); goog.require('goog.log'); goog.require('goog.object'); goog.require('goog.string'); goog.require('goog.string.Unicode'); goog.require('goog.style'); goog.require('goog.ui.editor.messages'); goog.require('goog.userAgent'); /** * Functions to style text (e.g. underline, make bold, etc.) * @constructor * @extends {goog.editor.Plugin} */ goog.editor.plugins.BasicTextFormatter = function() { goog.editor.Plugin.call(this); }; goog.inherits(goog.editor.plugins.BasicTextFormatter, goog.editor.Plugin); /** @override */ goog.editor.plugins.BasicTextFormatter.prototype.getTrogClassId = function() { return 'BTF'; }; /** * Logging object. * @type {goog.log.Logger} * @protected * @override */ goog.editor.plugins.BasicTextFormatter.prototype.logger = goog.log.getLogger('goog.editor.plugins.BasicTextFormatter'); /** * Commands implemented by this plugin. * @enum {string} */ goog.editor.plugins.BasicTextFormatter.COMMAND = { LINK: '+link', CREATE_LINK: '+createLink', FORMAT_BLOCK: '+formatBlock', INDENT: '+indent', OUTDENT: '+outdent', STRIKE_THROUGH: '+strikeThrough', HORIZONTAL_RULE: '+insertHorizontalRule', SUBSCRIPT: '+subscript', SUPERSCRIPT: '+superscript', UNDERLINE: '+underline', BOLD: '+bold', ITALIC: '+italic', FONT_SIZE: '+fontSize', FONT_FACE: '+fontName', FONT_COLOR: '+foreColor', BACKGROUND_COLOR: '+backColor', ORDERED_LIST: '+insertOrderedList', UNORDERED_LIST: '+insertUnorderedList', JUSTIFY_CENTER: '+justifyCenter', JUSTIFY_FULL: '+justifyFull', JUSTIFY_RIGHT: '+justifyRight', JUSTIFY_LEFT: '+justifyLeft' }; /** * Inverse map of execCommand strings to * {@link goog.editor.plugins.BasicTextFormatter.COMMAND} constants. Used to * determine whether a string corresponds to a command this plugin * handles in O(1) time. * @type {Object} * @private */ goog.editor.plugins.BasicTextFormatter.SUPPORTED_COMMANDS_ = goog.object.transpose(goog.editor.plugins.BasicTextFormatter.COMMAND); /** * Whether the string corresponds to a command this plugin handles. * @param {string} command Command string to check. * @return {boolean} Whether the string corresponds to a command * this plugin handles. * @override */ goog.editor.plugins.BasicTextFormatter.prototype.isSupportedCommand = function( command) { // TODO(user): restore this to simple check once table editing // is moved out into its own plugin return command in goog.editor.plugins.BasicTextFormatter.SUPPORTED_COMMANDS_; }; /** * Array of execCommand strings which should be silent. * @type {!Array<goog.editor.plugins.BasicTextFormatter.COMMAND>} * @private */ goog.editor.plugins.BasicTextFormatter.SILENT_COMMANDS_ = [goog.editor.plugins.BasicTextFormatter.COMMAND.CREATE_LINK]; /** * Whether the string corresponds to a command that should be silent. * @override */ goog.editor.plugins.BasicTextFormatter.prototype.isSilentCommand = function( command) { return goog.array.contains( goog.editor.plugins.BasicTextFormatter.SILENT_COMMANDS_, command); }; /** * @return {goog.dom.AbstractRange} The closure range object that wraps the * current user selection. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.getRange_ = function() { return this.getFieldObject().getRange(); }; /** * @return {!Document} The document object associated with the currently active * field. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.getDocument_ = function() { return this.getFieldDomHelper().getDocument(); }; /** * Execute a user-initiated command. * @param {string} command Command to execute. * @param {...*} var_args For color commands, this * should be the hex color (with the #). For FORMAT_BLOCK, this should be * the goog.editor.plugins.BasicTextFormatter.BLOCK_COMMAND. * It will be unused for other commands. * @return {Object|undefined} The result of the command. * @override */ goog.editor.plugins.BasicTextFormatter.prototype.execCommandInternal = function( command, var_args) { var preserveDir, styleWithCss, needsFormatBlockDiv, hasDummySelection; var result; var opt_arg = arguments[1]; switch (command) { case goog.editor.plugins.BasicTextFormatter.COMMAND.BACKGROUND_COLOR: // Don't bother for no color selected, color picker is resetting itself. if (!goog.isNull(opt_arg)) { if (goog.editor.BrowserFeature.EATS_EMPTY_BACKGROUND_COLOR) { this.applyBgColorManually_(opt_arg); } else if (goog.userAgent.OPERA) { // backColor will color the block level element instead of // the selected span of text in Opera. this.execCommandHelper_('hiliteColor', opt_arg); } else { this.execCommandHelper_(command, opt_arg); } } break; case goog.editor.plugins.BasicTextFormatter.COMMAND.CREATE_LINK: result = this.createLink_(arguments[1], arguments[2], arguments[3]); break; case goog.editor.plugins.BasicTextFormatter.COMMAND.LINK: result = this.toggleLink_(opt_arg); break; case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_CENTER: case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_FULL: case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_RIGHT: case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_LEFT: this.justify_(command); break; default: if (goog.userAgent.IE && command == goog.editor.plugins.BasicTextFormatter.COMMAND.FORMAT_BLOCK && opt_arg) { // IE requires that the argument be in the form of an opening // tag, like <h1>, including angle brackets. WebKit will accept // the arguemnt with or without brackets, and Firefox pre-3 supports // only a fixed subset of tags with brackets, and prefers without. // So we only add them IE only. opt_arg = '<' + opt_arg + '>'; } if (command == goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_COLOR && goog.isNull(opt_arg)) { // If we don't have a color, then FONT_COLOR is a no-op. break; } switch (command) { case goog.editor.plugins.BasicTextFormatter.COMMAND.INDENT: case goog.editor.plugins.BasicTextFormatter.COMMAND.OUTDENT: if (goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS) { if (goog.userAgent.GECKO) { styleWithCss = true; } if (goog.userAgent.OPERA) { if (command == goog.editor.plugins.BasicTextFormatter.COMMAND.OUTDENT) { // styleWithCSS actually sets negative margins on <blockquote> // to outdent them. If the command is enabled without // styleWithCSS flipped on, then the caret is in a blockquote so // styleWithCSS must not be used. But if the command is not // enabled, styleWithCSS should be used so that elements such as // a <div> with a margin-left style can still be outdented. // (Opera bug: CORE-21118) styleWithCss = !this.getDocument_().queryCommandEnabled('outdent'); } else { // Always use styleWithCSS for indenting. Otherwise, Opera will // make separate <blockquote>s around *each* indented line, // which adds big default <blockquote> margins between each // indented line. styleWithCss = true; } } } // Fall through. case goog.editor.plugins.BasicTextFormatter.COMMAND.ORDERED_LIST: case goog.editor.plugins.BasicTextFormatter.COMMAND.UNORDERED_LIST: if (goog.editor.BrowserFeature.LEAVES_P_WHEN_REMOVING_LISTS && this.queryCommandStateInternal_(this.getDocument_(), command)) { // IE leaves behind P tags when unapplying lists. // If we're not in P-mode, then we want divs // So, unlistify, then convert the Ps into divs. needsFormatBlockDiv = this.getFieldObject().queryCommandValue( goog.editor.Command.DEFAULT_TAG) != goog.dom.TagName.P; } else if (!goog.editor.BrowserFeature.CAN_LISTIFY_BR) { // IE doesn't convert BRed line breaks into separate list items. // So convert the BRs to divs, then do the listify. this.convertBreaksToDivs_(); } // This fix only works in Gecko. if (goog.userAgent.GECKO && goog.editor.BrowserFeature.FORGETS_FORMATTING_WHEN_LISTIFYING && !this.queryCommandValue(command)) { hasDummySelection |= this.beforeInsertListGecko_(); } // Fall through to preserveDir block case goog.editor.plugins.BasicTextFormatter.COMMAND.FORMAT_BLOCK: // Both FF & IE may lose directionality info. Save/restore it. // TODO(user): Does Safari also need this? // TODO (gmark, jparent): This isn't ideal because it uses a string // literal, so if the plugin name changes, it would break. We need a // better solution. See also other places in code that use // this.getPluginByClassId('Bidi'). preserveDir = !!this.getFieldObject().getPluginByClassId('Bidi'); break; case goog.editor.plugins.BasicTextFormatter.COMMAND.SUBSCRIPT: case goog.editor.plugins.BasicTextFormatter.COMMAND.SUPERSCRIPT: if (goog.editor.BrowserFeature.NESTS_SUBSCRIPT_SUPERSCRIPT) { // This browser nests subscript and superscript when both are // applied, instead of canceling out the first when applying the // second. this.applySubscriptSuperscriptWorkarounds_(command); } break; case goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE: case goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD: case goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC: // If we are applying the formatting, then we want to have // styleWithCSS false so that we generate html tags (like <b>). If we // are unformatting something, we want to have styleWithCSS true so // that we can unformat both html tags and inline styling. // TODO(user): What about WebKit and Opera? styleWithCss = goog.userAgent.GECKO && goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && this.queryCommandValue(command); break; case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_COLOR: case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_FACE: // It is very expensive in FF (order of magnitude difference) to use // font tags instead of styled spans. Whenever possible, // force FF to use spans. // Font size is very expensive too, but FF always uses font tags, // regardless of which styleWithCSS value you use. styleWithCss = goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && goog.userAgent.GECKO; } /** * Cases where we just use the default execCommand (in addition * to the above fall-throughs) * goog.editor.plugins.BasicTextFormatter.COMMAND.STRIKE_THROUGH: * goog.editor.plugins.BasicTextFormatter.COMMAND.HORIZONTAL_RULE: * goog.editor.plugins.BasicTextFormatter.COMMAND.SUBSCRIPT: * goog.editor.plugins.BasicTextFormatter.COMMAND.SUPERSCRIPT: * goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE: * goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD: * goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC: * goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_SIZE: * goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_FACE: */ this.execCommandHelper_(command, opt_arg, preserveDir, !!styleWithCss); if (hasDummySelection) { this.getDocument_().execCommand('Delete', false, true); } if (needsFormatBlockDiv) { this.getDocument_().execCommand('FormatBlock', false, '<div>'); } } // FF loses focus, so we have to set the focus back to the document or the // user can't type after selecting from menu. In IE, focus is set correctly // and resetting it here messes it up. if (goog.userAgent.GECKO && !this.getFieldObject().inModalMode()) { this.focusField_(); } return result; }; /** * Focuses on the field. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.focusField_ = function() { this.getFieldDomHelper().getWindow().focus(); }; /** * Gets the command value. * @param {string} command The command value to get. * @return {string|boolean|null} The current value of the command in the given * selection. NOTE: This return type list is not documented in MSDN or MDC * and has been constructed from experience. Please update it * if necessary. * @override */ goog.editor.plugins.BasicTextFormatter.prototype.queryCommandValue = function( command) { var styleWithCss; switch (command) { case goog.editor.plugins.BasicTextFormatter.COMMAND.LINK: return this.isNodeInState_(goog.dom.TagName.A); case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_CENTER: case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_FULL: case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_RIGHT: case goog.editor.plugins.BasicTextFormatter.COMMAND.JUSTIFY_LEFT: return this.isJustification_(command); case goog.editor.plugins.BasicTextFormatter.COMMAND.FORMAT_BLOCK: // TODO(nicksantos): See if we can use queryCommandValue here. return goog.editor.plugins.BasicTextFormatter.getSelectionBlockState_( this.getFieldObject().getRange()); case goog.editor.plugins.BasicTextFormatter.COMMAND.INDENT: case goog.editor.plugins.BasicTextFormatter.COMMAND.OUTDENT: case goog.editor.plugins.BasicTextFormatter.COMMAND.HORIZONTAL_RULE: // TODO: See if there are reasonable results to return for // these commands. return false; case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_SIZE: case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_FACE: case goog.editor.plugins.BasicTextFormatter.COMMAND.FONT_COLOR: case goog.editor.plugins.BasicTextFormatter.COMMAND.BACKGROUND_COLOR: // We use queryCommandValue here since we don't just want to know if a // color/fontface/fontsize is applied, we want to know WHICH one it is. return this.queryCommandValueInternal_( this.getDocument_(), command, goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && goog.userAgent.GECKO); case goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE: case goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD: case goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC: styleWithCss = goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && goog.userAgent.GECKO; default: /** * goog.editor.plugins.BasicTextFormatter.COMMAND.STRIKE_THROUGH * goog.editor.plugins.BasicTextFormatter.COMMAND.SUBSCRIPT * goog.editor.plugins.BasicTextFormatter.COMMAND.SUPERSCRIPT * goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE * goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD * goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC * goog.editor.plugins.BasicTextFormatter.COMMAND.ORDERED_LIST * goog.editor.plugins.BasicTextFormatter.COMMAND.UNORDERED_LIST */ // This only works for commands that use the default execCommand return this.queryCommandStateInternal_( this.getDocument_(), command, styleWithCss); } }; /** * @override */ goog.editor.plugins.BasicTextFormatter.prototype.prepareContentsHtml = function( html) { // If the browser collapses empty nodes and the field has only a script // tag in it, then it will collapse this node. Which will mean the user // can't click into it to edit it. if (goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES && html.match(/^\s*<script/i)) { html = '&nbsp;' + html; } if (goog.editor.BrowserFeature.CONVERT_TO_B_AND_I_TAGS) { // Some browsers (FF) can't undo strong/em in some cases, but can undo b/i! html = html.replace(/<(\/?)strong([^\w])/gi, '<$1b$2'); html = html.replace(/<(\/?)em([^\w])/gi, '<$1i$2'); } return html; }; /** * @override */ goog.editor.plugins.BasicTextFormatter.prototype.cleanContentsDom = function( fieldCopy) { var images = goog.dom.getElementsByTagName(goog.dom.TagName.IMG, fieldCopy); for (var i = 0, image; image = images[i]; i++) { if (goog.editor.BrowserFeature.SHOWS_CUSTOM_ATTRS_IN_INNER_HTML) { // Only need to remove these attributes in IE because // Firefox and Safari don't show custom attributes in the innerHTML. image.removeAttribute('tabIndex'); image.removeAttribute('tabIndexSet'); goog.removeUid(image); // Declare oldTypeIndex for the compiler. The associated plugin may not be // included in the compiled bundle. /** @type {number} */ image.oldTabIndex; // oldTabIndex will only be set if // goog.editor.BrowserFeature.TABS_THROUGH_IMAGES is true and we're in // P-on-enter mode. if (image.oldTabIndex) { image.tabIndex = image.oldTabIndex; } } } }; /** * @override */ goog.editor.plugins.BasicTextFormatter.prototype.cleanContentsHtml = function( html) { if (goog.editor.BrowserFeature.MOVES_STYLE_TO_HEAD) { // Safari creates a new <head> element for <style> tags, so prepend their // contents to the output. var heads = this.getFieldObject() .getEditableDomHelper() .getElementsByTagNameAndClass(goog.dom.TagName.HEAD); var stylesHtmlArr = []; // i starts at 1 so we don't copy in the original, legitimate <head>. var numHeads = heads.length; for (var i = 1; i < numHeads; ++i) { var styles = goog.dom.getElementsByTagName(goog.dom.TagName.STYLE, heads[i]); var numStyles = styles.length; for (var j = 0; j < numStyles; ++j) { stylesHtmlArr.push(styles[j].outerHTML); } } return stylesHtmlArr.join('') + html; } return html; }; /** * @override */ goog.editor.plugins.BasicTextFormatter.prototype.handleKeyboardShortcut = function(e, key, isModifierPressed) { if (!isModifierPressed) { return false; } var command; switch (key) { case 'b': // Ctrl+B command = goog.editor.plugins.BasicTextFormatter.COMMAND.BOLD; break; case 'i': // Ctrl+I command = goog.editor.plugins.BasicTextFormatter.COMMAND.ITALIC; break; case 'u': // Ctrl+U command = goog.editor.plugins.BasicTextFormatter.COMMAND.UNDERLINE; break; case 's': // Ctrl+S // TODO(user): This doesn't belong in here. Clients should handle // this themselves. // Catching control + s prevents the annoying browser save dialog // from appearing. return true; } if (command) { this.getFieldObject().execCommand(command); return true; } return false; }; // Helpers for execCommand /** * Regular expression to match BRs in HTML. Saves the BRs' attributes in $1 for * use with replace(). In non-IE browsers, does not match BRs adjacent to an * opening or closing DIV or P tag, since nonrendered BR elements can occur at * the end of block level containers in those browsers' editors. * @type {RegExp} * @private */ goog.editor.plugins.BasicTextFormatter.BR_REGEXP_ = goog.userAgent.IE ? /<br([^\/>]*)\/?>/gi : /<br([^\/>]*)\/?>(?!<\/(div|p)>)/gi; /** * Convert BRs in the selection to divs. * This is only intended to be used in IE and Opera. * @return {boolean} Whether any BR's were converted. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.convertBreaksToDivs_ = function() { if (!goog.userAgent.IE && !goog.userAgent.OPERA) { // This function is only supported on IE and Opera. return false; } var range = this.getRange_(); var parent = range.getContainerElement(); var doc = this.getDocument_(); var dom = this.getFieldDomHelper(); goog.editor.plugins.BasicTextFormatter.BR_REGEXP_.lastIndex = 0; // Only mess with the HTML/selection if it contains a BR. if (goog.editor.plugins.BasicTextFormatter.BR_REGEXP_.test( parent.innerHTML)) { // Insert temporary markers to remember the selection. var savedRange = range.saveUsingCarets(); if (parent.tagName == goog.dom.TagName.P) { // Can't append paragraphs to paragraph tags. Throws an exception in IE. goog.editor.plugins.BasicTextFormatter.convertParagraphToDiv_( parent, true); } else { // Used to do: // IE: <div>foo<br>bar</div> --> <div>foo<p id="temp_br">bar</div> // Opera: <div>foo<br>bar</div> --> <div>foo<p class="temp_br">bar</div> // To fix bug 1939883, now does for both: // <div>foo<br>bar</div> --> <div>foo<p trtempbr="temp_br">bar</div> // TODO(user): Confirm if there's any way to skip this // intermediate step of converting br's to p's before converting those to // div's. The reason may be hidden in CLs 5332866 and 8530601. var attribute = 'trtempbr'; var value = 'temp_br'; var newHtml = parent.innerHTML.replace( goog.editor.plugins.BasicTextFormatter.BR_REGEXP_, '<p$1 ' + attribute + '="' + value + '">'); goog.editor.node.replaceInnerHtml(parent, newHtml); var paragraphs = goog.array.toArray( goog.dom.getElementsByTagName(goog.dom.TagName.P, parent)); goog.iter.forEach(paragraphs, function(paragraph) { if (paragraph.getAttribute(attribute) == value) { paragraph.removeAttribute(attribute); if (goog.string.isBreakingWhitespace( goog.dom.getTextContent(paragraph))) { // Prevent the empty blocks from collapsing. // A <BR> is preferable because it doesn't result in any text being // added to the "blank" line. In IE, however, it is possible to // place the caret after the <br>, which effectively creates a // visible line break. Because of this, we have to resort to using a // &nbsp; in IE. var child = goog.userAgent.IE ? doc.createTextNode(goog.string.Unicode.NBSP) : dom.createElement(goog.dom.TagName.BR); paragraph.appendChild(child); } goog.editor.plugins.BasicTextFormatter.convertParagraphToDiv_( paragraph); } }); } // Select the previously selected text so we only listify // the selected portion and maintain the user's selection. savedRange.restore(); return true; } return false; }; /** * Convert the given paragraph to being a div. This clobbers the * passed-in node! * This is only intended to be used in IE and Opera. * @param {Node} paragraph Paragragh to convert to a div. * @param {boolean=} opt_convertBrs If true, also convert BRs to divs. * @private */ goog.editor.plugins.BasicTextFormatter.convertParagraphToDiv_ = function( paragraph, opt_convertBrs) { if (!goog.userAgent.IE && !goog.userAgent.OPERA) { // This function is only supported on IE and Opera. return; } var outerHTML = paragraph.outerHTML.replace(/<(\/?)p/gi, '<$1div'); if (opt_convertBrs) { // IE fills in the closing div tag if it's missing! outerHTML = outerHTML.replace( goog.editor.plugins.BasicTextFormatter.BR_REGEXP_, '</div><div$1>'); } if (goog.userAgent.OPERA && !/<\/div>$/i.test(outerHTML)) { // Opera doesn't automatically add the closing tag, so add it if needed. outerHTML += '</div>'; } paragraph.outerHTML = outerHTML; }; /** * If this is a goog.editor.plugins.BasicTextFormatter.COMMAND, * convert it to something that we can pass into execCommand, * queryCommandState, etc. * * TODO(user): Consider doing away with the + and converter completely. * * @param {goog.editor.plugins.BasicTextFormatter.COMMAND|string} * command A command key. * @return {string} The equivalent execCommand command. * @private */ goog.editor.plugins.BasicTextFormatter.convertToRealExecCommand_ = function( command) { return command.indexOf('+') == 0 ? command.substring(1) : command; }; /** * Justify the text in the selection. * @param {string} command The type of justification to perform. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.justify_ = function(command) { this.execCommandHelper_(command, null, false, true); // Firefox cannot justify divs. In fact, justifying divs results in removing // the divs and replacing them with brs. So "<div>foo</div><div>bar</div>" // becomes "foo<br>bar" after alignment is applied. However, if you justify // again, then you get "<div style='text-align: right'>foo<br>bar</div>", // which at least looks visually correct. Since justification is (normally) // idempotent, it isn't a problem when the selection does not contain divs to // apply justifcation again. if (goog.userAgent.GECKO) { this.execCommandHelper_(command, null, false, true); } // Convert all block elements in the selection to use CSS text-align // instead of the align property. This works better because the align // property is overridden by the CSS text-align property. // // Only for browsers that can't handle this by the styleWithCSS execCommand, // which allows us to specify if we should insert align or text-align. // TODO(user): What about WebKit or Opera? if (!(goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS && goog.userAgent.GECKO)) { goog.iter.forEach( this.getFieldObject().getRange(), goog.editor.plugins.BasicTextFormatter.convertContainerToTextAlign_); } }; /** * Converts the block element containing the given node to use CSS text-align * instead of the align property. * @param {Node} node The node to convert the container of. * @private */ goog.editor.plugins.BasicTextFormatter.convertContainerToTextAlign_ = function( node) { var container = goog.editor.style.getContainer(node); // TODO(user): Fix this so that it doesn't screw up tables. if (container.align) { container.style.textAlign = container.align; container.removeAttribute('align'); } }; /** * Perform an execCommand on the active document. * @param {string} command The command to execute. * @param {string|number|boolean|null=} opt_value Optional value. * @param {boolean=} opt_preserveDir Set true to make sure that command does not * change directionality of the selected text (works only if all selected * text has the same directionality, otherwise ignored). Should not be true * if bidi plugin is not loaded. * @param {boolean=} opt_styleWithCss Set to true to ask the browser to use CSS * to perform the execCommand. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.execCommandHelper_ = function( command, opt_value, opt_preserveDir, opt_styleWithCss) { // There is a bug in FF: some commands do not preserve attributes of the // block-level elements they replace. // This (among the rest) leads to loss of directionality information. // For now we use a hack (when opt_preserveDir==true) to avoid this // directionality problem in the simplest cases. // Known affected commands: formatBlock, insertOrderedList, // insertUnorderedList, indent, outdent. // A similar problem occurs in IE when insertOrderedList or // insertUnorderedList remove existing list. var dir = null; if (opt_preserveDir) { dir = this.getFieldObject().queryCommandValue(goog.editor.Command.DIR_RTL) ? 'rtl' : this.getFieldObject().queryCommandValue(goog.editor.Command.DIR_LTR) ? 'ltr' : null; } command = goog.editor.plugins.BasicTextFormatter.convertToRealExecCommand_(command); var endDiv, nbsp; if (goog.userAgent.IE) { var ret = this.applyExecCommandIEFixes_(command); endDiv = ret[0]; nbsp = ret[1]; } if (goog.userAgent.WEBKIT) { endDiv = this.applyExecCommandSafariFixes_(command); } if (goog.userAgent.GECKO) { this.applyExecCommandGeckoFixes_(command); } if (goog.editor.BrowserFeature.DOESNT_OVERRIDE_FONT_SIZE_IN_STYLE_ATTR && command.toLowerCase() == 'fontsize') { this.removeFontSizeFromStyleAttrs_(); } var doc = this.getDocument_(); if (opt_styleWithCss && goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS) { doc.execCommand('styleWithCSS', false, true); if (goog.userAgent.OPERA) { this.invalidateInlineCss_(); } } doc.execCommand(command, false, opt_value); if (opt_styleWithCss && goog.editor.BrowserFeature.HAS_STYLE_WITH_CSS) { // If we enabled styleWithCSS, turn it back off. doc.execCommand('styleWithCSS', false, false); } if (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('526') && command.toLowerCase() == 'formatblock' && opt_value && /^[<]?h\d[>]?$/i.test(opt_value)) { this.cleanUpSafariHeadings_(); } if (/insert(un)?orderedlist/i.test(command)) { // NOTE(user): This doesn't check queryCommandState because it seems to // lie. Also, this runs for insertunorderedlist so that the the list // isn't made up of an <ul> for each <li> - even though it looks the same, // the markup is disgusting. if (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher(534)) { this.fixSafariLists_(); } if (goog.userAgent.IE) { this.fixIELists_(); if (nbsp) { // Remove the text node, if applicable. Do not try to instead clobber // the contents of the text node if it was added, or the same invalid // node thing as above will happen. The error won't happen here, it // will happen after you hit enter and then do anything that loops // through the dom and tries to read that node. goog.dom.removeNode(nbsp); } } } if (endDiv) { // Remove the dummy div. goog.dom.removeNode(endDiv); } // Restore directionality if required and only when unambigous (dir!=null). if (dir) { this.getFieldObject().execCommand(dir); } }; /** * Applies a background color to a selection when the browser can't do the job. * * NOTE(nicksantos): If you think this is hacky, you should try applying * background color in Opera. It made me cry. * * @param {string} bgColor backgroundColor from .formatText to .execCommand. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.applyBgColorManually_ = function(bgColor) { var needsSpaceInTextNode = goog.userAgent.GECKO; var range = this.getFieldObject().getRange(); var textNode; var parentTag; if (range && range.isCollapsed()) { // Hack to handle Firefox bug: // https://bugzilla.mozilla.org/show_bug.cgi?id=279330 // execCommand hiliteColor in Firefox on collapsed selection creates // a font tag onkeypress textNode = this.getFieldDomHelper().createTextNode( needsSpaceInTextNode ? ' ' : ''); var containerNode = range.getStartNode(); // Check if we're inside a tag that contains the cursor and nothing else; // if we are, don't create a dummySpan. Just use this containing tag to // hide the 1-space selection. // If the user sets a background color on a collapsed selection, then sets // another one immediately, we get a span tag with a single empty TextNode. // If the user sets a background color, types, then backspaces, we get a // span tag with nothing inside it (container is the span). parentTag = containerNode.nodeType == goog.dom.NodeType.ELEMENT ? containerNode : containerNode.parentNode; if (parentTag.innerHTML == '') { // There's an Element to work with // make the space character invisible using a CSS indent hack parentTag.style.textIndent = '-10000px'; parentTag.appendChild(textNode); } else { // No Element to work with; make one // create a span with a space character inside // make the space character invisible using a CSS indent hack parentTag = this.getFieldDomHelper().createDom( goog.dom.TagName.SPAN, {'style': 'text-indent:-10000px'}, textNode); range.replaceContentsWithNode(parentTag); } goog.dom.Range.createFromNodeContents(textNode).select(); } this.execCommandHelper_('hiliteColor', bgColor, false, true); if (textNode) { // eliminate the space if necessary. if (needsSpaceInTextNode) { textNode.data = ''; } // eliminate the hack. parentTag.style.textIndent = ''; // execCommand modified our span so we leave it in place. } }; /** * Toggle link for the current selection: * If selection contains a link, unlink it, return null. * Otherwise, make selection into a link, return the link. * @param {string=} opt_target Target for the link. * @return {goog.editor.Link?} The resulting link, or null if a link was * removed. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.toggleLink_ = function( opt_target) { if (!this.getFieldObject().isSelectionEditable()) { this.focusField_(); } var range = this.getRange_(); // Since we wrap images in links, its possible that the user selected an // image and clicked link, in which case we want to actually use the // image as the selection. var parent = range && range.getContainerElement(); var link = /** @type {Element} */ ( goog.dom.getAncestorByTagNameAndClass(parent, goog.dom.TagName.A)); if (link && goog.editor.node.isEditable(link)) { goog.dom.flattenElement(link); } else { var editableLink = this.createLink_(range, '/', opt_target); if (editableLink) { if (!this.getFieldObject().execCommand( goog.editor.Command.MODAL_LINK_EDITOR, editableLink)) { var url = this.getFieldObject().getAppWindow().prompt( goog.ui.editor.messages.MSG_LINK_TO, 'http://'); if (url) { editableLink.setTextAndUrl(editableLink.getCurrentText() || url, url); editableLink.placeCursorRightOf(); } else { var savedRange = goog.editor.range.saveUsingNormalizedCarets( goog.dom.Range.createFromNodeContents(editableLink.getAnchor())); editableLink.removeLink(); savedRange.restore().select(); return null; } } return editableLink; } } return null; }; /** * Create a link out of the current selection. If nothing is selected, insert * a new link. Otherwise, enclose the selection in a link. * @param {goog.dom.AbstractRange} range The closure range object for the * current selection. * @param {string} url The url to link to. * @param {string=} opt_target Target for the link. * @return {goog.editor.Link?} The newly created link, or null if the link * couldn't be created. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.createLink_ = function( range, url, opt_target) { var anchor = null; var anchors = []; var parent = range && range.getContainerElement(); // We do not yet support creating links around images. Instead of throwing // lots of js errors, just fail silently. // TODO(user): Add support for linking images. if (parent && parent.tagName == goog.dom.TagName.IMG) { return null; } // If range is not present, the editable field doesn't have focus, abort // creating a link. if (!range) { return null; } if (range.isCollapsed()) { var textRange = range.getTextRange(0).getBrowserRangeObject(); if (goog.editor.BrowserFeature.HAS_W3C_RANGES) { anchor = this.getFieldDomHelper().createElement(goog.dom.TagName.A); textRange.insertNode(anchor); } else if (goog.editor.BrowserFeature.HAS_IE_RANGES) { // TODO: Use goog.dom.AbstractRange's surroundContents textRange.pasteHTML("<a id='newLink'></a>"); anchor = this.getFieldDomHelper().getElement('newLink'); anchor.removeAttribute('id'); } } else { // Create a unique identifier for the link so we can retrieve it later. // execCommand doesn't return the link to us, and we need a way to find // the newly created link in the dom, and the url is the only property // we have control over, so we set that to be unique and then find it. var uniqueId = goog.string.createUniqueString(); this.execCommandHelper_('CreateLink', uniqueId); var setHrefAndLink = function(element, index, arr) { // We can't do straight comparison since the href can contain the // absolute url. if (goog.string.endsWith(element.href, uniqueId)) { anchors.push(element); } }; goog.array.forEach( goog.dom.getElementsByTagName( goog.dom.TagName.A, /** @type {!Element} */ (this.getFieldObject().getElement())), setHrefAndLink); if (anchors.length) { anchor = anchors.pop(); } var isLikelyUrl = function(a, i, anchors) { return goog.editor.Link.isLikelyUrl(goog.dom.getRawTextContent(a)); }; if (anchors.length && goog.array.every(anchors, isLikelyUrl)) { for (var i = 0, a; a = anchors[i]; i++) { goog.editor.Link.createNewLinkFromText(a, opt_target); } anchors = null; } } return goog.editor.Link.createNewLink( /** @type {HTMLAnchorElement} */ (anchor), url, opt_target, anchors); }; //--------------------------------------------------------------------- // browser fixes /** * The following execCommands are "broken" in some way - in IE they allow * the nodes outside the contentEditable region to get modified (see * execCommand below for more details). * @const * @private */ goog.editor.plugins.BasicTextFormatter.brokenExecCommandsIE_ = { 'indent': 1, 'outdent': 1, 'insertOrderedList': 1, 'insertUnorderedList': 1, 'justifyCenter': 1, 'justifyFull': 1, 'justifyRight': 1, 'justifyLeft': 1, 'ltr': 1, 'rtl': 1 }; /** * When the following commands are executed while the selection is * inside a blockquote, they hose the blockquote tag in weird and * unintuitive ways. * @const * @private */ goog.editor.plugins.BasicTextFormatter.blockquoteHatingCommandsIE_ = { 'insertOrderedList': 1, 'insertUnorderedList': 1 }; /** * Makes sure that superscript is removed before applying subscript, and vice * versa. Fixes {@link http://buganizer/issue?id=1173491} . * @param {goog.editor.plugins.BasicTextFormatter.COMMAND} command The command * being applied, either SUBSCRIPT or SUPERSCRIPT. * @private */ goog.editor.plugins.BasicTextFormatter.prototype .applySubscriptSuperscriptWorkarounds_ = function(command) { if (!this.queryCommandValue(command)) { // The current selection doesn't currently have the requested // command, so we are applying it as opposed to removing it. // (Note that queryCommandValue() will only return true if the // command is applied to the whole selection, not just part of it. // In this case it is fine because only if the whole selection has // the command applied will we be removing it and thus skipping the // removal of the opposite command.) var oppositeCommand = (command == goog.editor.plugins.BasicTextFormatter.COMMAND.SUBSCRIPT ? goog.editor.plugins.BasicTextFormatter.COMMAND.SUPERSCRIPT : goog.editor.plugins.BasicTextFormatter.COMMAND.SUBSCRIPT); var oppositeExecCommand = goog.editor.plugins.BasicTextFormatter.convertToRealExecCommand_( oppositeCommand); // Executing the opposite command on a selection that already has it // applied will cancel it out. But if the selection only has the // opposite command applied to a part of it, the browser will // normalize the selection to have the opposite command applied on // the whole of it. if (!this.queryCommandValue(oppositeCommand)) { // The selection doesn't have the opposite command applied to the // whole of it, so let's exec the opposite command to normalize // the selection. // Note: since we know both subscript and superscript commands // will boil down to a simple call to the browser's execCommand(), // for performance reasons we can do that directly instead of // calling execCommandHelper_(). However this is a potential for // bugs if the implementation of execCommandHelper_() is changed // to do something more int eh case of subscript and superscript. this.getDocument_().execCommand(oppositeExecCommand, false, null); } // Now that we know the whole selection has the opposite command // applied, we exec it a second time to properly remove it. this.getDocument_().execCommand(oppositeExecCommand, false, null); } }; /** * Removes inline font-size styles from elements fully contained in the * selection, so the font tags produced by execCommand work properly. * See {@bug 1286408}. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.removeFontSizeFromStyleAttrs_ = function() { // Expand the range so that we consider surrounding tags. E.g. if only the // text node inside a span is selected, the browser could wrap a font tag // around the span and leave the selection such that only the text node is // found when looking inside the range, not the span. var range = goog.editor.range.expand( this.getFieldObject().getRange(), this.getFieldObject().getElement()); goog.iter.forEach(goog.iter.filter(range, function(tag, dummy, iter) { return iter.isStartTag() && range.containsNode(tag); }), function(node) { goog.style.setStyle(node, 'font-size', ''); // Gecko doesn't remove empty style tags. if (goog.userAgent.GECKO && node.style.length == 0 && node.getAttribute('style') != null) { node.removeAttribute('style'); } }); }; /** * Apply pre-execCommand fixes for IE. * @param {string} command The command to execute. * @return {!Array<Node>} Array of nodes to be removed after the execCommand. * Will never be longer than 2 elements. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.applyExecCommandIEFixes_ = function(command) { // IE has a crazy bug where executing list commands // around blockquotes cause the blockquotes to get transformed // into "<OL><OL>" or "<UL><UL>" tags. var toRemove = []; var endDiv = null; var range = this.getRange_(); var dh = this.getFieldDomHelper(); if (command in goog.editor.plugins.BasicTextFormatter.blockquoteHatingCommandsIE_) { var parent = range && range.getContainerElement(); if (parent) { var blockquotes = goog.dom.getElementsByTagNameAndClass( goog.dom.TagName.BLOCKQUOTE, null, parent); // If a blockquote contains the selection, the fix is easy: // add a dummy div to the blockquote that isn't in the current selection. // // if the selection contains a blockquote, // there appears to be no easy way to protect it from getting mangled. // For now, we're just going to punt on this and try to // adjust the selection so that IE does something reasonable. // // TODO(nicksantos): Find a better fix for this. var bq; for (var i = 0; i < blockquotes.length; i++) { if (range.containsNode(blockquotes[i])) { bq = blockquotes[i]; break; } } var bqThatNeedsDummyDiv = bq || goog.dom.getAncestorByTagNameAndClass( parent, goog.dom.TagName.BLOCKQUOTE); if (bqThatNeedsDummyDiv) { endDiv = dh.createDom(goog.dom.TagName.DIV, {style: 'height:0'}); goog.dom.appendChild(bqThatNeedsDummyDiv, endDiv); toRemove.push(endDiv); if (bq) { range = goog.dom.Range.createFromNodes(bq, 0, endDiv, 0); } else if (range.containsNode(endDiv)) { // the selection might be the entire blockquote, and // it's important that endDiv not be in the selection. range = goog.dom.Range.createFromNodes( range.getStartNode(), range.getStartOffset(), endDiv, 0); } range.select(); } } } // IE has a crazy bug where certain block execCommands cause it to mess with // the DOM nodes above the contentEditable element if the selection contains // or partially contains the last block element in the contentEditable // element. // Known commands: Indent, outdent, insertorderedlist, insertunorderedlist, // Justify (all of them) // Both of the above are "solved" by appending a dummy div to the field // before the execCommand and removing it after, but we don't need to do this // if we've alread added a dummy div somewhere else. var fieldObject = this.getFieldObject(); if (!fieldObject.usesIframe() && !endDiv) { if (command in goog.editor.plugins.BasicTextFormatter.brokenExecCommandsIE_) { var field = fieldObject.getElement(); // If the field is totally empty, or if the field contains only text nodes // and the cursor is at the end of the field, then IE stills walks outside // the contentEditable region and destroys things AND justify will not // work. This is "solved" by adding a text node into the end of the // field and moving the cursor before it. if (range && range.isCollapsed() && !goog.dom.getFirstElementChild(field)) { // The problem only occurs if the selection is at the end of the field. var selection = range.getTextRange(0).getBrowserRangeObject(); var testRange = selection.duplicate(); testRange.moveToElementText(field); testRange.collapse(false); if (testRange.isEqual(selection)) { // For reasons I really don't understand, if you use a breaking space // here, either " " or String.fromCharCode(32), this textNode becomes // corrupted, only after you hit ENTER to split it. It exists in the // dom in that its parent has it as childNode and the parent's // innerText is correct, but the node itself throws invalid argument // errors when you try to access its data, parentNode, nextSibling, // previousSibling or most other properties. WTF. var nbsp = dh.createTextNode(goog.string.Unicode.NBSP); field.appendChild(nbsp); selection.move('character', 1); selection.move('character', -1); selection.select(); toRemove.push(nbsp); } } endDiv = dh.createDom(goog.dom.TagName.DIV, {style: 'height:0'}); goog.dom.appendChild(field, endDiv); toRemove.push(endDiv); } } return toRemove; }; /** * Fix a ridiculous Safari bug: the first letters of new headings * somehow retain their original font size and weight if multiple lines are * selected during the execCommand that turns them into headings. * The solution is to strip these styles which are normally stripped when * making things headings anyway. * @private */ goog.editor.plugins.BasicTextFormatter.prototype.cleanUpSafariHeadings_ = function() { goog.iter.forEach(this.getRange_(), function(node) { if (node.className == 'Apple-style-span') { // These shouldn't persist after creating headings via // a FormatBlock execCommand. node.style.fontSize = ''; node.style.fontWeight = ''; } }); }; /** * Prevent Safari from making each list item be "1" when converting from * unordered to ordered lists. * (see https://bugs.webkit.org/show_bug.cgi?id=19539, fixed by 2010-04-21) * @private */ goog.editor.plugins.BasicTextFormatter.prototype.fixSafariLists_ = function() { var previousList = false; goog.iter.forEach(this.getRange_(), function(node) { var tagName = node.tagName; if (tagName == goog.dom.TagName.UL || tagName == goog.dom.TagName.OL) { // Don't disturb lists outside of the selection. If this is the first <ul> // or <ol> in the range, we don't really want to merge the previous list // into it, sin