diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/compose/content | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/compose/content')
53 files changed, 35904 insertions, 0 deletions
diff --git a/comm/mail/components/compose/content/ComposerCommands.js b/comm/mail/components/compose/content/ComposerCommands.js new file mode 100644 index 0000000000..7e9d7a992d --- /dev/null +++ b/comm/mail/components/compose/content/ComposerCommands.js @@ -0,0 +1,2261 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Implementations of nsIControllerCommand for composer commands. These commands + * are related to editing. You can fire these commands with following functions: + * goDoCommand and goDoCommandParams(If command requires any parameters). + * + * Sometimes, we want to reflect the changes in the UI also. We have two functions + * for that: pokeStyleUI and pokeMultiStateUI. The pokeStyleUI function is for those + * commands which are boolean in nature for example "cmd_bold" command, text can + * be bold or not. The pokeMultiStateUI function is for the commands which can have + * multiple values for example "cmd_fontFace" can have different values like + * arial, variable width etc. + * + * Here, some of the commands are getting executed by document.execCommand. + * Those are listed in the gCommandMap Map object. In that also, some commands + * are of type boolean and some are of multiple state. We have two functions to + * execute them: doStatefulCommand and doStyleUICommand. + * + * All commands are not executable through document.execCommand. + * In all those cases, we will use goDoCommand or goDoCommandParams. + * The goDoCommandParams function is implemented in this file. + * The goDoCOmmand function is from globalOverlay.js. For the Commands + * which can be executed by document.execCommand, we will use doStatefulCommand + * and doStyleUICommand. + */ + +/* import-globals-from ../../../../../toolkit/components/printing/content/printUtils.js */ +/* import-globals-from ../../../base/content/globalOverlay.js */ +/* import-globals-from ../../../base/content/utilityOverlay.js */ +/* import-globals-from editor.js */ +/* import-globals-from editorUtilities.js */ +/* import-globals-from MsgComposeCommands.js */ + +var gComposerJSCommandControllerID = 0; + +/** + * Used to register commands we have created manually. + */ +function SetupHTMLEditorCommands() { + var commandTable = GetComposerCommandTable(); + if (!commandTable) { + return; + } + + // Include everything a text editor does + SetupTextEditorCommands(); + + // dump("Registering HTML editor commands\n"); + + commandTable.registerCommand("cmd_renderedHTMLEnabler", nsDummyHTMLCommand); + + commandTable.registerCommand("cmd_listProperties", nsListPropertiesCommand); + commandTable.registerCommand("cmd_colorProperties", nsColorPropertiesCommand); + commandTable.registerCommand("cmd_increaseFontStep", nsIncreaseFontCommand); + commandTable.registerCommand("cmd_decreaseFontStep", nsDecreaseFontCommand); + commandTable.registerCommand( + "cmd_objectProperties", + nsObjectPropertiesCommand + ); + commandTable.registerCommand( + "cmd_removeNamedAnchors", + nsRemoveNamedAnchorsCommand + ); + + commandTable.registerCommand("cmd_image", nsImageCommand); + commandTable.registerCommand("cmd_hline", nsHLineCommand); + commandTable.registerCommand("cmd_link", nsLinkCommand); + commandTable.registerCommand("cmd_anchor", nsAnchorCommand); + commandTable.registerCommand( + "cmd_insertHTMLWithDialog", + nsInsertHTMLWithDialogCommand + ); + commandTable.registerCommand( + "cmd_insertMathWithDialog", + nsInsertMathWithDialogCommand + ); + commandTable.registerCommand("cmd_insertBreakAll", nsInsertBreakAllCommand); + + commandTable.registerCommand("cmd_table", nsInsertOrEditTableCommand); + commandTable.registerCommand("cmd_editTable", nsEditTableCommand); + commandTable.registerCommand("cmd_SelectTable", nsSelectTableCommand); + commandTable.registerCommand("cmd_SelectRow", nsSelectTableRowCommand); + commandTable.registerCommand("cmd_SelectColumn", nsSelectTableColumnCommand); + commandTable.registerCommand("cmd_SelectCell", nsSelectTableCellCommand); + commandTable.registerCommand( + "cmd_SelectAllCells", + nsSelectAllTableCellsCommand + ); + commandTable.registerCommand("cmd_InsertTable", nsInsertTableCommand); + commandTable.registerCommand( + "cmd_InsertRowAbove", + nsInsertTableRowAboveCommand + ); + commandTable.registerCommand( + "cmd_InsertRowBelow", + nsInsertTableRowBelowCommand + ); + commandTable.registerCommand( + "cmd_InsertColumnBefore", + nsInsertTableColumnBeforeCommand + ); + commandTable.registerCommand( + "cmd_InsertColumnAfter", + nsInsertTableColumnAfterCommand + ); + commandTable.registerCommand( + "cmd_InsertCellBefore", + nsInsertTableCellBeforeCommand + ); + commandTable.registerCommand( + "cmd_InsertCellAfter", + nsInsertTableCellAfterCommand + ); + commandTable.registerCommand("cmd_DeleteTable", nsDeleteTableCommand); + commandTable.registerCommand("cmd_DeleteRow", nsDeleteTableRowCommand); + commandTable.registerCommand("cmd_DeleteColumn", nsDeleteTableColumnCommand); + commandTable.registerCommand("cmd_DeleteCell", nsDeleteTableCellCommand); + commandTable.registerCommand( + "cmd_DeleteCellContents", + nsDeleteTableCellContentsCommand + ); + commandTable.registerCommand("cmd_JoinTableCells", nsJoinTableCellsCommand); + commandTable.registerCommand("cmd_SplitTableCell", nsSplitTableCellCommand); + commandTable.registerCommand( + "cmd_TableOrCellColor", + nsTableOrCellColorCommand + ); + commandTable.registerCommand("cmd_smiley", nsSetSmiley); + commandTable.registerCommand("cmd_ConvertToTable", nsConvertToTable); +} + +function SetupTextEditorCommands() { + var commandTable = GetComposerCommandTable(); + if (!commandTable) { + return; + } + // dump("Registering plain text editor commands\n"); + + commandTable.registerCommand("cmd_findReplace", nsFindReplaceCommand); + commandTable.registerCommand("cmd_find", nsFindCommand); + commandTable.registerCommand("cmd_findNext", nsFindAgainCommand); + commandTable.registerCommand("cmd_findPrev", nsFindAgainCommand); + commandTable.registerCommand("cmd_rewrap", nsRewrapCommand); + commandTable.registerCommand("cmd_spelling", nsSpellingCommand); + commandTable.registerCommand("cmd_insertChars", nsInsertCharsCommand); +} + +/** + * Used to register the command controller in the editor document. + * + * @returns {nsIControllerCommandTable|null} - A controller used to + * register the manually created commands. + */ +function GetComposerCommandTable() { + var controller; + if (gComposerJSCommandControllerID) { + try { + controller = window.content.controllers.getControllerById( + gComposerJSCommandControllerID + ); + } catch (e) {} + } + if (!controller) { + // create it + controller = + Cc["@mozilla.org/embedcomp/base-command-controller;1"].createInstance(); + + var editorController = controller.QueryInterface(Ci.nsIControllerContext); + editorController.setCommandContext(GetCurrentEditorElement()); + window.content.controllers.insertControllerAt(0, controller); + + // Store the controller ID so we can be sure to get the right one later + gComposerJSCommandControllerID = + window.content.controllers.getControllerId(controller); + } + + if (controller) { + var interfaceRequestor = controller.QueryInterface( + Ci.nsIInterfaceRequestor + ); + return interfaceRequestor.getInterface(Ci.nsIControllerCommandTable); + } + return null; +} + +/* eslint-disable complexity */ + +/** + * Get the state of the given command and call the pokeStyleUI or pokeMultiStateUI + * according to the type of the command to reflect the UI changes in the editor. + * + * @param {string} command - The id of the command. + */ +function goUpdateCommandState(command) { + try { + var controller = + document.commandDispatcher.getControllerForCommand(command); + if (!(controller instanceof Ci.nsICommandController)) { + return; + } + + var params = newCommandParams(); + if (!params) { + return; + } + + controller.getCommandStateWithParams(command, params); + + switch (command) { + case "cmd_bold": + case "cmd_italic": + case "cmd_underline": + case "cmd_var": + case "cmd_samp": + case "cmd_code": + case "cmd_acronym": + case "cmd_abbr": + case "cmd_cite": + case "cmd_strong": + case "cmd_em": + case "cmd_superscript": + case "cmd_subscript": + case "cmd_strikethrough": + case "cmd_tt": + case "cmd_nobreak": + case "cmd_ul": + case "cmd_ol": + pokeStyleUI(command, params.getBooleanValue("state_all")); + break; + + case "cmd_paragraphState": + case "cmd_align": + case "cmd_highlight": + case "cmd_backgroundColor": + case "cmd_fontColor": + case "cmd_fontFace": + pokeMultiStateUI(command, params); + break; + + case "cmd_indent": + case "cmd_outdent": + case "cmd_increaseFont": + case "cmd_decreaseFont": + case "cmd_increaseFontStep": + case "cmd_decreaseFontStep": + case "cmd_removeStyles": + case "cmd_smiley": + break; + + default: + dump("no update for command: " + command + "\n"); + } + } catch (e) { + console.error(e); + } +} +/* eslint-enable complexity */ + +/** + * Used in the oncommandupdate attribute of the goUpdateComposerMenuItems. + * For any commandset events fired, this function will be called. + * Used to update the UI state of the editor buttons and menulist. + * Whenever you change your selection in the editor part, i.e. if you move + * your cursor, you will find this functions getting called and + * updating the editor UI of toolbarbuttons and menulists. This is mainly + * to update the UI according to your selection in the editor part. + * + * @param {XULElement} commandset - The <xul:commandset> element to update for. + */ +function goUpdateComposerMenuItems(commandset) { + // dump("Updating commands for " + commandset.id + "\n"); + for (var i = 0; i < commandset.children.length; i++) { + var commandNode = commandset.children[i]; + var commandID = commandNode.id; + if (commandID) { + goUpdateCommand(commandID); // enable or disable + if (commandNode.hasAttribute("state")) { + goUpdateCommandState(commandID); + } + } + } +} + +/** + * Execute the command with the provided parameters. + * This is directly calling commands with multiple state attributes, which + * are not supported by document.execCommand() + * + * @param {string} command - The command ID. + * @param {string} paramValue - The parameter value. + */ +function goDoCommandParams(command, paramValue) { + try { + let params = newCommandParams(); + params.setStringValue("state_attribute", paramValue); + let controller = + document.commandDispatcher.getControllerForCommand(command); + if (controller && controller.isCommandEnabled(command)) { + if (controller instanceof Ci.nsICommandController) { + controller.doCommandWithParams(command, params); + } else { + controller.doCommand(command); + } + } + } catch (e) { + console.error(e); + } +} + +/** + * Update the UI to reflect setting a given state for a command. This + * is used for boolean type of commands. + * + * @param {string} uiID - The id of the command. + * @param {boolean} desiredState - State to set for the command. + */ +function pokeStyleUI(uiID, desiredState) { + let commandNode = document.getElementById(uiID); + let uiState = commandNode.getAttribute("state") == "true"; + if (desiredState != uiState) { + commandNode.setAttribute("state", desiredState ? "true" : "false"); + let buttonId; + switch (uiID) { + case "cmd_bold": + buttonId = "boldButton"; + break; + case "cmd_italic": + buttonId = "italicButton"; + break; + case "cmd_underline": + buttonId = "underlineButton"; + break; + case "cmd_ul": + buttonId = "ulButton"; + break; + case "cmd_ol": + buttonId = "olButton"; + break; + } + if (buttonId) { + document.getElementById(buttonId).checked = desiredState; + } + } +} + +/** + * Maps internal command names to their document.execCommand() command string. + */ +let gCommandMap = new Map([ + ["cmd_bold", "bold"], + ["cmd_italic", "italic"], + ["cmd_underline", "underline"], + ["cmd_strikethrough", "strikethrough"], + ["cmd_superscript", "superscript"], + ["cmd_subscript", "subscript"], + ["cmd_ul", "InsertUnorderedList"], + ["cmd_ol", "InsertOrderedList"], + ["cmd_fontFace", "fontName"], + + // This are currently implemented with the help of + // color selection dialog box in the editor.js. + // ["cmd_highlight", "backColor"], + // ["cmd_fontColor", "foreColor"], +]); + +/** + * Used for the boolean type commands available through + * document.execCommand(). We will also call pokeStyleUI to update + * the UI. + * + * @param {string} cmdStr - The id of the command. + */ +function doStyleUICommand(cmdStr) { + GetCurrentEditorElement().contentDocument.execCommand( + gCommandMap.get(cmdStr), + false, + null + ); + let commandNode = document.getElementById(cmdStr); + let newState = commandNode.getAttribute("state") != "true"; + pokeStyleUI(cmdStr, newState); +} + +// Copied from jsmime.js. +function stringToTypedArray(buffer) { + var typedarray = new Uint8Array(buffer.length); + for (var i = 0; i < buffer.length; i++) { + typedarray[i] = buffer.charCodeAt(i); + } + return typedarray; +} + +/** + * Update the UI to reflect setting a given state for a command. This is used + * when the command state has a string value i.e. multiple state type commands. + * + * @param {string} uiID - The id of the command. + * @param {nsICommandParams} cmdParams - Command parameters object. + */ +function pokeMultiStateUI(uiID, cmdParams) { + let desiredAttrib; + if (cmdParams.getBooleanValue("state_mixed")) { + desiredAttrib = "mixed"; + } else if ( + cmdParams.getValueType("state_attribute") == Ci.nsICommandParams.eStringType + ) { + desiredAttrib = cmdParams.getCStringValue("state_attribute"); + // Decode UTF-8, for example for font names in Japanese. + desiredAttrib = new TextDecoder("UTF-8").decode( + stringToTypedArray(desiredAttrib) + ); + } else { + desiredAttrib = cmdParams.getStringValue("state_attribute"); + } + + let commandNode = document.getElementById(uiID); + let uiState = commandNode.getAttribute("state"); + if (desiredAttrib != uiState) { + commandNode.setAttribute("state", desiredAttrib); + switch (uiID) { + case "cmd_paragraphState": { + onParagraphFormatChange(); + break; + } + case "cmd_fontFace": { + onFontFaceChange(); + break; + } + case "cmd_fontColor": { + onFontColorChange(); + break; + } + case "cmd_backgroundColor": { + onBackgroundColorChange(); + break; + } + } + } +} + +/** + * Perform the action of the multiple states type commands available through + * document.execCommand(). + * + * @param {string} commandID - The id of the command. + * @param {string} newState - The parameter value. + * @param {boolean} updateUI - updates the UI if true. Used when + * function is called in another JavaScript function. + */ +function doStatefulCommand(commandID, newState, updateUI) { + if (commandID == "cmd_align") { + let command; + switch (newState) { + case "left": + command = "justifyLeft"; + break; + case "center": + command = "justifyCenter"; + break; + case "right": + command = "justifyRight"; + break; + case "justify": + command = "justifyFull"; + break; + } + GetCurrentEditorElement().contentDocument.execCommand(command, false, null); + } else if (commandID == "cmd_fontFace" && newState == "") { + goDoCommandParams(commandID, newState); + } else { + GetCurrentEditorElement().contentDocument.execCommand( + gCommandMap.get(commandID), + false, + newState + ); + } + + if (updateUI) { + let commandNode = document.getElementById(commandID); + commandNode.setAttribute("state", newState); + switch (commandID) { + case "cmd_fontFace": { + onFontFaceChange(); + break; + } + } + } else { + let commandNode = document.getElementById(commandID); + if (commandNode) { + commandNode.setAttribute("state", newState); + } + } +} + +var nsDummyHTMLCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + // do nothing + dump("Hey, who's calling the dummy command?\n"); + }, +}; + +// ------- output utilities ----- // + +// returns a fileExtension string +function GetExtensionBasedOnMimeType(aMIMEType) { + try { + var mimeService = null; + mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + + var fileExtension = mimeService.getPrimaryExtension(aMIMEType, null); + + // the MIME service likes to give back ".htm" for text/html files, + // so do a special-case fix here. + if (fileExtension == "htm") { + fileExtension = "html"; + } + + return fileExtension; + } catch (e) {} + return ""; +} + +function GetSuggestedFileName(aDocumentURLString, aMIMEType) { + var extension = GetExtensionBasedOnMimeType(aMIMEType); + if (extension) { + extension = "." + extension; + } + + // check for existing file name we can use + if (aDocumentURLString && !IsUrlAboutBlank(aDocumentURLString)) { + try { + let docURI = Services.io.newURI( + aDocumentURLString, + GetCurrentEditor().documentCharacterSet + ); + docURI = docURI.QueryInterface(Ci.nsIURL); + + // grab the file name + let url = validateFileName(decodeURIComponent(docURI.fileBaseName)); + if (url) { + return url + extension; + } + } catch (e) {} + } + + // Check if there is a title we can use to generate a valid filename, + // if we can't, use the default filename. + var title = + validateFileName(GetDocumentTitle()) || + GetString("untitledDefaultFilename"); + return title + extension; +} + +/** + * @returns {Promise} dialogResult + */ +function PromptForSaveLocation( + aDoSaveAsText, + aEditorType, + aMIMEType, + aDocumentURLString +) { + var dialogResult = {}; + dialogResult.filepickerClick = Ci.nsIFilePicker.returnCancel; + dialogResult.resultingURI = ""; + dialogResult.resultingLocalFile = null; + + var fp = null; + try { + fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + } catch (e) {} + if (!fp) { + return dialogResult; + } + + // determine prompt string based on type of saving we'll do + var promptString; + if (aDoSaveAsText || aEditorType == "text") { + promptString = GetString("SaveTextAs"); + } else { + promptString = GetString("SaveDocumentAs"); + } + + fp.init(window, promptString, Ci.nsIFilePicker.modeSave); + + // Set filters according to the type of output + if (aDoSaveAsText) { + fp.appendFilters(Ci.nsIFilePicker.filterText); + } else { + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + + // now let's actually set the filepicker's suggested filename + var suggestedFileName = GetSuggestedFileName(aDocumentURLString, aMIMEType); + if (suggestedFileName) { + fp.defaultString = suggestedFileName; + } + + // set the file picker's current directory + // assuming we have information needed (like prior saved location) + try { + var fileHandler = GetFileProtocolHandler(); + + var isLocalFile = true; + try { + let docURI = Services.io.newURI( + aDocumentURLString, + GetCurrentEditor().documentCharacterSet + ); + isLocalFile = docURI.schemeIs("file"); + } catch (e) {} + + var parentLocation = null; + if (isLocalFile) { + var fileLocation = fileHandler.getFileFromURLSpec(aDocumentURLString); // this asserts if url is not local + parentLocation = fileLocation.parent; + } + if (parentLocation) { + // Save current filepicker's default location + if ("gFilePickerDirectory" in window) { + gFilePickerDirectory = fp.displayDirectory; + } + + fp.displayDirectory = parentLocation; + } else { + // Initialize to the last-used directory for the particular type (saved in prefs) + SetFilePickerDirectory(fp, aEditorType); + } + } catch (e) {} + + return new Promise(resolve => { + fp.open(rv => { + dialogResult.filepickerClick = rv; + if (rv != Ci.nsIFilePicker.returnCancel && fp.file) { + // Allow OK and replace. + // reset urlstring to new save location + dialogResult.resultingURIString = fileHandler.getURLSpecFromActualFile( + fp.file + ); + dialogResult.resultingLocalFile = fp.file; + SaveFilePickerDirectory(fp, aEditorType); + resolve(dialogResult); + } else if ("gFilePickerDirectory" in window && gFilePickerDirectory) { + fp.displayDirectory = gFilePickerDirectory; + resolve(null); + } + }); + }); +} + +/** + * If needed, prompt for document title and set the document title to the + * preferred value. + * + * @returns true if the title was set up successfully; + * false if the user cancelled the title prompt + */ +function PromptAndSetTitleIfNone() { + if (GetDocumentTitle()) { + // we have a title; no need to prompt! + return true; + } + + let result = { value: null }; + let captionStr = GetString("DocumentTitle"); + let msgStr = GetString("NeedDocTitle") + "\n" + GetString("DocTitleHelp"); + let confirmed = Services.prompt.prompt( + window, + captionStr, + msgStr, + result, + null, + { value: 0 } + ); + if (confirmed) { + SetDocumentTitle(TrimString(result.value)); + } + + return confirmed; +} + +var gPersistObj; + +// Don't forget to do these things after calling OutputFileWithPersistAPI: +// we need to update the uri before notifying listeners +// UpdateWindowTitle(); +// if (!aSaveCopy) +// editor.resetModificationCount(); +// this should cause notification to listeners that document has changed + +function OutputFileWithPersistAPI( + editorDoc, + aDestinationLocation, + aRelatedFilesParentDir, + aMimeType +) { + gPersistObj = null; + var editor = GetCurrentEditor(); + try { + editor.forceCompositionEnd(); + } catch (e) {} + + var isLocalFile = false; + try { + aDestinationLocation.QueryInterface(Ci.nsIFile); + isLocalFile = true; + } catch (e) { + try { + var tmp = aDestinationLocation.QueryInterface(Ci.nsIURI); + isLocalFile = tmp.schemeIs("file"); + } catch (e) {} + } + + try { + // we should supply a parent directory if/when we turn on functionality to save related documents + var persistObj = Cc[ + "@mozilla.org/embedding/browser/nsWebBrowserPersist;1" + ].createInstance(Ci.nsIWebBrowserPersist); + persistObj.progressListener = gEditorOutputProgressListener; + + var wrapColumn = GetWrapColumn(); + var outputFlags = GetOutputFlags(aMimeType, wrapColumn); + + // for 4.x parity as well as improving readability of file locally on server + // this will always send crlf for upload (http/ftp) + if (!isLocalFile) { + // if we aren't saving locally then send both cr and lf + outputFlags |= + Ci.nsIWebBrowserPersist.ENCODE_FLAGS_CR_LINEBREAKS | + Ci.nsIWebBrowserPersist.ENCODE_FLAGS_LF_LINEBREAKS; + + // we want to serialize the output for all remote publishing + // some servers can handle only one connection at a time + // some day perhaps we can make this user-configurable per site? + persistObj.persistFlags = + persistObj.persistFlags | + Ci.nsIWebBrowserPersist.PERSIST_FLAGS_SERIALIZE_OUTPUT; + } + + // note: we always want to set the replace existing files flag since we have + // already given user the chance to not replace an existing file (file picker) + // or the user picked an option where the file is implicitly being replaced (save) + persistObj.persistFlags = + persistObj.persistFlags | + Ci.nsIWebBrowserPersist.PERSIST_FLAGS_NO_BASE_TAG_MODIFICATIONS | + Ci.nsIWebBrowserPersist.PERSIST_FLAGS_REPLACE_EXISTING_FILES | + Ci.nsIWebBrowserPersist.PERSIST_FLAGS_DONT_FIXUP_LINKS | + Ci.nsIWebBrowserPersist.PERSIST_FLAGS_DONT_CHANGE_FILENAMES | + Ci.nsIWebBrowserPersist.PERSIST_FLAGS_FIXUP_ORIGINAL_DOM; + persistObj.saveDocument( + editorDoc, + aDestinationLocation, + aRelatedFilesParentDir, + aMimeType, + outputFlags, + wrapColumn + ); + gPersistObj = persistObj; + } catch (e) { + dump("caught an error, bail\n"); + return false; + } + + return true; +} + +// returns output flags based on mimetype, wrapCol and prefs +function GetOutputFlags(aMimeType, aWrapColumn) { + var outputFlags = 0; + var editor = GetCurrentEditor(); + var outputEntity = + editor && editor.documentCharacterSet == "ISO-8859-1" + ? Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_LATIN1_ENTITIES + : Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES; + if (aMimeType == "text/plain") { + // When saving in "text/plain" format, always do formatting + outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_FORMATTED; + } else { + // Should we prettyprint? Check the pref + if (Services.prefs.getBoolPref("editor.prettyprint")) { + outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_FORMATTED; + } + + try { + // How much entity names should we output? Check the pref + switch (Services.prefs.getCharPref("editor.encode_entity")) { + case "basic": + outputEntity = + Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES; + break; + case "latin1": + outputEntity = + Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_LATIN1_ENTITIES; + break; + case "html": + outputEntity = + Ci.nsIWebBrowserPersist.ENCODE_FLAGS_ENCODE_HTML_ENTITIES; + break; + case "none": + outputEntity = 0; + break; + } + } catch (e) {} + } + outputFlags |= outputEntity; + + if (aWrapColumn > 0) { + outputFlags |= Ci.nsIWebBrowserPersist.ENCODE_FLAGS_WRAP; + } + + return outputFlags; +} + +// returns number of column where to wrap +function GetWrapColumn() { + try { + return GetCurrentEditor().wrapWidth; + } catch (e) {} + return 0; +} + +const gShowDebugOutputStateChange = false; +const gShowDebugOutputProgress = false; +const gShowDebugOutputStatusChange = false; + +const gShowDebugOutputLocationChange = false; +const gShowDebugOutputSecurityChange = false; + +const kErrorBindingAborted = 2152398850; +const kErrorBindingRedirected = 2152398851; +const kFileNotFound = 2152857618; + +var gEditorOutputProgressListener = { + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + // Use this to access onStateChange flags + var requestSpec; + try { + var channel = aRequest.QueryInterface(Ci.nsIChannel); + requestSpec = StripUsernamePasswordFromURI(channel.URI); + } catch (e) { + if (gShowDebugOutputStateChange) { + dump("***** onStateChange; NO REQUEST CHANNEL\n"); + } + } + + if (gShowDebugOutputStateChange) { + dump("\n***** onStateChange request: " + requestSpec + "\n"); + dump(" state flags: "); + + if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) { + dump(" STATE_START, "); + } + if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + dump(" STATE_STOP, "); + } + if (aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK) { + dump(" STATE_IS_NETWORK "); + } + + dump(`\n * requestSpec=${requestSpec}, aStatus=${aStatus}\n`); + + DumpDebugStatus(aStatus); + } + }, + + onProgressChange( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ) { + if (!gPersistObj) { + return; + } + + if (gShowDebugOutputProgress) { + dump( + "\n onProgressChange: gPersistObj.result=" + gPersistObj.result + "\n" + ); + try { + var channel = aRequest.QueryInterface(Ci.nsIChannel); + dump("***** onProgressChange request: " + channel.URI.spec + "\n"); + } catch (e) {} + dump( + "***** self: " + + aCurSelfProgress + + " / " + + aMaxSelfProgress + + "\n" + ); + dump( + "***** total: " + + aCurTotalProgress + + " / " + + aMaxTotalProgress + + "\n\n" + ); + + if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_READY) { + dump(" Persister is ready to save data\n\n"); + } else if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_SAVING) { + dump(" Persister is saving data.\n\n"); + } else if ( + gPersistObj.currentState == gPersistObj.PERSIST_STATE_FINISHED + ) { + dump(" PERSISTER HAS FINISHED SAVING DATA\n\n\n"); + } + } + }, + + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + if (gShowDebugOutputLocationChange) { + dump("***** onLocationChange: " + aLocation.spec + "\n"); + try { + var channel = aRequest.QueryInterface(Ci.nsIChannel); + dump("***** request: " + channel.URI.spec + "\n"); + } catch (e) {} + } + }, + + onStatusChange(aWebProgress, aRequest, aStatus, aMessage) { + if (gShowDebugOutputStatusChange) { + dump("***** onStatusChange: " + aMessage + "\n"); + try { + var channel = aRequest.QueryInterface(Ci.nsIChannel); + dump("***** request: " + channel.URI.spec + "\n"); + } catch (e) { + dump(" couldn't get request\n"); + } + + DumpDebugStatus(aStatus); + + if (gPersistObj) { + if (gPersistObj.currentState == gPersistObj.PERSIST_STATE_READY) { + dump(" Persister is ready to save data\n\n"); + } else if ( + gPersistObj.currentState == gPersistObj.PERSIST_STATE_SAVING + ) { + dump(" Persister is saving data.\n\n"); + } else if ( + gPersistObj.currentState == gPersistObj.PERSIST_STATE_FINISHED + ) { + dump(" PERSISTER HAS FINISHED SAVING DATA\n\n\n"); + } + } + } + }, + + onSecurityChange(aWebProgress, aRequest, state) { + if (gShowDebugOutputSecurityChange) { + try { + var channel = aRequest.QueryInterface(Ci.nsIChannel); + dump("***** onSecurityChange request: " + channel.URI.spec + "\n"); + } catch (e) {} + } + }, + + onContentBlockingEvent(aWebProgress, aRequest, aEvent) {}, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), +}; + +/* eslint-disable complexity */ +function DumpDebugStatus(aStatus) { + // see nsError.h and netCore.h and ftpCore.h + + if (aStatus == kErrorBindingAborted) { + dump("***** status is NS_BINDING_ABORTED\n"); + } else if (aStatus == kErrorBindingRedirected) { + dump("***** status is NS_BINDING_REDIRECTED\n"); + } else if (aStatus == 2152398859) { + // in netCore.h 11 + dump("***** status is ALREADY_CONNECTED\n"); + } else if (aStatus == 2152398860) { + // in netCore.h 12 + dump("***** status is NOT_CONNECTED\n"); + } else if (aStatus == 2152398861) { + // in nsISocketTransportService.idl 13 + dump("***** status is CONNECTION_REFUSED\n"); + } else if (aStatus == 2152398862) { + // in nsISocketTransportService.idl 14 + dump("***** status is NET_TIMEOUT\n"); + } else if (aStatus == 2152398863) { + // in netCore.h 15 + dump("***** status is IN_PROGRESS\n"); + } else if (aStatus == 2152398864) { + // 0x804b0010 in netCore.h 16 + dump("***** status is OFFLINE\n"); + } else if (aStatus == 2152398865) { + // in netCore.h 17 + dump("***** status is NO_CONTENT\n"); + } else if (aStatus == 2152398866) { + // in netCore.h 18 + dump("***** status is UNKNOWN_PROTOCOL\n"); + } else if (aStatus == 2152398867) { + // in netCore.h 19 + dump("***** status is PORT_ACCESS_NOT_ALLOWED\n"); + } else if (aStatus == 2152398868) { + // in nsISocketTransportService.idl 20 + dump("***** status is NET_RESET\n"); + } else if (aStatus == 2152398869) { + // in ftpCore.h 21 + dump("***** status is FTP_LOGIN\n"); + } else if (aStatus == 2152398870) { + // in ftpCore.h 22 + dump("***** status is FTP_CWD\n"); + } else if (aStatus == 2152398871) { + // in ftpCore.h 23 + dump("***** status is FTP_PASV\n"); + } else if (aStatus == 2152398872) { + // in ftpCore.h 24 + dump("***** status is FTP_PWD\n"); + } else if (aStatus == 2152857601) { + dump("***** status is UNRECOGNIZED_PATH\n"); + } else if (aStatus == 2152857602) { + dump("***** status is UNRESOLABLE SYMLINK\n"); + } else if (aStatus == 2152857604) { + dump("***** status is UNKNOWN_TYPE\n"); + } else if (aStatus == 2152857605) { + dump("***** status is DESTINATION_NOT_DIR\n"); + } else if (aStatus == 2152857606) { + dump("***** status is TARGET_DOES_NOT_EXIST\n"); + } else if (aStatus == 2152857608) { + dump("***** status is ALREADY_EXISTS\n"); + } else if (aStatus == 2152857609) { + dump("***** status is INVALID_PATH\n"); + } else if (aStatus == 2152857610) { + dump("***** status is DISK_FULL\n"); + } else if (aStatus == 2152857612) { + dump("***** status is NOT_DIRECTORY\n"); + } else if (aStatus == 2152857613) { + dump("***** status is IS_DIRECTORY\n"); + } else if (aStatus == 2152857614) { + dump("***** status is IS_LOCKED\n"); + } else if (aStatus == 2152857615) { + dump("***** status is TOO_BIG\n"); + } else if (aStatus == 2152857616) { + dump("***** status is NO_DEVICE_SPACE\n"); + } else if (aStatus == 2152857617) { + dump("***** status is NAME_TOO_LONG\n"); + } else if (aStatus == 2152857618) { + // 80520012 + dump("***** status is FILE_NOT_FOUND\n"); + } else if (aStatus == 2152857619) { + dump("***** status is READ_ONLY\n"); + } else if (aStatus == 2152857620) { + dump("***** status is DIR_NOT_EMPTY\n"); + } else if (aStatus == 2152857621) { + dump("***** status is ACCESS_DENIED\n"); + } else if (aStatus == 2152398878) { + dump("***** status is ? (No connection or time out?)\n"); + } else { + dump("***** status is " + aStatus + "\n"); + } +} +/* eslint-enable complexity */ + +const kSupportedTextMimeTypes = [ + "text/plain", + "text/css", + "text/rdf", + "text/xsl", + "text/javascript", // obsolete type + "text/ecmascript", // obsolete type + "application/javascript", + "application/ecmascript", + "application/x-javascript", // obsolete type + "application/xhtml+xml", +]; + +function IsSupportedTextMimeType(aMimeType) { + for (var i = 0; i < kSupportedTextMimeTypes.length; i++) { + if (kSupportedTextMimeTypes[i] == aMimeType) { + return true; + } + } + return false; +} + +/* eslint-disable complexity */ +// throws an error or returns true if user attempted save; false if user canceled save +async function SaveDocument(aSaveAs, aSaveCopy, aMimeType) { + var editor = GetCurrentEditor(); + if (!aMimeType || !editor) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + + var editorDoc = editor.document; + if (!editorDoc) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + + // if we don't have the right editor type bail (we handle text and html) + var editorType = GetCurrentEditorType(); + if (!["text", "html", "htmlmail", "textmail"].includes(editorType)) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + var saveAsTextFile = IsSupportedTextMimeType(aMimeType); + + // check if the file is to be saved is a format we don't understand; if so, bail + if ( + aMimeType != kHTMLMimeType && + aMimeType != kXHTMLMimeType && + !saveAsTextFile + ) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + if (saveAsTextFile) { + aMimeType = "text/plain"; + } + + var urlstring = GetDocumentUrl(); + var mustShowFileDialog = + aSaveAs || IsUrlAboutBlank(urlstring) || urlstring == ""; + + // If editing a remote URL, force SaveAs dialog + if (!mustShowFileDialog && GetScheme(urlstring) != "file") { + mustShowFileDialog = true; + } + + var doUpdateURI = false; + var tempLocalFile = null; + + if (mustShowFileDialog) { + try { + // Prompt for title if we are saving to HTML + if (!saveAsTextFile && editorType == "html") { + var userContinuing = PromptAndSetTitleIfNone(); // not cancel + if (!userContinuing) { + return false; + } + } + + var dialogResult = await PromptForSaveLocation( + saveAsTextFile, + editorType, + aMimeType, + urlstring + ); + if (!dialogResult) { + return false; + } + + // What is this unused 'replacing' var supposed to be doing? + /* eslint-disable-next-line no-unused-vars */ + var replacing = + dialogResult.filepickerClick == Ci.nsIFilePicker.returnReplace; + + urlstring = dialogResult.resultingURIString; + tempLocalFile = dialogResult.resultingLocalFile; + + // update the new URL for the webshell unless we are saving a copy + if (!aSaveCopy) { + doUpdateURI = true; + } + } catch (e) { + console.error(e); + return false; + } + } // mustShowFileDialog + + var success = true; + try { + // if somehow we didn't get a local file but we did get a uri, + // attempt to create the localfile if it's a "file" url + var docURI; + if (!tempLocalFile) { + docURI = Services.io.newURI(urlstring, editor.documentCharacterSet); + + if (docURI.schemeIs("file")) { + var fileHandler = GetFileProtocolHandler(); + tempLocalFile = fileHandler + .getFileFromURLSpec(urlstring) + .QueryInterface(Ci.nsIFile); + } + } + + // this is the location where the related files will go + var relatedFilesDir = null; + + // Only change links or move files if pref is set + // and we are saving to a new location + if (Services.prefs.getBoolPref("editor.save_associated_files") && aSaveAs) { + try { + if (tempLocalFile) { + // if we are saving to the same parent directory, don't set relatedFilesDir + // grab old location, chop off file + // grab new location, chop off file, compare + var oldLocation = GetDocumentUrl(); + var oldLocationLastSlash = oldLocation.lastIndexOf("/"); + if (oldLocationLastSlash != -1) { + oldLocation = oldLocation.slice(0, oldLocationLastSlash); + } + + var relatedFilesDirStr = urlstring; + var newLocationLastSlash = relatedFilesDirStr.lastIndexOf("/"); + if (newLocationLastSlash != -1) { + relatedFilesDirStr = relatedFilesDirStr.slice( + 0, + newLocationLastSlash + ); + } + if ( + oldLocation == relatedFilesDirStr || + IsUrlAboutBlank(oldLocation) + ) { + relatedFilesDir = null; + } else { + relatedFilesDir = tempLocalFile.parent; + } + } else { + var lastSlash = urlstring.lastIndexOf("/"); + if (lastSlash != -1) { + var relatedFilesDirString = urlstring.slice(0, lastSlash + 1); // include last slash + relatedFilesDir = Services.io.newURI( + relatedFilesDirString, + editor.documentCharacterSet + ); + } + } + } catch (e) { + relatedFilesDir = null; + } + } + + let destinationLocation = tempLocalFile ? tempLocalFile : docURI; + + success = OutputFileWithPersistAPI( + editorDoc, + destinationLocation, + relatedFilesDir, + aMimeType + ); + } catch (e) { + success = false; + } + + if (success) { + try { + if (doUpdateURI) { + // If a local file, we must create a new uri from nsIFile + if (tempLocalFile) { + docURI = GetFileProtocolHandler().newFileURI(tempLocalFile); + } + } + + // Update window title to show possibly different filename + // This also covers problem that after undoing a title change, + // window title loses the extra [filename] part that this adds + UpdateWindowTitle(); + + if (!aSaveCopy) { + editor.resetModificationCount(); + } + // this should cause notification to listeners that document has changed + + // Set UI based on whether we're editing a remote or local url + goUpdateCommand("cmd_save"); + } catch (e) {} + } else { + Services.prompt.alert( + window, + GetString("SaveDocument"), + GetString("SaveFileFailed") + ); + } + return success; +} +/* eslint-enable complexity */ + +var nsFindReplaceCommand = { + isCommandEnabled(aCommand, editorElement) { + return editorElement.getEditor(editorElement.contentWindow) != null; + }, + + getCommandStateParams(aCommand, aParams, editorElement) {}, + doCommandParams(aCommand, aParams, editorElement) {}, + + doCommand(aCommand, editorElement) { + window.openDialog( + "chrome://messenger/content/messengercompose/EdReplace.xhtml", + "_blank", + "chrome,modal,titlebar", + editorElement + ); + }, +}; + +var nsFindCommand = { + isCommandEnabled(aCommand, editorElement) { + return editorElement.getEditor(editorElement.contentWindow) != null; + }, + + getCommandStateParams(aCommand, aParams, editorElement) {}, + doCommandParams(aCommand, aParams, editorElement) {}, + + doCommand(aCommand, editorElement) { + document.getElementById("FindToolbar").onFindCommand(); + }, +}; + +var nsFindAgainCommand = { + isCommandEnabled(aCommand, editorElement) { + // we can only do this if the search pattern is non-empty. Not sure how + // to get that from here + return editorElement.getEditor(editorElement.contentWindow) != null; + }, + + getCommandStateParams(aCommand, aParams, editorElement) {}, + doCommandParams(aCommand, aParams, editorElement) {}, + + doCommand(aCommand, editorElement) { + let findPrev = aCommand == "cmd_findPrev"; + document.getElementById("FindToolbar").onFindAgainCommand(findPrev); + }, +}; + +var nsRewrapCommand = { + isCommandEnabled(aCommand, dummy) { + return ( + IsDocumentEditable() && + !IsInHTMLSourceMode() && + GetCurrentEditor() instanceof Ci.nsIEditorMailSupport + ); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + GetCurrentEditor().QueryInterface(Ci.nsIEditorMailSupport).rewrap(false); + }, +}; + +var nsSpellingCommand = { + isCommandEnabled(aCommand, dummy) { + return ( + IsDocumentEditable() && !IsInHTMLSourceMode() && IsSpellCheckerInstalled() + ); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + window.cancelSendMessage = false; + try { + var skipBlockQuotes = + window.document.documentElement.getAttribute("windowtype") == + "msgcompose"; + window.openDialog( + "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml", + "_blank", + "dialog,close,titlebar,modal,resizable", + false, + skipBlockQuotes, + true + ); + } catch (ex) {} + }, +}; + +var nsImageCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + window.openDialog( + "chrome://messenger/content/messengercompose/EdImageProps.xhtml", + "_blank", + "chrome,close,titlebar,modal" + ); + }, +}; + +var nsHLineCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + // Inserting an HLine is different in that we don't use properties dialog + // unless we are editing an existing line's attributes + // We get the last-used attributes from the prefs and insert immediately + + var tagName = "hr"; + var editor = GetCurrentEditor(); + + var hLine; + try { + hLine = editor.getSelectedElement(tagName); + } catch (e) { + return; + } + + if (hLine) { + // We only open the dialog for an existing HRule + window.openDialog( + "chrome://messenger/content/messengercompose/EdHLineProps.xhtml", + "_blank", + "chrome,close,titlebar,modal" + ); + } else { + try { + hLine = editor.createElementWithDefaults(tagName); + + // We change the default attributes to those saved in the user prefs + let align = Services.prefs.getIntPref("editor.hrule.align"); + if (align == 0) { + editor.setAttributeOrEquivalent(hLine, "align", "left", true); + } else if (align == 2) { + editor.setAttributeOrEquivalent(hLine, "align", "right", true); + } + + // Note: Default is center (don't write attribute) + + let width = Services.prefs.getIntPref("editor.hrule.width"); + if (Services.prefs.getBoolPref("editor.hrule.width_percent")) { + width = width + "%"; + } + + editor.setAttributeOrEquivalent(hLine, "width", width, true); + + let height = Services.prefs.getIntPref("editor.hrule.height"); + editor.setAttributeOrEquivalent(hLine, "size", String(height), true); + + if (Services.prefs.getBoolPref("editor.hrule.shading")) { + hLine.removeAttribute("noshade"); + } else { + hLine.setAttribute("noshade", "noshade"); + } + + editor.insertElementAtSelection(hLine, true); + } catch (e) {} + } + }, +}; + +var nsLinkCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + // If selected element is an image, launch that dialog instead + // since last tab panel handles link around an image + var element = GetObjectForProperties(); + if (element && element.nodeName.toLowerCase() == "img") { + window.openDialog( + "chrome://messenger/content/messengercompose/EdImageProps.xhtml", + "_blank", + "chrome,close,titlebar,modal", + null, + true + ); + } else { + window.openDialog( + "chrome://messenger/content/messengercompose/EdLinkProps.xhtml", + "_blank", + "chrome,close,titlebar,modal" + ); + } + }, +}; + +var nsAnchorCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + window.openDialog( + "chrome://messenger/content/messengercompose/EdNamedAnchorProps.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "" + ); + }, +}; + +var nsInsertHTMLWithDialogCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + gMsgCompose.allowRemoteContent = true; + window.openDialog( + "chrome://messenger/content/messengercompose/EdInsSrc.xhtml", + "_blank", + "chrome,close,titlebar,modal,resizable", + "" + ); + }, +}; + +var nsInsertMathWithDialogCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + window.openDialog( + "chrome://messenger/content/messengercompose/EdInsertMath.xhtml", + "_blank", + "chrome,close,titlebar,modal,resizable", + "" + ); + }, +}; + +var nsInsertCharsCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + EditorFindOrCreateInsertCharWindow(); + }, +}; + +var nsInsertBreakAllCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentEditor().insertHTML("<br clear='all'>"); + } catch (e) {} + }, +}; + +var nsListPropertiesCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + window.openDialog( + "chrome://messenger/content/messengercompose/EdListProps.xhtml", + "_blank", + "chrome,close,titlebar,modal" + ); + }, +}; + +var nsObjectPropertiesCommand = { + isCommandEnabled(aCommand, dummy) { + var isEnabled = false; + if (IsDocumentEditable() && IsEditingRenderedHTML()) { + isEnabled = + GetObjectForProperties() != null || + GetCurrentEditor().getSelectedElement("href") != null; + } + return isEnabled; + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + // Launch Object properties for appropriate selected element + var element = GetObjectForProperties(); + if (element) { + var name = element.nodeName.toLowerCase(); + switch (name) { + case "img": + gMsgCompose.allowRemoteContent = true; + goDoCommand("cmd_image"); + break; + case "hr": + goDoCommand("cmd_hline"); + break; + case "table": + EditorInsertOrEditTable(false); + break; + case "td": + case "th": + EditorTableCellProperties(); + break; + case "ol": + case "ul": + case "dl": + case "li": + goDoCommand("cmd_listProperties"); + break; + case "a": + if (element.name) { + goDoCommand("cmd_anchor"); + } else if (element.href) { + goDoCommand("cmd_link"); + } + break; + case "math": + goDoCommand("cmd_insertMathWithDialog"); + break; + default: + doAdvancedProperties(element); + break; + } + } else { + // We get a partially-selected link if asked for specifically + try { + element = GetCurrentEditor().getSelectedElement("href"); + } catch (e) {} + if (element) { + goDoCommand("cmd_link"); + } + } + }, +}; + +var nsSetSmiley = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) { + try { + let editor = GetCurrentEditor(); + let smileyCode = aParams.getStringValue("state_attribute"); + editor.insertHTML(smileyCode); + window.content.focus(); + } catch (e) { + dump("Exception occurred in smiley InsertElementAtSelection\n"); + } + }, + // This is now deprecated in favor of "doCommandParams" + doCommand(aCommand) {}, +}; + +function doAdvancedProperties(element) { + if (element) { + window.openDialog( + "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml", + "_blank", + "chrome,close,titlebar,modal,resizable=yes", + "", + element + ); + } +} + +var nsColorPropertiesCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + window.openDialog( + "chrome://messenger/content/messengercompose/EdColorProps.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "" + ); + UpdateDefaultColors(); + }, +}; + +var nsIncreaseFontCommand = { + isCommandEnabled(aCommand, dummy) { + if (!(IsDocumentEditable() && IsEditingRenderedHTML())) { + return false; + } + let setIndex = parseInt(getLegacyFontSize()); + return setIndex < 6; + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + let setIndex = parseInt(getLegacyFontSize()); + EditorSetFontSize((setIndex + 1).toString()); + }, +}; + +var nsDecreaseFontCommand = { + isCommandEnabled(aCommand, dummy) { + if (!(IsDocumentEditable() && IsEditingRenderedHTML())) { + return false; + } + let setIndex = parseInt(getLegacyFontSize()); + return setIndex > 1; + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + let setIndex = parseInt(getLegacyFontSize()); + EditorSetFontSize((setIndex - 1).toString()); + }, +}; + +var nsRemoveNamedAnchorsCommand = { + isCommandEnabled(aCommand, dummy) { + // We could see if there's any link in selection, but it doesn't seem worth the work! + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + EditorRemoveTextProperty("name", ""); + window.content.focus(); + }, +}; + +var nsInsertOrEditTableCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + if (IsInTableCell()) { + EditorTableCellProperties(); + } else { + EditorInsertOrEditTable(true); + } + }, +}; + +var nsEditTableCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTable(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + EditorInsertOrEditTable(false); + }, +}; + +var nsSelectTableCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTable(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().selectTable(); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsSelectTableRowCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().selectTableRow(); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsSelectTableColumnCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().selectTableColumn(); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsSelectTableCellCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().selectTableCell(); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsSelectAllTableCellsCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTable(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().selectAllTableCells(); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsInsertTableCommand = { + isCommandEnabled(aCommand, dummy) { + return IsDocumentEditable() && IsEditingRenderedHTML(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + EditorInsertTable(); + }, +}; + +var nsInsertTableRowAboveCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().insertTableRow(1, false); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsInsertTableRowBelowCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().insertTableRow(1, true); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsInsertTableColumnBeforeCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().insertTableColumn(1, false); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsInsertTableColumnAfterCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().insertTableColumn(1, true); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsInsertTableCellBeforeCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().insertTableCell(1, false); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsInsertTableCellAfterCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().insertTableCell(1, true); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsDeleteTableCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTable(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().deleteTable(); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsDeleteTableRowCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + var rows = GetNumberOfContiguousSelectedRows(); + // Delete at least one row + if (rows == 0) { + rows = 1; + } + + try { + var editor = GetCurrentTableEditor(); + editor.beginTransaction(); + + // Loop to delete all blocks of contiguous, selected rows + while (rows) { + editor.deleteTableRow(rows); + rows = GetNumberOfContiguousSelectedRows(); + } + } finally { + editor.endTransaction(); + } + window.content.focus(); + }, +}; + +var nsDeleteTableColumnCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + var columns = GetNumberOfContiguousSelectedColumns(); + // Delete at least one column + if (columns == 0) { + columns = 1; + } + + try { + var editor = GetCurrentTableEditor(); + editor.beginTransaction(); + + // Loop to delete all blocks of contiguous, selected columns + while (columns) { + editor.deleteTableColumn(columns); + columns = GetNumberOfContiguousSelectedColumns(); + } + } finally { + editor.endTransaction(); + } + window.content.focus(); + }, +}; + +var nsDeleteTableCellCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().deleteTableCell(1); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsDeleteTableCellContentsCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTableCell(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().deleteTableCellContents(); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsJoinTableCellsCommand = { + isCommandEnabled(aCommand, dummy) { + if (IsDocumentEditable() && IsEditingRenderedHTML()) { + try { + var editor = GetCurrentTableEditor(); + var tagNameObj = { value: "" }; + var countObj = { value: 0 }; + var cell = editor.getSelectedOrParentTableElement(tagNameObj, countObj); + + // We need a cell and either > 1 selected cell or a cell to the right + // (this cell may originate in a row spanned from above current row) + // Note that editor returns "td" for "th" also. + // (this is a pain! Editor and gecko use lowercase tagNames, JS uses uppercase!) + if (cell && tagNameObj.value == "td") { + // Selected cells + if (countObj.value > 1) { + return true; + } + + var colSpan = cell.getAttribute("colspan"); + + // getAttribute returns string, we need number + // no attribute means colspan = 1 + if (!colSpan) { + colSpan = Number(1); + } else { + colSpan = Number(colSpan); + } + + var rowObj = { value: 0 }; + var colObj = { value: 0 }; + editor.getCellIndexes(cell, rowObj, colObj); + + // Test if cell exists to the right of current cell + // (cells with 0 span should never have cells to the right + // if there is, user can select the 2 cells to join them) + return ( + colSpan && + editor.getCellAt(null, rowObj.value, colObj.value + colSpan) + ); + } + } catch (e) {} + } + return false; + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + // Param: Don't merge non-contiguous cells + try { + GetCurrentTableEditor().joinTableCells(false); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsSplitTableCellCommand = { + isCommandEnabled(aCommand, dummy) { + if (IsDocumentEditable() && IsEditingRenderedHTML()) { + var tagNameObj = { value: "" }; + var countObj = { value: 0 }; + var cell; + try { + cell = GetCurrentTableEditor().getSelectedOrParentTableElement( + tagNameObj, + countObj + ); + } catch (e) {} + + // We need a cell parent and there's just 1 selected cell + // or selection is entirely inside 1 cell + if ( + cell && + tagNameObj.value == "td" && + countObj.value <= 1 && + IsSelectionInOneCell() + ) { + var colSpan = cell.getAttribute("colspan"); + var rowSpan = cell.getAttribute("rowspan"); + if (!colSpan) { + colSpan = 1; + } + if (!rowSpan) { + rowSpan = 1; + } + return colSpan > 1 || rowSpan > 1 || colSpan == 0 || rowSpan == 0; + } + } + return false; + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + try { + GetCurrentTableEditor().splitTableCell(); + } catch (e) {} + window.content.focus(); + }, +}; + +var nsTableOrCellColorCommand = { + isCommandEnabled(aCommand, dummy) { + return IsInTable(); + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + EditorSelectColor("TableOrCell"); + }, +}; + +var nsConvertToTable = { + isCommandEnabled(aCommand, dummy) { + if (IsDocumentEditable() && IsEditingRenderedHTML()) { + var selection; + try { + selection = GetCurrentEditor().selection; + } catch (e) {} + + if (selection && !selection.isCollapsed) { + // Don't allow if table or cell is the selection + var element; + try { + element = GetCurrentEditor().getSelectedElement(""); + } catch (e) {} + if (element) { + var name = element.nodeName.toLowerCase(); + if ( + name == "td" || + name == "th" || + name == "caption" || + name == "table" + ) { + return false; + } + } + + // Selection start and end must be in the same cell + // in same cell or both are NOT in a cell + if ( + GetParentTableCell(selection.focusNode) != + GetParentTableCell(selection.anchorNode) + ) { + return false; + } + + return true; + } + } + return false; + }, + + getCommandStateParams(aCommand, aParams, aRefCon) {}, + doCommandParams(aCommand, aParams, aRefCon) {}, + + doCommand(aCommand) { + if (this.isCommandEnabled()) { + window.openDialog( + "chrome://messenger/content/messengercompose/EdConvertToTable.xhtml", + "_blank", + "chrome,close,titlebar,modal" + ); + } + }, +}; diff --git a/comm/mail/components/compose/content/MsgComposeCommands.js b/comm/mail/components/compose/content/MsgComposeCommands.js new file mode 100644 index 0000000000..6a0045b58d --- /dev/null +++ b/comm/mail/components/compose/content/MsgComposeCommands.js @@ -0,0 +1,11654 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../../../../../toolkit/content/contentAreaUtils.js */ +/* import-globals-from ../../../../mailnews/addrbook/content/abDragDrop.js */ +/* import-globals-from ../../../../mailnews/base/prefs/content/accountUtils.js */ +/* import-globals-from ../../../base/content/contentAreaClick.js */ +/* import-globals-from ../../../base/content/mailCore.js */ +/* import-globals-from ../../../base/content/messenger-customization.js */ +/* import-globals-from ../../../base/content/toolbarIconColor.js */ +/* import-globals-from ../../../base/content/utilityOverlay.js */ +/* import-globals-from ../../../base/content/viewZoomOverlay.js */ +/* import-globals-from ../../../base/content/widgets/browserPopups.js */ +/* import-globals-from ../../../extensions/openpgp/content/ui/keyAssistant.js */ +/* import-globals-from addressingWidgetOverlay.js */ +/* import-globals-from cloudAttachmentLinkManager.js */ +/* import-globals-from ComposerCommands.js */ +/* import-globals-from editor.js */ +/* import-globals-from editorUtilities.js */ + +/** + * Commands for the message composition window. + */ + +// Ensure the activity modules are loaded for this window. +ChromeUtils.import("resource:///modules/activity/activityModules.jsm"); +var { AttachmentChecker } = ChromeUtils.import( + "resource:///modules/AttachmentChecker.jsm" +); +var { cloudFileAccounts } = ChromeUtils.import( + "resource:///modules/cloudFileAccounts.jsm" +); +var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm"); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + FolderUtils: "resource:///modules/FolderUtils.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm", + BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm", + UIFontSize: "resource:///modules/UIFontSize.jsm", + UIDensity: "resource:///modules/UIDensity.jsm", +}); + +XPCOMUtils.defineLazyGetter( + this, + "l10nCompose", + () => + new Localization([ + "branding/brand.ftl", + "messenger/messengercompose/messengercompose.ftl", + ]) +); + +XPCOMUtils.defineLazyGetter( + this, + "l10nComposeSync", + () => + new Localization( + ["branding/brand.ftl", "messenger/messengercompose/messengercompose.ftl"], + true + ) +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PrintUtils", + "chrome://messenger/content/printUtils.js" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + MailStringUtils: "resource:///modules/MailStringUtils.jsm", +}); + +/** + * Global message window object. This is used by mail-offline.js and therefore + * should not be renamed. We need to avoid doing this kind of cross file global + * stuff in the future and instead pass this object as parameter when needed by + * functions in the other js file. + */ +var msgWindow; + +var gMessenger; + +/** + * Global variables, need to be re-initialized every time mostly because + * we need to release them when the window closes. + */ +var gMsgCompose; +var gOriginalMsgURI; +var gWindowLocked; +var gSendLocked; +var gContentChanged; +var gSubjectChanged; +var gAutoSaving; +var gCurrentIdentity; +var defaultSaveOperation; +var gSendOperationInProgress; +var gSaveOperationInProgress; +var gCloseWindowAfterSave; +var gSavedSendNowKey; +var gContextMenu; +var gLastFocusElement = null; +var gLoadingComplete = false; + +var gAttachmentBucket; +var gAttachmentCounter; +/** + * typedef {Object} FocusArea + * + * @property {Element} root - The root of a given area of the UI. + * @property {moveFocusWithin} focus - A method to move the focus within the + * root. + */ +/** + * @callback moveFocusWithin + * + * @param {Element} root - The element to move the focus within. + * + * @returns {boolean} - Whether the focus was successfully moved to within the + * given element. + */ +/** + * An ordered list of non-intersecting areas we want to jump focus between. + * Ordering should be in the same order as tab focus. See + * {@link moveFocusToNeighbouringArea}. + * + * @type {FocusArea[]} + */ +var gFocusAreas; +// TODO: Maybe the following two variables can be combined. +var gManualAttachmentReminder; +var gDisableAttachmentReminder; +var gComposeType; +var gLanguageObserver; +var gRecipientObserver; +var gWantCannotEncryptBCCNotification = true; +var gRecipientKeysObserver; +var gCheckPublicRecipientsTimer; +var gBodyFromArgs; + +// gSMFields is the nsIMsgComposeSecure instance for S/MIME. +// gMsgCompose.compFields.composeSecure is set to this instance most of +// the time. Because the S/MIME code has no knowledge of the OpenPGP +// implementation, gMsgCompose.compFields.composeSecure is set to an +// instance of PgpMimeEncrypt only temporarily. Keeping variable +// gSMFields separate allows switching as needed. +var gSMFields = null; + +var gSMPendingCertLookupSet = new Set(); +var gSMCertsAlreadyLookedUpInLDAP = new Set(); + +var gSelectedTechnologyIsPGP = false; + +// The initial flags store the value we used at composer open time. +// Some flags might be automatically changed as a consequence of other +// changes. When reverting automatic actions, the initial flags help +// us know what value we should use for restoring. + +var gSendSigned = false; + +var gAttachMyPublicPGPKey = false; + +var gSendEncrypted = false; + +// gEncryptSubject contains the preference for subject encryption, +// considered only if encryption is enabled and the technology allows it. +// In other words, gEncryptSubject might be set to true, but if +// encryption is disabled, or if S/MIME is used, +// gEncryptSubject==true is ignored. +var gEncryptSubject = false; + +var gUserTouchedSendEncrypted = false; +var gUserTouchedSendSigned = false; +var gUserTouchedAttachMyPubKey = false; +var gUserTouchedEncryptSubject = false; + +var gIsRelatedToEncryptedOriginal = false; + +var gOpened = Date.now(); + +var gEncryptedURIService = Cc[ + "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1" +].getService(Ci.nsIEncryptedSMIMEURIsService); + +try { + var gDragService = Cc["@mozilla.org/widget/dragservice;1"].getService( + Ci.nsIDragService + ); +} catch (e) {} + +/** + * Boolean variable to keep track of the dragging action of files above the + * compose window. + * + * @type {boolean} + */ +var gIsDraggingAttachments; + +/** + * Boolean variable to allow showing the attach inline overlay when dragging + * links that otherwise would only trigger the add as attachment overlay. + * + * @type {boolean} + */ +var gIsValidInline; + +// i18n globals +var _gComposeBundle; +function getComposeBundle() { + // That one has to be lazy. Getting a reference to an element with a XBL + // binding attached will cause the XBL constructors to fire if they haven't + // already. If we get a reference to the compose bundle at script load-time, + // this will cause the XBL constructor that's responsible for the personas to + // fire up, thus executing the personas code while the DOM is not fully built. + // Since this <script> comes before the <statusbar>, the Personas code will + // fail. + if (!_gComposeBundle) { + _gComposeBundle = document.getElementById("bundle_composeMsgs"); + } + return _gComposeBundle; +} + +var gLastWindowToHaveFocus; +var gLastKnownComposeStates; +var gReceiptOptionChanged; +var gDSNOptionChanged; +var gAttachVCardOptionChanged; + +var gAutoSaveInterval; +var gAutoSaveTimeout; +var gAutoSaveKickedIn; +var gEditingDraft; +var gNumUploadingAttachments; + +// From the user's point-of-view, is spell checking enabled? This value only +// changes if the user makes the change, it's not affected by the process of +// sending or saving the message or any other reason the actual state of the +// spellchecker might change. +var gSpellCheckingEnabled; + +var kComposeAttachDirPrefName = "mail.compose.attach.dir"; + +window.addEventListener("unload", event => { + ComposeUnload(); +}); +window.addEventListener("load", event => { + ComposeLoad(); +}); +window.addEventListener("close", event => { + if (!ComposeCanClose()) { + event.preventDefault(); + } +}); +window.addEventListener("focus", event => { + EditorOnFocus(); +}); +window.addEventListener("click", event => { + composeWindowOnClick(event); +}); + +document.addEventListener("focusin", event => { + // Listen for focusin event in composition. gLastFocusElement might well be + // null, e.g. when focusin enters a different document like contacts sidebar. + gLastFocusElement = event.relatedTarget; +}); + +// For WebExtensions. +this.__defineGetter__("browser", GetCurrentEditorElement); + +/** + * @implements {nsIXULBrowserWindow} + */ +var XULBrowserWindow = { + // Used to show the link-being-hovered-over in the status bar. Do nothing here. + setOverLink(url, anchorElt) {}, + + // Called before links are navigated to to allow us to retarget them if needed. + onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) { + return originalTarget; + }, + + // Called by BrowserParent::RecvShowTooltip. + showTooltip(xDevPix, yDevPix, tooltip, direction, browser) { + if ( + Cc["@mozilla.org/widget/dragservice;1"] + .getService(Ci.nsIDragService) + .getCurrentSession() + ) { + return; + } + + let elt = document.getElementById("remoteBrowserTooltip"); + elt.label = tooltip; + elt.style.direction = direction; + elt.openPopupAtScreen( + xDevPix / window.devicePixelRatio, + yDevPix / window.devicePixelRatio, + false, + null + ); + }, + + // Called by BrowserParent::RecvHideTooltip. + hideTooltip() { + let elt = document.getElementById("remoteBrowserTooltip"); + elt.hidePopup(); + }, + + getTabCount() { + return 1; + }, +}; +window + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.XULBrowserWindow; + +// Observer for the autocomplete input. +const inputObserver = { + observe: (subject, topic, data) => { + if (topic == "autocomplete-did-enter-text") { + let input = subject.QueryInterface( + Ci.nsIAutoCompleteInput + ).wrappedJSObject; + + // Interrupt if there's no input proxy, or the input doesn't have an ID, + // the latter meaning that the autocomplete event was triggered within an + // already existing pill, so we don't want to create a new pill. + if (!input || !input.id) { + return; + } + + // Trigger the pill creation. + recipientAddPills(document.getElementById(input.id)); + } + }, +}; + +const keyObserver = { + observe: async (subject, topic, data) => { + switch (topic) { + case "openpgp-key-change": + EnigmailKeyRing.clearCache(); + // fall through + case "openpgp-acceptance-change": + checkEncryptionState(topic); + gKeyAssistant.onExternalKeyChange(); + break; + default: + break; + } + }, +}; + +// Non translatable international shortcuts. +var SHOW_TO_KEY = "T"; +var SHOW_CC_KEY = "C"; +var SHOW_BCC_KEY = "B"; + +function InitializeGlobalVariables() { + gMessenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger); + + gMsgCompose = null; + gOriginalMsgURI = null; + gWindowLocked = false; + gContentChanged = false; + gSubjectChanged = false; + gCurrentIdentity = null; + defaultSaveOperation = "draft"; + gSendOperationInProgress = false; + gSaveOperationInProgress = false; + gAutoSaving = false; + gCloseWindowAfterSave = false; + gSavedSendNowKey = null; + gManualAttachmentReminder = false; + gDisableAttachmentReminder = false; + gLanguageObserver = null; + gRecipientObserver = null; + + gLastWindowToHaveFocus = null; + gLastKnownComposeStates = {}; + gReceiptOptionChanged = false; + gDSNOptionChanged = false; + gAttachVCardOptionChanged = false; + gNumUploadingAttachments = 0; + // eslint-disable-next-line no-global-assign + msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance( + Ci.nsIMsgWindow + ); + MailServices.mailSession.AddMsgWindow(msgWindow); + + // Add the observer. + Services.obs.addObserver(inputObserver, "autocomplete-did-enter-text"); + Services.obs.addObserver(keyObserver, "openpgp-key-change"); + Services.obs.addObserver(keyObserver, "openpgp-acceptance-change"); +} +InitializeGlobalVariables(); + +function ReleaseGlobalVariables() { + gCurrentIdentity = null; + gMsgCompose = null; + gOriginalMsgURI = null; + gMessenger = null; + gRecipientObserver = null; + gDisableAttachmentReminder = false; + _gComposeBundle = null; + MailServices.mailSession.RemoveMsgWindow(msgWindow); + // eslint-disable-next-line no-global-assign + msgWindow = null; + + gLastKnownComposeStates = null; + + // Remove the observers. + Services.obs.removeObserver(inputObserver, "autocomplete-did-enter-text"); + Services.obs.removeObserver(keyObserver, "openpgp-key-change"); + Services.obs.removeObserver(keyObserver, "openpgp-acceptance-change"); +} + +// Notification box shown at the bottom of the window. +XPCOMUtils.defineLazyGetter(this, "gComposeNotification", () => { + return new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "bottom"); + document.getElementById("compose-notification-bottom").append(element); + }); +}); + +/** + * Get the first next sibling element matching the selector (if specified). + * + * @param {HTMLElement} element - The source element whose sibling to look for. + * @param {string} [selector] - The CSS query selector to match. + * + * @returns {(HTMLElement|null)} - The first matching sibling element, or null. + */ +function getNextSibling(element, selector) { + let sibling = element.nextElementSibling; + if (!selector) { + // If there's no selector, return the first next sibling. + return sibling; + } + while (sibling) { + if (sibling.matches(selector)) { + // Return the current sibling if it matches the selector. + return sibling; + } + // Otherwise, continue the loop with the following next sibling. + sibling = sibling.nextElementSibling; + } + return null; +} + +/** + * Get the first previous sibling element matching the selector (if specified). + * + * @param {HTMLElement} element - The source element whose sibling to look for. + * @param {string} [selector] - The CSS query selector to match. + * + * @returns {(HTMLElement|null)} - The first matching sibling element, or null. + */ +function getPreviousSibling(element, selector) { + let sibling = element.previousElementSibling; + if (!selector) { + // If there's no selector, return the first previous sibling. + return sibling; + } + while (sibling) { + if (sibling.matches(selector)) { + // Return the current sibling if it matches the selector. + return sibling; + } + // Otherwise, continue the loop with the preceding previous sibling. + sibling = sibling.previousElementSibling; + } + return null; +} + +/** + * Get a pretty, human-readable shortcut key string from a given <key> id. + * + * @param aKeyId the ID of a <key> element + * @returns string pretty, human-readable shortcut key string from the <key> + */ +function getPrettyKey(aKeyId) { + return ShortcutUtils.prettifyShortcut(document.getElementById(aKeyId)); +} + +/** + * Disables or enables editable elements in the window. + * The elements to operate on are marked with the "disableonsend" attribute. + * This includes elements like the address list, attachment list, subject + * and message body. + * + * @param aDisable true = disable items. false = enable items. + */ +function updateEditableFields(aDisable) { + if (!gMsgCompose) { + return; + } + + if (aDisable) { + gMsgCompose.editor.flags |= Ci.nsIEditor.eEditorReadonlyMask; + } else { + gMsgCompose.editor.flags &= ~Ci.nsIEditor.eEditorReadonlyMask; + + try { + let checker = GetCurrentEditor().getInlineSpellChecker(true); + checker.enableRealTimeSpell = gSpellCheckingEnabled; + } catch (ex) { + // An error will be thrown if there are no dictionaries. Just ignore it. + } + } + + // Disable all the input fields and labels. + for (let element of document.querySelectorAll('[disableonsend="true"]')) { + element.disabled = aDisable; + } + + // Update the UI of the addressing rows. + for (let row of document.querySelectorAll(".address-container")) { + row.classList.toggle("disable-container", aDisable); + } + + // Prevent any interaction with the addressing pills. + for (let pill of document.querySelectorAll("mail-address-pill")) { + pill.toggleAttribute("disabled", aDisable); + } +} + +/** + * Small helper function to check whether the node passed in is a signature. + * Note that a text node is not a DOM element, hence .localName can't be used. + */ +function isSignature(aNode) { + return ( + ["DIV", "PRE"].includes(aNode.nodeName) && + aNode.classList.contains("moz-signature") + ); +} + +var stateListener = { + NotifyComposeFieldsReady() { + ComposeFieldsReady(); + updateSendCommands(true); + }, + + NotifyComposeBodyReady() { + // Look all the possible compose types (nsIMsgComposeParams.idl): + switch (gComposeType) { + case Ci.nsIMsgCompType.MailToUrl: + gBodyFromArgs = true; + // Falls through + case Ci.nsIMsgCompType.New: + case Ci.nsIMsgCompType.NewsPost: + case Ci.nsIMsgCompType.ForwardAsAttachment: + this.NotifyComposeBodyReadyNew(); + break; + + case Ci.nsIMsgCompType.Reply: + case Ci.nsIMsgCompType.ReplyAll: + case Ci.nsIMsgCompType.ReplyToSender: + case Ci.nsIMsgCompType.ReplyToGroup: + case Ci.nsIMsgCompType.ReplyToSenderAndGroup: + case Ci.nsIMsgCompType.ReplyWithTemplate: + case Ci.nsIMsgCompType.ReplyToList: + this.NotifyComposeBodyReadyReply(); + break; + + case Ci.nsIMsgCompType.Redirect: + case Ci.nsIMsgCompType.ForwardInline: + this.NotifyComposeBodyReadyForwardInline(); + break; + + case Ci.nsIMsgCompType.EditTemplate: + defaultSaveOperation = "template"; + break; + case Ci.nsIMsgCompType.Draft: + case Ci.nsIMsgCompType.Template: + case Ci.nsIMsgCompType.EditAsNew: + break; + + default: + dump( + "Unexpected nsIMsgCompType in NotifyComposeBodyReady (" + + gComposeType + + ")\n" + ); + } + + // Setting the selected item in the identity list will cause an + // identity/signature switch. This can only be done once the message + // body has already been assembled with the signature we need to switch. + if (gMsgCompose.identity != gCurrentIdentity) { + let identityList = document.getElementById("msgIdentity"); + identityList.selectedItem = identityList.getElementsByAttribute( + "identitykey", + gMsgCompose.identity.key + )[0]; + LoadIdentity(false); + } + if (gMsgCompose.composeHTML) { + loadHTMLMsgPrefs(); + } + AdjustFocus(); + }, + + NotifyComposeBodyReadyNew() { + let useParagraph = Services.prefs.getBoolPref( + "mail.compose.default_to_paragraph" + ); + let insertParagraph = gMsgCompose.composeHTML && useParagraph; + + let mailBody = getBrowser().contentDocument.querySelector("body"); + if (insertParagraph && gBodyFromArgs) { + // Check for "empty" body before allowing paragraph to be inserted. + // Non-empty bodies in a new message can occur when clicking on a + // mailto link or when using the command line option -compose. + // An "empty" body can be one of these three cases: + // 1) <br> and nothing follows (no next sibling) + // 2) <div/pre class="moz-signature"> + // 3) No elements, just text + // Note that <br><div/pre class="moz-signature"> doesn't happen in + // paragraph mode. + let firstChild = mailBody.firstChild; + let firstElementChild = mailBody.firstElementChild; + if (firstElementChild) { + if ( + (firstElementChild.nodeName != "BR" || + firstElementChild.nextElementSibling) && + !isSignature(firstElementChild) + ) { + insertParagraph = false; + } + } else if (firstChild && firstChild.nodeType == Node.TEXT_NODE) { + insertParagraph = false; + } + } + + // Control insertion of line breaks. + if (insertParagraph) { + let editor = GetCurrentEditor(); + editor.enableUndo(false); + + editor.selection.collapse(mailBody, 0); + let pElement = editor.createElementWithDefaults("p"); + pElement.appendChild(editor.createElementWithDefaults("br")); + editor.insertElementAtSelection(pElement, false); + + document.getElementById("cmd_paragraphState").setAttribute("state", "p"); + + editor.beginningOfDocument(); + editor.enableUndo(true); + editor.resetModificationCount(); + } else { + document.getElementById("cmd_paragraphState").setAttribute("state", ""); + } + onParagraphFormatChange(); + }, + + NotifyComposeBodyReadyReply() { + // Control insertion of line breaks. + let useParagraph = Services.prefs.getBoolPref( + "mail.compose.default_to_paragraph" + ); + if (gMsgCompose.composeHTML && useParagraph) { + let mailBody = getBrowser().contentDocument.querySelector("body"); + let editor = GetCurrentEditor(); + let selection = editor.selection; + + // Make sure the selection isn't inside the signature. + if (isSignature(mailBody.firstElementChild)) { + selection.collapse(mailBody, 0); + } + + let range = selection.getRangeAt(0); + let start = range.startOffset; + + if (start != range.endOffset) { + // The selection is not collapsed, most likely due to the + // "select the quote" option. In this case we do nothing. + return; + } + + if (range.startContainer != mailBody) { + dump("Unexpected selection in NotifyComposeBodyReadyReply\n"); + return; + } + + editor.enableUndo(false); + + let pElement = editor.createElementWithDefaults("p"); + pElement.appendChild(editor.createElementWithDefaults("br")); + editor.insertElementAtSelection(pElement, false); + + // Position into the paragraph. + selection.collapse(pElement, 0); + + document.getElementById("cmd_paragraphState").setAttribute("state", "p"); + + editor.enableUndo(true); + editor.resetModificationCount(); + } else { + document.getElementById("cmd_paragraphState").setAttribute("state", ""); + } + onParagraphFormatChange(); + }, + + NotifyComposeBodyReadyForwardInline() { + let mailBody = getBrowser().contentDocument.querySelector("body"); + let editor = GetCurrentEditor(); + let selection = editor.selection; + + editor.enableUndo(false); + + // Control insertion of line breaks. + selection.collapse(mailBody, 0); + let useParagraph = Services.prefs.getBoolPref( + "mail.compose.default_to_paragraph" + ); + if (gMsgCompose.composeHTML && useParagraph) { + let pElement = editor.createElementWithDefaults("p"); + let brElement = editor.createElementWithDefaults("br"); + pElement.appendChild(brElement); + editor.insertElementAtSelection(pElement, false); + document.getElementById("cmd_paragraphState").setAttribute("state", "p"); + } else { + // insertLineBreak() has been observed to insert two <br> elements + // instead of one before a <div>, so we'll do it ourselves here. + let brElement = editor.createElementWithDefaults("br"); + editor.insertElementAtSelection(brElement, false); + document.getElementById("cmd_paragraphState").setAttribute("state", ""); + } + + onParagraphFormatChange(); + editor.beginningOfDocument(); + editor.enableUndo(true); + editor.resetModificationCount(); + }, + + ComposeProcessDone(aResult) { + ToggleWindowLock(false); + + if (aResult == Cr.NS_OK) { + if (!gAutoSaving) { + SetContentAndBodyAsUnmodified(); + } + + if (gCloseWindowAfterSave) { + // Notify the SendListener that Send has been aborted and Stopped + if (gMsgCompose) { + gMsgCompose.onSendNotPerformed(null, Cr.NS_ERROR_ABORT); + } + + MsgComposeCloseWindow(); + } + } else if (gAutoSaving) { + // If we failed to save, and we're autosaving, need to re-mark the editor + // as changed, so that we won't lose the changes. + gMsgCompose.bodyModified = true; + gContentChanged = true; + } + gAutoSaving = false; + gCloseWindowAfterSave = false; + }, + + SaveInFolderDone(folderURI) { + DisplaySaveFolderDlg(folderURI); + }, +}; + +var gSendListener = { + // nsIMsgSendListener + onStartSending(aMsgID, aMsgSize) {}, + onProgress(aMsgID, aProgress, aProgressMax) {}, + onStatus(aMsgID, aMsg) {}, + onStopSending(aMsgID, aStatus, aMsg, aReturnFile) { + if (Components.isSuccessCode(aStatus)) { + Services.obs.notifyObservers(null, "mail:composeSendSucceeded", aMsgID); + } + }, + onGetDraftFolderURI(aMsgID, aFolderURI) {}, + onSendNotPerformed(aMsgID, aStatus) {}, + onTransportSecurityError(msgID, status, secInfo, location) { + // We're only interested in Bad Cert errors here. + let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService( + Ci.nsINSSErrorsService + ); + let errorClass = nssErrorsService.getErrorClass(status); + if (errorClass != Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { + return; + } + + // Give the user the option of adding an exception for the bad cert. + let params = { + exceptionAdded: false, + securityInfo: secInfo, + prefetchCert: true, + location, + }; + window.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + // params.exceptionAdded will be set if the user added an exception. + }, +}; + +// all progress notifications are done through the nsIWebProgressListener implementation... +var progressListener = { + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + let progressMeter = document.getElementById("compose-progressmeter"); + if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) { + progressMeter.hidden = false; + progressMeter.removeAttribute("value"); + } + + if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + gSendOperationInProgress = false; + gSaveOperationInProgress = false; + progressMeter.hidden = true; + progressMeter.value = 0; + document.getElementById("statusText").textContent = ""; + Services.obs.notifyObservers( + { composeWindow: window }, + "mail:composeSendProgressStop" + ); + } + }, + + onProgressChange( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ) { + // Calculate percentage. + var percent; + if (aMaxTotalProgress > 0) { + percent = Math.round((aCurTotalProgress * 100) / aMaxTotalProgress); + if (percent > 100) { + percent = 100; + } + + // Advance progress meter. + document.getElementById("compose-progressmeter").value = percent; + } else { + // Progress meter should be barber-pole in this case. + document.getElementById("compose-progressmeter").removeAttribute("value"); + } + }, + + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + // we can ignore this notification + }, + + onStatusChange(aWebProgress, aRequest, aStatus, aMessage) { + // Looks like it's possible that we get call while the document has been already delete! + // therefore we need to protect ourself by using try/catch + try { + let statusText = document.getElementById("statusText"); + if (statusText) { + statusText.textContent = aMessage; + } + } catch (ex) {} + }, + + onSecurityChange(aWebProgress, aRequest, state) { + // we can ignore this notification + }, + + onContentBlockingEvent(aWebProgress, aRequest, aEvent) { + // we can ignore this notification + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), +}; + +var defaultController = { + commands: { + cmd_attachFile: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + AttachFile(); + }, + }, + + cmd_attachCloud: { + isEnabled() { + // Hide the command entirely if there are no cloud accounts or + // the feature is disabled. + let cmd = document.getElementById("cmd_attachCloud"); + cmd.hidden = + !Services.prefs.getBoolPref("mail.cloud_files.enabled") || + cloudFileAccounts.configuredAccounts.length == 0 || + Services.io.offline; + return !cmd.hidden && !gWindowLocked; + }, + doCommand() { + // We should never actually call this, since the <command> node calls + // a different function. + }, + }, + + cmd_attachPage: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + gMsgCompose.allowRemoteContent = true; + AttachPage(); + }, + }, + + cmd_attachVCard: { + isEnabled() { + let cmd = document.getElementById("cmd_attachVCard"); + cmd.setAttribute("checked", gMsgCompose.compFields.attachVCard); + return !!gCurrentIdentity?.escapedVCard; + }, + doCommand() {}, + }, + + cmd_attachPublicKey: { + isEnabled() { + let cmd = document.getElementById("cmd_attachPublicKey"); + cmd.setAttribute("checked", gAttachMyPublicPGPKey); + return isPgpConfigured(); + }, + doCommand() {}, + }, + + cmd_toggleAttachmentPane: { + isEnabled() { + return !gWindowLocked && gAttachmentBucket.itemCount; + }, + doCommand() { + toggleAttachmentPane("toggle"); + }, + }, + + cmd_reorderAttachments: { + isEnabled() { + if (!gAttachmentBucket.itemCount) { + let reorderAttachmentsPanel = document.getElementById( + "reorderAttachmentsPanel" + ); + if (reorderAttachmentsPanel.state == "open") { + // When the panel is open and all attachments get deleted, + // we get notified here and want to close the panel. + reorderAttachmentsPanel.hidePopup(); + } + } + return gAttachmentBucket.itemCount > 1; + }, + doCommand() { + showReorderAttachmentsPanel(); + }, + }, + + cmd_removeAllAttachments: { + isEnabled() { + return !gWindowLocked && gAttachmentBucket.itemCount; + }, + doCommand() { + RemoveAllAttachments(); + }, + }, + + cmd_close: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + if (ComposeCanClose()) { + window.close(); + } + }, + }, + + cmd_saveDefault: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + Save(); + }, + }, + + cmd_saveAsFile: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + SaveAsFile(true); + }, + }, + + cmd_saveAsDraft: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + SaveAsDraft(); + }, + }, + + cmd_saveAsTemplate: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + SaveAsTemplate(); + }, + }, + + cmd_sendButton: { + isEnabled() { + return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked; + }, + doCommand() { + if (Services.io.offline) { + SendMessageLater(); + } else { + SendMessage(); + } + }, + }, + + cmd_sendNow: { + isEnabled() { + return ( + !gWindowLocked && + !Services.io.offline && + !gSendLocked && + !gNumUploadingAttachments + ); + }, + doCommand() { + SendMessage(); + }, + }, + + cmd_sendLater: { + isEnabled() { + return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked; + }, + doCommand() { + SendMessageLater(); + }, + }, + + cmd_sendWithCheck: { + isEnabled() { + return !gWindowLocked && !gNumUploadingAttachments && !gSendLocked; + }, + doCommand() { + SendMessageWithCheck(); + }, + }, + + cmd_print: { + isEnabled() { + return !gWindowLocked; + }, + doCommand() { + DoCommandPrint(); + }, + }, + + cmd_delete: { + isEnabled() { + let cmdDelete = document.getElementById("cmd_delete"); + let textValue = cmdDelete.getAttribute("valueDefault"); + let accesskeyValue = cmdDelete.getAttribute("valueDefaultAccessKey"); + + cmdDelete.setAttribute("label", textValue); + cmdDelete.setAttribute("accesskey", accesskeyValue); + + return false; + }, + doCommand() {}, + }, + + cmd_account: { + isEnabled() { + return true; + }, + doCommand() { + let currentAccountKey = getCurrentAccountKey(); + let account = MailServices.accounts.getAccount(currentAccountKey); + MsgAccountManager(null, account.incomingServer); + }, + }, + + cmd_showFormatToolbar: { + isEnabled() { + return gMsgCompose && gMsgCompose.composeHTML; + }, + doCommand() { + goToggleToolbar("FormatToolbar", "menu_showFormatToolbar"); + }, + }, + + cmd_quoteMessage: { + isEnabled() { + let selectedURIs = GetSelectedMessages(); + return selectedURIs && selectedURIs.length > 0; + }, + doCommand() { + QuoteSelectedMessage(); + }, + }, + + cmd_toggleReturnReceipt: { + isEnabled() { + if (!gMsgCompose) { + return false; + } + return !gWindowLocked; + }, + doCommand() { + ToggleReturnReceipt(); + }, + }, + + cmd_fullZoomReduce: { + isEnabled() { + return true; + }, + doCommand() { + ZoomManager.reduce(); + }, + }, + + cmd_fullZoomEnlarge: { + isEnabled() { + return true; + }, + doCommand() { + ZoomManager.enlarge(); + }, + }, + + cmd_fullZoomReset: { + isEnabled() { + return true; + }, + doCommand() { + ZoomManager.reset(); + }, + }, + + cmd_spelling: { + isEnabled() { + return true; + }, + doCommand() { + window.cancelSendMessage = false; + var skipBlockQuotes = + window.document.documentElement.getAttribute("windowtype") == + "msgcompose"; + window.openDialog( + "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml", + "_blank", + "dialog,close,titlebar,modal,resizable", + false, + skipBlockQuotes, + true + ); + }, + }, + + cmd_fullZoomToggle: { + isEnabled() { + return true; + }, + doCommand() { + ZoomManager.toggleZoom(); + }, + }, + }, + + supportsCommand(aCommand) { + return aCommand in this.commands; + }, + + isCommandEnabled(aCommand) { + if (!this.supportsCommand(aCommand)) { + return false; + } + return this.commands[aCommand].isEnabled(); + }, + + doCommand(aCommand) { + if (!this.supportsCommand(aCommand)) { + return; + } + var cmd = this.commands[aCommand]; + if (!cmd.isEnabled()) { + return; + } + cmd.doCommand(); + }, + + onEvent(event) {}, +}; + +var attachmentBucketController = { + commands: { + cmd_selectAll: { + isEnabled() { + return true; + }, + doCommand() { + gAttachmentBucket.selectAll(); + }, + }, + + cmd_delete: { + isEnabled() { + let cmdDelete = document.getElementById("cmd_delete"); + let textValue = getComposeBundle().getString("removeAttachmentMsgs"); + textValue = PluralForm.get(gAttachmentBucket.selectedCount, textValue); + let accesskeyValue = cmdDelete.getAttribute( + "valueRemoveAttachmentAccessKey" + ); + cmdDelete.setAttribute("label", textValue); + cmdDelete.setAttribute("accesskey", accesskeyValue); + + return gAttachmentBucket.selectedCount; + }, + doCommand() { + RemoveSelectedAttachment(); + }, + }, + + cmd_openAttachment: { + isEnabled() { + return gAttachmentBucket.selectedCount == 1; + }, + doCommand() { + OpenSelectedAttachment(); + }, + }, + + cmd_renameAttachment: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount == 1 && + !gAttachmentBucket.selectedItem.uploading + ); + }, + doCommand() { + RenameSelectedAttachment(); + }, + }, + + cmd_moveAttachmentLeft: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount && !attachmentsSelectionIsBlock("top") + ); + }, + doCommand() { + moveSelectedAttachments("left"); + }, + }, + + cmd_moveAttachmentRight: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount && + !attachmentsSelectionIsBlock("bottom") + ); + }, + doCommand() { + moveSelectedAttachments("right"); + }, + }, + + cmd_moveAttachmentBundleUp: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount > 1 && !attachmentsSelectionIsBlock() + ); + }, + doCommand() { + moveSelectedAttachments("bundleUp"); + }, + }, + + cmd_moveAttachmentBundleDown: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount > 1 && !attachmentsSelectionIsBlock() + ); + }, + doCommand() { + moveSelectedAttachments("bundleDown"); + }, + }, + + cmd_moveAttachmentTop: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount && !attachmentsSelectionIsBlock("top") + ); + }, + doCommand() { + moveSelectedAttachments("top"); + }, + }, + + cmd_moveAttachmentBottom: { + isEnabled() { + return ( + gAttachmentBucket.selectedCount && + !attachmentsSelectionIsBlock("bottom") + ); + }, + doCommand() { + moveSelectedAttachments("bottom"); + }, + }, + + cmd_sortAttachmentsToggle: { + isEnabled() { + let sortSelection; + let currSortOrder; + let isBlock; + let btnAscending; + let toggleCmd = document.getElementById("cmd_sortAttachmentsToggle"); + let toggleBtn = document.getElementById("btn_sortAttachmentsToggle"); + let sortDirection; + let btnLabelAttr; + + if ( + gAttachmentBucket.selectedCount > 1 && + gAttachmentBucket.selectedCount < gAttachmentBucket.itemCount + ) { + // Sort selected attachments only, which needs at least 2 of them, + // but not all. + sortSelection = true; + currSortOrder = attachmentsSelectionGetSortOrder(); + isBlock = attachmentsSelectionIsBlock(); + // If current sorting is ascending AND it's a block; OR + // if current sorting is descending AND it's NOT a block yet: + // Offer toggle button face to sort descending. + // In all other cases, offer toggle button face to sort ascending. + btnAscending = !( + (currSortOrder == "ascending" && isBlock) || + (currSortOrder == "descending" && !isBlock) + ); + // Set sortDirection for toggleCmd, and respective button face. + if (btnAscending) { + sortDirection = "ascending"; + btnLabelAttr = "label-selection-AZ"; + } else { + sortDirection = "descending"; + btnLabelAttr = "label-selection-ZA"; + } + } else { + // gAttachmentBucket.selectedCount <= 1 or all attachments are selected. + // Sort all attachments. + sortSelection = false; + currSortOrder = attachmentsGetSortOrder(); + btnAscending = !(currSortOrder == "ascending"); + // Set sortDirection for toggleCmd, and respective button face. + if (btnAscending) { + sortDirection = "ascending"; + btnLabelAttr = "label-AZ"; + } else { + sortDirection = "descending"; + btnLabelAttr = "label-ZA"; + } + } + + // Set the sort direction for toggleCmd. + toggleCmd.setAttribute("sortdirection", sortDirection); + // The button's icon is set dynamically via CSS involving the button's + // sortdirection attribute, which is forwarded by the command. + toggleBtn.setAttribute("label", toggleBtn.getAttribute(btnLabelAttr)); + + return sortSelection + ? !(currSortOrder == "equivalent" && isBlock) + : !(currSortOrder == "equivalent"); + }, + doCommand() { + moveSelectedAttachments("toggleSort"); + }, + }, + + cmd_convertCloud: { + isEnabled() { + // Hide the command entirely if Filelink is disabled, or if there are + // no cloud accounts. + let cmd = document.getElementById("cmd_convertCloud"); + + cmd.hidden = + !Services.prefs.getBoolPref("mail.cloud_files.enabled") || + cloudFileAccounts.configuredAccounts.length == 0 || + Services.io.offline; + if (cmd.hidden) { + return false; + } + + for (let item of gAttachmentBucket.selectedItems) { + if (item.uploading) { + return false; + } + } + return true; + }, + doCommand() { + // We should never actually call this, since the <command> node calls + // a different function. + }, + }, + + cmd_convertAttachment: { + isEnabled() { + if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) { + return false; + } + + for (let item of gAttachmentBucket.selectedItems) { + if (item.uploading) { + return false; + } + } + return true; + }, + doCommand() { + convertSelectedToRegularAttachment(); + }, + }, + + cmd_cancelUpload: { + isEnabled() { + let cmd = document.getElementById( + "composeAttachmentContext_cancelUploadItem" + ); + + // If Filelink is disabled, hide this menuitem and bailout. + if (!Services.prefs.getBoolPref("mail.cloud_files.enabled")) { + cmd.hidden = true; + return false; + } + + for (let item of gAttachmentBucket.selectedItems) { + if (item && item.uploading) { + cmd.hidden = false; + return true; + } + } + + // Hide the command entirely if the selected attachments aren't cloud + // files. + // For some reason, the hidden property isn't propagating from the cmd + // to the menuitem. + cmd.hidden = true; + return false; + }, + doCommand() { + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + + for (let item of gAttachmentBucket.selectedItems) { + if (item && item.uploading) { + let file = fileHandler.getFileFromURLSpec(item.attachment.url); + item.uploading.cancelFileUpload(window, file); + } + } + }, + }, + }, + + supportsCommand(aCommand) { + return aCommand in this.commands; + }, + + isCommandEnabled(aCommand) { + if (!this.supportsCommand(aCommand)) { + return false; + } + return this.commands[aCommand].isEnabled(); + }, + + doCommand(aCommand) { + if (!this.supportsCommand(aCommand)) { + return; + } + var cmd = this.commands[aCommand]; + if (!cmd.isEnabled()) { + return; + } + cmd.doCommand(); + }, + + onEvent(event) {}, +}; + +/** + * Start composing a new message. + */ +function goOpenNewMessage(aEvent) { + // If aEvent is passed, check if Shift key was pressed for composition in + // non-default format (HTML vs. plaintext). + let msgCompFormat = + aEvent && aEvent.shiftKey + ? Ci.nsIMsgCompFormat.OppositeOfDefault + : Ci.nsIMsgCompFormat.Default; + + MailServices.compose.OpenComposeWindow( + null, + null, + null, + Ci.nsIMsgCompType.New, + msgCompFormat, + gCurrentIdentity, + null, + null + ); +} + +function QuoteSelectedMessage() { + var selectedURIs = GetSelectedMessages(); + if (selectedURIs) { + gMsgCompose.allowRemoteContent = false; + for (let i = 0; i < selectedURIs.length; i++) { + gMsgCompose.quoteMessage(selectedURIs[i]); + } + } +} + +function GetSelectedMessages() { + let mailWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (!mailWindow) { + return null; + } + let tab = mailWindow.document.getElementById("tabmail").currentTabInfo; + if (tab.mode.name == "mail3PaneTab" && tab.message) { + return tab.chromeBrowser.contentWindow?.gDBView?.getURIsForSelection(); + } else if (tab.mode.name == "mailMessageTab") { + return [tab.messageURI]; + } + return null; +} + +function SetupCommandUpdateHandlers() { + top.controllers.appendController(defaultController); + gAttachmentBucket.controllers.appendController(attachmentBucketController); + + document + .getElementById("optionsMenuPopup") + .addEventListener("popupshowing", updateOptionItems, true); +} + +function UnloadCommandUpdateHandlers() { + document + .getElementById("optionsMenuPopup") + .removeEventListener("popupshowing", updateOptionItems, true); + + gAttachmentBucket.controllers.removeController(attachmentBucketController); + top.controllers.removeController(defaultController); +} + +function CommandUpdate_MsgCompose() { + var focusedWindow = top.document.commandDispatcher.focusedWindow; + + // we're just setting focus to where it was before + if (focusedWindow == gLastWindowToHaveFocus) { + return; + } + + gLastWindowToHaveFocus = focusedWindow; + updateComposeItems(); +} + +function findbarFindReplace() { + focusMsgBody(); + let findbar = document.getElementById("FindToolbar"); + findbar.close(); + goDoCommand("cmd_findReplace"); + findbar.open(); +} + +function updateComposeItems() { + try { + // Edit Menu + goUpdateCommand("cmd_rewrap"); + + // Insert Menu + if (gMsgCompose && gMsgCompose.composeHTML) { + goUpdateCommand("cmd_renderedHTMLEnabler"); + goUpdateCommand("cmd_fontColor"); + goUpdateCommand("cmd_backgroundColor"); + goUpdateCommand("cmd_decreaseFontStep"); + goUpdateCommand("cmd_increaseFontStep"); + goUpdateCommand("cmd_bold"); + goUpdateCommand("cmd_italic"); + goUpdateCommand("cmd_underline"); + goUpdateCommand("cmd_removeStyles"); + goUpdateCommand("cmd_ul"); + goUpdateCommand("cmd_ol"); + goUpdateCommand("cmd_indent"); + goUpdateCommand("cmd_outdent"); + goUpdateCommand("cmd_align"); + goUpdateCommand("cmd_smiley"); + } + + // Options Menu + goUpdateCommand("cmd_spelling"); + + // Workaround to update 'Quote' toolbar button. (See bug 609926.) + goUpdateCommand("cmd_quoteMessage"); + goUpdateCommand("cmd_toggleReturnReceipt"); + } catch (e) {} +} + +/** + * Disables or restores all toolbar items (menus/buttons) in the window. + * + * @param {boolean} disable - Meaning true = disable all items, false = restore + * items to the state stored before disabling them. + */ +function updateAllItems(disable) { + for (let item of document.querySelectorAll( + "menu, toolbarbutton, [command], [oncommand]" + )) { + if (disable) { + // Disable all items + item.setAttribute("stateBeforeSend", item.getAttribute("disabled")); + item.setAttribute("disabled", "disabled"); + } else { + // Restore initial state + let stateBeforeSend = item.getAttribute("stateBeforeSend"); + if (stateBeforeSend == "disabled" || stateBeforeSend == "true") { + item.setAttribute("disabled", stateBeforeSend); + } else { + item.removeAttribute("disabled"); + } + item.removeAttribute("stateBeforeSend"); + } + } +} + +function InitFileSaveAsMenu() { + document + .getElementById("cmd_saveAsFile") + .setAttribute("checked", defaultSaveOperation == "file"); + document + .getElementById("cmd_saveAsDraft") + .setAttribute("checked", defaultSaveOperation == "draft"); + document + .getElementById("cmd_saveAsTemplate") + .setAttribute("checked", defaultSaveOperation == "template"); +} + +function isSmimeSigningConfigured() { + return !!gCurrentIdentity?.getUnicharAttribute("signing_cert_name"); +} + +function isSmimeEncryptionConfigured() { + return !!gCurrentIdentity?.getUnicharAttribute("encryption_cert_name"); +} + +function isPgpConfigured() { + return !!gCurrentIdentity?.getUnicharAttribute("openpgp_key_id"); +} + +function toggleGlobalSignMessage() { + gSendSigned = !gSendSigned; + gUserTouchedSendSigned = true; + + updateAttachMyPubKey(); + showSendEncryptedAndSigned(); +} + +function updateAttachMyPubKey() { + if (!gUserTouchedAttachMyPubKey) { + if (gSendSigned) { + gAttachMyPublicPGPKey = gCurrentIdentity.attachPgpKey; + } else { + gAttachMyPublicPGPKey = false; + } + } +} + +function removeAutoDisableNotification() { + let notification = gComposeNotification.getNotificationWithValue( + "e2eeDisableNotification" + ); + if (notification) { + gComposeNotification.removeNotification(notification); + } +} + +function toggleEncryptMessage() { + gSendEncrypted = !gSendEncrypted; + + if (gSendEncrypted) { + removeAutoDisableNotification(); + } + + gUserTouchedSendEncrypted = true; + checkEncryptionState(); +} + +function toggleAttachMyPublicKey(target) { + gAttachMyPublicPGPKey = target.getAttribute("checked") != "true"; + target.setAttribute("checked", gAttachMyPublicPGPKey); + gUserTouchedAttachMyPubKey = true; +} + +function updateEncryptedSubject() { + let warnSubjectUnencrypted = + (!gSelectedTechnologyIsPGP && gSendEncrypted) || + (isPgpConfigured() && + gSelectedTechnologyIsPGP && + gSendEncrypted && + !gEncryptSubject); + + document + .getElementById("msgSubject") + .classList.toggle("with-icon", warnSubjectUnencrypted); + document.getElementById("msgEncryptedSubjectIcon").hidden = + !warnSubjectUnencrypted; +} + +function toggleEncryptedSubject() { + gEncryptSubject = !gEncryptSubject; + gUserTouchedEncryptSubject = true; + updateEncryptedSubject(); +} + +/** + * Update user interface elements + * + * @param {string} menu_id - suffix of the menu ID of the menu to update + */ +function setSecuritySettings(menu_id) { + let encItem = document.getElementById("menu_securityEncrypt" + menu_id); + encItem.setAttribute("checked", gSendEncrypted); + + let disableSig = false; + let disableEnc = false; + + if (gSelectedTechnologyIsPGP) { + if (!isPgpConfigured()) { + disableSig = true; + disableEnc = true; + } + } else { + if (!isSmimeSigningConfigured()) { + disableSig = true; + } + if (!isSmimeEncryptionConfigured()) { + disableEnc = true; + } + } + + let sigItem = document.getElementById("menu_securitySign" + menu_id); + sigItem.setAttribute("checked", gSendSigned && !disableSig); + + // The radio button to disable encryption is always active. + // This is necessary, even if the current identity doesn't have + // e2ee configured. If the user switches the sender identity of an + // email, we might keep encryption enabled, to not surprise the user. + // This means, we must always allow the user to disable encryption. + encItem.disabled = disableEnc && !gSendEncrypted; + + sigItem.disabled = disableSig; + + let pgpItem = document.getElementById("encTech_OpenPGP" + menu_id); + let smimeItem = document.getElementById("encTech_SMIME" + menu_id); + + smimeItem.disabled = + !isSmimeSigningConfigured() && !isSmimeEncryptionConfigured(); + + let encryptSubjectItem = document.getElementById( + `menu_securityEncryptSubject${menu_id}` + ); + + pgpItem.setAttribute("checked", gSelectedTechnologyIsPGP); + smimeItem.setAttribute("checked", !gSelectedTechnologyIsPGP); + encryptSubjectItem.setAttribute( + "checked", + !disableEnc && gSelectedTechnologyIsPGP && gSendEncrypted && gEncryptSubject + ); + encryptSubjectItem.setAttribute( + "disabled", + disableEnc || !gSelectedTechnologyIsPGP || !gSendEncrypted + ); + + document.getElementById("menu_recipientStatus" + menu_id).disabled = + disableEnc; + let manager = document.getElementById("menu_openManager" + menu_id); + manager.disabled = disableEnc; + manager.hidden = !gSelectedTechnologyIsPGP; +} + +/** + * Show the message security status based on the selected encryption technology. + * + * @param {boolean} [isSending=false] - If the key assistant was triggered + * during a sending attempt. + */ +function showMessageComposeSecurityStatus(isSending = false) { + if (gSelectedTechnologyIsPGP) { + if ( + Services.prefs.getBoolPref("mail.openpgp.key_assistant.enable", false) + ) { + gKeyAssistant.show(getEncryptionCompatibleRecipients(), isSending); + } else { + Recipients2CompFields(gMsgCompose.compFields); + window.openDialog( + "chrome://openpgp/content/ui/composeKeyStatus.xhtml", + "", + "chrome,modal,resizable,centerscreen", + { + compFields: gMsgCompose.compFields, + currentIdentity: gCurrentIdentity, + } + ); + checkEncryptionState(); + } + } else { + Recipients2CompFields(gMsgCompose.compFields); + // Copy current flags to S/MIME composeSecure object. + gMsgCompose.compFields.composeSecure.requireEncryptMessage = gSendEncrypted; + gMsgCompose.compFields.composeSecure.signMessage = gSendSigned; + window.openDialog( + "chrome://messenger-smime/content/msgCompSecurityInfo.xhtml", + "", + "chrome,modal,resizable,centerscreen", + { + compFields: gMsgCompose.compFields, + subject: document.getElementById("msgSubject").value, + isSigningCertAvailable: + gCurrentIdentity.getUnicharAttribute("signing_cert_name") != "", + isEncryptionCertAvailable: + gCurrentIdentity.getUnicharAttribute("encryption_cert_name") != "", + currentIdentity: gCurrentIdentity, + recipients: getEncryptionCompatibleRecipients(), + } + ); + } +} + +function msgComposeContextOnShowing(event) { + if (event.target.id != "msgComposeContext") { + return; + } + + // gSpellChecker handles all spell checking related to the context menu, + // except whether or not spell checking is enabled. We need the editor's + // spell checker for that. + gSpellChecker.initFromRemote( + nsContextMenu.contentData.spellInfo, + nsContextMenu.contentData.actor.manager + ); + + let canSpell = gSpellChecker.canSpellCheck; + let showDictionaries = canSpell && gSpellChecker.enabled; + let onMisspelling = gSpellChecker.overMisspelling; + let showUndo = canSpell && gSpellChecker.canUndo(); + + document.getElementById("spellCheckSeparator").hidden = !canSpell; + document.getElementById("spellCheckEnable").hidden = !canSpell; + document + .getElementById("spellCheckEnable") + .setAttribute("checked", canSpell && gSpellCheckingEnabled); + + document.getElementById("spellCheckAddToDictionary").hidden = !onMisspelling; + document.getElementById("spellCheckUndoAddToDictionary").hidden = !showUndo; + document.getElementById("spellCheckIgnoreWord").hidden = !onMisspelling; + + // Suggestion list. + document.getElementById("spellCheckSuggestionsSeparator").hidden = + !onMisspelling && !showUndo; + let separator = document.getElementById("spellCheckAddSep"); + separator.hidden = !onMisspelling; + if (onMisspelling) { + let addMenuItem = document.getElementById("spellCheckAddToDictionary"); + let suggestionCount = gSpellChecker.addSuggestionsToMenu( + addMenuItem.parentNode, + separator, + nsContextMenu.contentData.spellInfo.spellSuggestions + ); + document.getElementById("spellCheckNoSuggestions").hidden = + !suggestionCount == 0; + } else { + document.getElementById("spellCheckNoSuggestions").hidden = !false; + } + + // Dictionary list. + document.getElementById("spellCheckDictionaries").hidden = !showDictionaries; + if (canSpell) { + let dictMenu = document.getElementById("spellCheckDictionariesMenu"); + let dictSep = document.getElementById("spellCheckLanguageSeparator"); + let count = gSpellChecker.addDictionaryListToMenu(dictMenu, dictSep); + dictSep.hidden = count == 0; + document.getElementById("spellCheckAddDictionariesMain").hidden = !false; + } else if (this.onSpellcheckable) { + // when there is no spellchecker but we might be able to spellcheck + // add the add to dictionaries item. This will ensure that people + // with no dictionaries will be able to download them + document.getElementById("spellCheckLanguageSeparator").hidden = + !showDictionaries; + document.getElementById("spellCheckAddDictionariesMain").hidden = + !showDictionaries; + } else { + document.getElementById("spellCheckAddDictionariesMain").hidden = !false; + } + + updateEditItems(); + + // The rest of this block sends menu information to WebExtensions. + + let editor = GetCurrentEditorElement(); + let target = editor.contentDocument.elementFromPoint( + editor._contextX, + editor._contextY + ); + + let selectionInfo = SelectionUtils.getSelectionDetails(window); + let isContentSelected = !selectionInfo.docSelectionIsCollapsed; + let textSelected = selectionInfo.text; + let isTextSelected = !!textSelected.length; + + // Set up early the right flags for editable / not editable. + let editFlags = SpellCheckHelper.isEditable(target, window); + let onTextInput = (editFlags & SpellCheckHelper.TEXTINPUT) !== 0; + let onEditable = + (editFlags & + (SpellCheckHelper.EDITABLE | SpellCheckHelper.CONTENTEDITABLE)) !== + 0; + + let onImage = false; + let srcUrl = undefined; + + if (target.nodeType == Node.ELEMENT_NODE) { + if (target instanceof Ci.nsIImageLoadingContent && target.currentURI) { + onImage = true; + srcUrl = target.currentURI.spec; + } + } + + let onLink = false; + let linkText = undefined; + let linkUrl = undefined; + + let link = target.closest("a"); + if (link) { + onLink = true; + linkText = + link.textContent || + link.getAttribute("title") || + link.getAttribute("a") || + link.href || + ""; + linkUrl = link.href; + } + + let subject = { + menu: event.target, + tab: window, + isContentSelected, + isTextSelected, + onTextInput, + onLink, + onImage, + onEditable, + srcUrl, + linkText, + linkUrl, + selectionText: isTextSelected ? selectionInfo.fullText : undefined, + pageUrl: target.ownerGlobal.top.location.href, + onComposeBody: true, + }; + subject.context = subject; + subject.wrappedJSObject = subject; + + Services.obs.notifyObservers(subject, "on-prepare-contextmenu"); + Services.obs.notifyObservers(subject, "on-build-contextmenu"); +} + +function msgComposeContextOnHiding(event) { + if (event.target.id != "msgComposeContext") { + return; + } + + if (nsContextMenu.contentData.actor) { + nsContextMenu.contentData.actor.hiding(); + } + + nsContextMenu.contentData = null; + gSpellChecker.clearSuggestionsFromMenu(); + gSpellChecker.clearDictionaryListFromMenu(); + gSpellChecker.uninit(); +} + +function updateEditItems() { + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_pasteNoFormatting"); + goUpdateCommand("cmd_pasteQuote"); + goUpdateCommand("cmd_delete"); + goUpdateCommand("cmd_renameAttachment"); + goUpdateCommand("cmd_reorderAttachments"); + goUpdateCommand("cmd_selectAll"); + goUpdateCommand("cmd_openAttachment"); + goUpdateCommand("cmd_findReplace"); + goUpdateCommand("cmd_find"); + goUpdateCommand("cmd_findNext"); + goUpdateCommand("cmd_findPrev"); +} + +function updateViewItems() { + goUpdateCommand("cmd_toggleAttachmentPane"); +} + +function updateOptionItems() { + goUpdateCommand("cmd_quoteMessage"); + goUpdateCommand("cmd_toggleReturnReceipt"); +} + +function updateAttachmentItems() { + goUpdateCommand("cmd_toggleAttachmentPane"); + goUpdateCommand("cmd_attachCloud"); + goUpdateCommand("cmd_convertCloud"); + goUpdateCommand("cmd_convertAttachment"); + goUpdateCommand("cmd_cancelUpload"); + goUpdateCommand("cmd_delete"); + goUpdateCommand("cmd_removeAllAttachments"); + goUpdateCommand("cmd_renameAttachment"); + updateReorderAttachmentsItems(); + goUpdateCommand("cmd_selectAll"); + goUpdateCommand("cmd_openAttachment"); + goUpdateCommand("cmd_attachVCard"); + goUpdateCommand("cmd_attachPublicKey"); +} + +function updateReorderAttachmentsItems() { + goUpdateCommand("cmd_reorderAttachments"); + goUpdateCommand("cmd_moveAttachmentLeft"); + goUpdateCommand("cmd_moveAttachmentRight"); + goUpdateCommand("cmd_moveAttachmentBundleUp"); + goUpdateCommand("cmd_moveAttachmentBundleDown"); + goUpdateCommand("cmd_moveAttachmentTop"); + goUpdateCommand("cmd_moveAttachmentBottom"); + goUpdateCommand("cmd_sortAttachmentsToggle"); +} + +/** + * Update all the commands for sending a message to reflect their current state. + */ +function updateSendCommands(aHaveController) { + updateSendLock(); + if (aHaveController) { + goUpdateCommand("cmd_sendButton"); + goUpdateCommand("cmd_sendNow"); + goUpdateCommand("cmd_sendLater"); + goUpdateCommand("cmd_sendWithCheck"); + } else { + goSetCommandEnabled( + "cmd_sendButton", + defaultController.isCommandEnabled("cmd_sendButton") + ); + goSetCommandEnabled( + "cmd_sendNow", + defaultController.isCommandEnabled("cmd_sendNow") + ); + goSetCommandEnabled( + "cmd_sendLater", + defaultController.isCommandEnabled("cmd_sendLater") + ); + goSetCommandEnabled( + "cmd_sendWithCheck", + defaultController.isCommandEnabled("cmd_sendWithCheck") + ); + } + + let changed = false; + let currentStates = {}; + let changedStates = {}; + for (let state of ["cmd_sendNow", "cmd_sendLater"]) { + currentStates[state] = defaultController.isCommandEnabled(state); + if ( + !gLastKnownComposeStates.hasOwnProperty(state) || + gLastKnownComposeStates[state] != currentStates[state] + ) { + gLastKnownComposeStates[state] = currentStates[state]; + changedStates[state] = currentStates[state]; + changed = true; + } + } + if (changed) { + window.dispatchEvent( + new CustomEvent("compose-state-changed", { detail: changedStates }) + ); + } +} + +function addAttachCloudMenuItems(aParentMenu) { + while (aParentMenu.hasChildNodes()) { + aParentMenu.lastChild.remove(); + } + + for (let account of cloudFileAccounts.configuredAccounts) { + if ( + aParentMenu.lastElementChild && + aParentMenu.lastElementChild.cloudFileUpload + ) { + aParentMenu.appendChild(document.createXULElement("menuseparator")); + } + + let item = document.createXULElement("menuitem"); + let iconURL = account.iconURL; + item.cloudFileAccount = account; + item.setAttribute( + "label", + cloudFileAccounts.getDisplayName(account) + "\u2026" + ); + if (iconURL) { + item.setAttribute("class", `${item.localName}-iconic`); + item.setAttribute("image", iconURL); + } + aParentMenu.appendChild(item); + + let previousUploads = account.getPreviousUploads(); + let addedFiles = []; + for (let upload of previousUploads) { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(upload.path); + + // TODO: Figure out how to handle files that no longer exist on the filesystem. + if (!file.exists()) { + continue; + } + if (!addedFiles.find(f => f.name == upload.name || f.url == upload.url)) { + let fileItem = document.createXULElement("menuitem"); + fileItem.cloudFileUpload = upload; + fileItem.cloudFileAccount = account; + fileItem.setAttribute("label", upload.name); + fileItem.setAttribute("class", "menuitem-iconic"); + fileItem.setAttribute("image", "moz-icon://" + upload.name); + aParentMenu.appendChild(fileItem); + addedFiles.push({ name: upload.name, url: upload.url }); + } + } + } +} + +function addConvertCloudMenuItems(aParentMenu, aAfterNodeId, aRadioGroup) { + let afterNode = document.getElementById(aAfterNodeId); + while (afterNode.nextElementSibling) { + afterNode.nextElementSibling.remove(); + } + + if (!gAttachmentBucket.selectedItem.sendViaCloud) { + let item = document.getElementById( + "convertCloudMenuItems_popup_convertAttachment" + ); + item.setAttribute("checked", "true"); + } + + for (let account of cloudFileAccounts.configuredAccounts) { + let item = document.createXULElement("menuitem"); + let iconURL = account.iconURL; + item.cloudFileAccount = account; + item.setAttribute("label", cloudFileAccounts.getDisplayName(account)); + item.setAttribute("type", "radio"); + item.setAttribute("name", aRadioGroup); + + if ( + gAttachmentBucket.selectedItem.cloudFileAccount && + gAttachmentBucket.selectedItem.cloudFileAccount.accountKey == + account.accountKey + ) { + item.setAttribute("checked", "true"); + } else if (iconURL) { + item.setAttribute("class", "menu-iconic"); + item.setAttribute("image", iconURL); + } + + aParentMenu.appendChild(item); + } + + // Check if the cloudFile has an invalid account and deselect the default + // option, allowing to convert it back to a regular file. + if ( + gAttachmentBucket.selectedItem.attachment.sendViaCloud && + !gAttachmentBucket.selectedItem.cloudFileAccount + ) { + let regularItem = document.getElementById( + "convertCloudMenuItems_popup_convertAttachment" + ); + regularItem.removeAttribute("checked"); + } +} + +async function updateAttachmentItemProperties(attachmentItem) { + // FIXME: The UI logic should be handled by the attachment list or item + // itself. + if (attachmentItem.uploading) { + // uploading/renaming + attachmentItem.setAttribute( + "tooltiptext", + getComposeBundle().getFormattedString("cloudFileUploadingTooltip", [ + cloudFileAccounts.getDisplayName(attachmentItem.uploading), + ]) + ); + gAttachmentBucket.setCloudIcon(attachmentItem, ""); + } else if (attachmentItem.attachment.sendViaCloud) { + let [tooltipUnknownAccountText, introText, titleText] = + await document.l10n.formatValues([ + "cloud-file-unknown-account-tooltip", + { + id: "cloud-file-placeholder-intro", + args: { filename: attachmentItem.attachment.name }, + }, + { + id: "cloud-file-placeholder-title", + args: { filename: attachmentItem.attachment.name }, + }, + ]); + + // uploaded + let tooltiptext; + if (attachmentItem.cloudFileAccount) { + tooltiptext = getComposeBundle().getFormattedString( + "cloudFileUploadedTooltip", + [cloudFileAccounts.getDisplayName(attachmentItem.cloudFileAccount)] + ); + } else { + tooltiptext = tooltipUnknownAccountText; + } + attachmentItem.setAttribute("tooltiptext", tooltiptext); + + gAttachmentBucket.setAttachmentName( + attachmentItem, + attachmentItem.attachment.name + ); + gAttachmentBucket.setCloudIcon( + attachmentItem, + attachmentItem.cloudFileUpload.serviceIcon + ); + + // Update the CloudPartHeaderData, if there is a valid cloudFileUpload. + if (attachmentItem.cloudFileUpload) { + let json = JSON.stringify(attachmentItem.cloudFileUpload); + // Convert 16bit JavaScript string to a byteString, to make it work with + // btoa(). + attachmentItem.attachment.cloudPartHeaderData = btoa( + MailStringUtils.stringToByteString(json) + ); + } + + // Update the cloudFile placeholder file. + attachmentItem.attachment.htmlAnnotation = `<!DOCTYPE html> +<html> + <head> + <title>${titleText}</title> + <meta charset="utf-8" /> + </head> + <body> + <div style="padding: 15px; font-family: Calibri, sans-serif;"> + <div style="margin-bottom: 15px;" id="cloudAttachmentListHeader">${introText}</div> + <ul>${ + ( + await gCloudAttachmentLinkManager._createNode( + document, + attachmentItem.cloudFileUpload, + true + ) + ).outerHTML + }</ul> + </div> + </body> +</html>`; + + // Calculate size of placeholder attachment. + attachmentItem.cloudHtmlFileSize = new TextEncoder().encode( + attachmentItem.attachment.htmlAnnotation + ).length; + } else { + // local + attachmentItem.setAttribute("tooltiptext", attachmentItem.attachment.url); + gAttachmentBucket.setAttachmentName( + attachmentItem, + attachmentItem.attachment.name + ); + gAttachmentBucket.setCloudIcon(attachmentItem, ""); + + // Remove placeholder file size information. + delete attachmentItem.cloudHtmlFileSize; + } + updateAttachmentPane(); +} + +async function showLocalizedCloudFileAlert( + ex, + provider = ex.cloudProvider, + filename = ex.cloudFileName +) { + let bundle = getComposeBundle(); + let localizedTitle, localizedMessage; + + switch (ex.result) { + case cloudFileAccounts.constants.uploadCancelled: + // No alerts for cancelled uploads. + return; + case cloudFileAccounts.constants.deleteErr: + localizedTitle = bundle.getString("errorCloudFileDeletion.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileDeletion.message", + [provider, filename] + ); + break; + case cloudFileAccounts.constants.offlineErr: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-connection-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-connection-error", + { + provider, + } + ); + break; + case cloudFileAccounts.constants.authErr: + localizedTitle = bundle.getString("errorCloudFileAuth.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileAuth.message", + [provider] + ); + break; + case cloudFileAccounts.constants.uploadErrWithCustomMessage: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-upload-error-with-custom-message-title", + { + provider, + filename, + } + ); + localizedMessage = ex.message; + break; + case cloudFileAccounts.constants.uploadErr: + localizedTitle = bundle.getString("errorCloudFileUpload.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileUpload.message", + [provider, filename] + ); + break; + case cloudFileAccounts.constants.uploadWouldExceedQuota: + localizedTitle = bundle.getString("errorCloudFileQuota.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileQuota.message", + [provider, filename] + ); + break; + case cloudFileAccounts.constants.uploadExceedsFileLimit: + localizedTitle = bundle.getString("errorCloudFileLimit.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileLimit.message", + [provider, filename] + ); + break; + case cloudFileAccounts.constants.renameNotSupported: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-rename-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-rename-not-supported", + { + provider, + } + ); + break; + case cloudFileAccounts.constants.renameErrWithCustomMessage: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-rename-error-with-custom-message-title", + { + provider, + filename, + } + ); + localizedMessage = ex.message; + break; + case cloudFileAccounts.constants.renameErr: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-rename-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-rename-error", + { + provider, + filename, + } + ); + break; + case cloudFileAccounts.constants.attachmentErr: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-attachment-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-attachment-error", + { + filename, + } + ); + break; + case cloudFileAccounts.constants.accountErr: + localizedTitle = await l10nCompose.formatValue( + "cloud-file-account-error-title" + ); + localizedMessage = await l10nCompose.formatValue( + "cloud-file-account-error", + { + filename, + } + ); + break; + default: + localizedTitle = bundle.getString("errorCloudFileOther.title"); + localizedMessage = bundle.getFormattedString( + "errorCloudFileOther.message", + [provider] + ); + } + + Services.prompt.alert(window, localizedTitle, localizedMessage); +} + +/** + * @typedef UpdateSettings + * @property {CloudFileAccount} [cloudFileAccount] - cloud file account to store + * the attachment + * @property {CloudFileUpload} [relatedCloudFileUpload] - information about an + * already uploaded file this upload is related to, e.g. renaming a repeatedly + * used cloud file or updating the content of a cloud file + * @property {nsIFile} [file] - file to replace the current attachments content + * @property {string} [name] - name to replace the current attachments name + */ + +/** + * Update the name and or the content of an attachment, as well as its local/cloud + * state. + * + * @param {DOMNode} attachmentItem - the existing attachmentItem + * @param {UpdateSettings} [updateSettings] - object defining how to update the + * attachment + */ +async function UpdateAttachment(attachmentItem, updateSettings = {}) { + if (!attachmentItem || !attachmentItem.attachment) { + throw new Error("Unexpected: Invalid attachment item."); + } + + let originalAttachment = Object.assign({}, attachmentItem.attachment); + let eventOnDone = false; + + // Ignore empty or falsy names. + let name = updateSettings.name || attachmentItem.attachment.name; + + let destCloudFileAccount = updateSettings.hasOwnProperty("cloudFileAccount") + ? updateSettings.cloudFileAccount + : attachmentItem.cloudFileAccount; + + try { + if ( + // Bypass upload and set provided relatedCloudFileUpload. + updateSettings.relatedCloudFileUpload && + updateSettings.cloudFileAccount && + updateSettings.cloudFileAccount.reuseUploads && + !updateSettings.file && + !updateSettings.name + ) { + attachmentItem.attachment.sendViaCloud = true; + attachmentItem.attachment.contentLocation = + updateSettings.relatedCloudFileUpload.url; + attachmentItem.attachment.cloudFileAccountKey = + updateSettings.cloudFileAccount.accountKey; + + attachmentItem.cloudFileAccount = updateSettings.cloudFileAccount; + attachmentItem.cloudFileUpload = updateSettings.relatedCloudFileUpload; + gAttachmentBucket.setCloudIcon( + attachmentItem, + updateSettings.relatedCloudFileUpload.serviceIcon + ); + + eventOnDone = new CustomEvent("attachment-uploaded", { + bubbles: true, + cancelable: true, + }); + } else if ( + // Handle a local -> local replace/rename. + !attachmentItem.attachment.sendViaCloud && + !updateSettings.hasOwnProperty("cloudFileAccount") + ) { + // Both modes - rename and replace - require the same UI handling. + eventOnDone = new CustomEvent("attachment-renamed", { + bubbles: true, + cancelable: true, + detail: originalAttachment, + }); + } else if ( + // Handle a cloud -> local conversion. + attachmentItem.attachment.sendViaCloud && + updateSettings.cloudFileAccount === null + ) { + // Throw if the linked local file does not exists (i.e. invalid draft). + if (!(await IOUtils.exists(attachmentItem.cloudFileUpload.path))) { + throw Components.Exception( + `CloudFile Error: Attachment file not found: ${attachmentItem.cloudFileUpload.path}`, + cloudFileAccounts.constants.attachmentErr + ); + } + + if (attachmentItem.cloudFileAccount) { + // A cloud delete error is not considered to be a fatal error. It is + // not preventing the attachment from being removed from the composer. + attachmentItem.cloudFileAccount + .deleteFile(window, attachmentItem.cloudFileUpload.id) + .catch(ex => console.warn(ex.message)); + } + // Clean up attachment from cloud bits. + attachmentItem.attachment.sendViaCloud = false; + attachmentItem.attachment.htmlAnnotation = ""; + attachmentItem.attachment.contentLocation = ""; + attachmentItem.attachment.cloudFileAccountKey = ""; + attachmentItem.attachment.cloudPartHeaderData = ""; + delete attachmentItem.cloudFileAccount; + delete attachmentItem.cloudFileUpload; + + eventOnDone = new CustomEvent("attachment-converted-to-regular", { + bubbles: true, + cancelable: true, + detail: originalAttachment, + }); + } else if ( + // Exit early if offline. + Services.io.offline + ) { + throw Components.Exception( + "Connection error: Offline", + cloudFileAccounts.constants.offlineErr + ); + } else { + // Handle a cloud -> cloud move/rename or a local -> cloud upload. + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + + let mode = "upload"; + if (attachmentItem.attachment.sendViaCloud) { + // Throw if the used cloudFile account does not exists (invalid draft, + // disabled add-on, removed account). + if ( + !destCloudFileAccount || + !cloudFileAccounts.getAccount(destCloudFileAccount.accountKey) + ) { + throw Components.Exception( + `CloudFile Error: Account not found: ${destCloudFileAccount?.accountKey}`, + cloudFileAccounts.constants.accountErr + ); + } + + if ( + attachmentItem.cloudFileUpload && + attachmentItem.cloudFileAccount == destCloudFileAccount && + !updateSettings.file && + !destCloudFileAccount.isReusedUpload(attachmentItem.cloudFileUpload) + ) { + mode = "rename"; + } else { + mode = "move"; + // Throw if the linked local file does not exists (invalid draft, removed + // local file). + if ( + !fileHandler + .getFileFromURLSpec(attachmentItem.attachment.url) + .exists() + ) { + throw Components.Exception( + `CloudFile Error: Attachment file not found: ${ + fileHandler.getFileFromURLSpec(attachmentItem.attachment.url) + .path + }`, + cloudFileAccounts.constants.attachmentErr + ); + } + if (!(await IOUtils.exists(attachmentItem.cloudFileUpload.path))) { + throw Components.Exception( + `CloudFile Error: Attachment file not found: ${attachmentItem.cloudFileUpload.path}`, + cloudFileAccounts.constants.attachmentErr + ); + } + } + } + + // Notify the UI that we're starting the upload process: disable send commands + // and show a "connecting" icon for the attachment. + gNumUploadingAttachments++; + updateSendCommands(true); + + attachmentItem.uploading = destCloudFileAccount; + await updateAttachmentItemProperties(attachmentItem); + + const eventsOnStart = { + upload: "attachment-uploading", + move: "attachment-moving", + }; + if (eventsOnStart[mode]) { + attachmentItem.dispatchEvent( + new CustomEvent(eventsOnStart[mode], { + bubbles: true, + cancelable: true, + detail: attachmentItem.attachment, + }) + ); + } + + try { + let upload; + if (mode == "rename") { + upload = await destCloudFileAccount.renameFile( + window, + attachmentItem.cloudFileUpload.id, + name + ); + } else { + let file = + updateSettings.file || + fileHandler.getFileFromURLSpec(attachmentItem.attachment.url); + + upload = await destCloudFileAccount.uploadFile( + window, + file, + name, + updateSettings.relatedCloudFileUpload + ); + + attachmentItem.cloudFileAccount = destCloudFileAccount; + attachmentItem.attachment.sendViaCloud = true; + attachmentItem.attachment.cloudFileAccountKey = + destCloudFileAccount.accountKey; + + Services.telemetry.keyedScalarAdd( + "tb.filelink.uploaded_size", + destCloudFileAccount.type, + file.fileSize + ); + } + + attachmentItem.cloudFileUpload = upload; + attachmentItem.attachment.contentLocation = upload.url; + + const eventsOnSuccess = { + upload: "attachment-uploaded", + move: "attachment-moved", + rename: "attachment-renamed", + }; + if (eventsOnSuccess[mode]) { + eventOnDone = new CustomEvent(eventsOnSuccess[mode], { + bubbles: true, + cancelable: true, + detail: originalAttachment, + }); + } + } catch (ex) { + const eventsOnFailure = { + upload: "attachment-upload-failed", + move: "attachment-move-failed", + }; + if (eventsOnFailure[mode]) { + eventOnDone = new CustomEvent(eventsOnFailure[mode], { + bubbles: true, + cancelable: true, + detail: ex.result, + }); + } + throw ex; + } finally { + attachmentItem.uploading = false; + gNumUploadingAttachments--; + updateSendCommands(true); + } + } + + // Update the local attachment. + if (updateSettings.file) { + let attachment = FileToAttachment(updateSettings.file); + attachmentItem.attachment.size = attachment.size; + attachmentItem.attachment.url = attachment.url; + } + attachmentItem.attachment.name = name; + + AttachmentsChanged(); + // Update cmd_sortAttachmentsToggle because replacing/renaming may change the + // current sort order. + goUpdateCommand("cmd_sortAttachmentsToggle"); + } catch (ex) { + // Attach provider and fileName to the Exception, so showLocalizedCloudFileAlert() + // can display the proper alert message. + ex.cloudProvider = destCloudFileAccount + ? cloudFileAccounts.getDisplayName(destCloudFileAccount) + : ""; + ex.cloudFileName = originalAttachment?.name || name; + throw ex; + } finally { + await updateAttachmentItemProperties(attachmentItem); + if (eventOnDone) { + attachmentItem.dispatchEvent(eventOnDone); + } + } +} + +function attachToCloud(event) { + gMsgCompose.allowRemoteContent = true; + if (event.target.cloudFileUpload) { + attachToCloudRepeat( + event.target.cloudFileUpload, + event.target.cloudFileAccount + ); + } else { + attachToCloudNew(event.target.cloudFileAccount); + } + event.stopPropagation(); +} + +/** + * Attach a file that has already been uploaded to a cloud provider. + * + * @param {object} upload - the cloudFileUpload of the already uploaded file + * @param {object} account - the cloudFileAccount of the already uploaded file + */ +async function attachToCloudRepeat(upload, account) { + gMsgCompose.allowRemoteContent = true; + let file = FileUtils.File(upload.path); + let attachment = FileToAttachment(file); + attachment.name = upload.name; + + let addedAttachmentItems = await AddAttachments([attachment]); + if (addedAttachmentItems.length > 0) { + try { + await UpdateAttachment(addedAttachmentItems[0], { + cloudFileAccount: account, + relatedCloudFileUpload: upload, + }); + } catch (ex) { + showLocalizedCloudFileAlert(ex); + } + } +} + +/** + * Prompt the user for a list of files to attach via a cloud provider. + * + * @param aAccount the cloud provider to upload the files to + */ +async function attachToCloudNew(aAccount) { + // We need to let the user pick local file(s) to upload to the cloud and + // gather url(s) to those files. + var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init( + window, + getComposeBundle().getFormattedString("chooseFileToAttachViaCloud", [ + cloudFileAccounts.getDisplayName(aAccount), + ]), + Ci.nsIFilePicker.modeOpenMultiple + ); + + var lastDirectory = GetLastAttachDirectory(); + if (lastDirectory) { + fp.displayDirectory = lastDirectory; + } + + fp.appendFilters(Ci.nsIFilePicker.filterAll); + + let rv = await new Promise(resolve => fp.open(resolve)); + if (rv != Ci.nsIFilePicker.returnOK || !fp.files) { + return; + } + + let files = [...fp.files]; + let attachments = files.map(f => FileToAttachment(f)); + let addedAttachmentItems = await AddAttachments(attachments); + SetLastAttachDirectory(files[files.length - 1]); + + let promises = []; + for (let attachmentItem of addedAttachmentItems) { + promises.push( + UpdateAttachment(attachmentItem, { cloudFileAccount: aAccount }).catch( + ex => { + RemoveAttachments([attachmentItem]); + showLocalizedCloudFileAlert(ex); + } + ) + ); + } + + await Promise.all(promises); +} + +/** + * Convert an array of attachments to cloud attachments. + * + * @param aItems an array of <attachmentitem>s containing the attachments in + * question + * @param aAccount the cloud account to upload the files to + */ +async function convertListItemsToCloudAttachment(aItems, aAccount) { + gMsgCompose.allowRemoteContent = true; + let promises = []; + for (let item of aItems) { + // Bail out, if we would convert to the current account. + if ( + item.attachment.sendViaCloud && + item.cloudFileAccount && + item.cloudFileAccount == aAccount + ) { + continue; + } + promises.push( + UpdateAttachment(item, { cloudFileAccount: aAccount }).catch( + showLocalizedCloudFileAlert + ) + ); + } + await Promise.all(promises); +} + +/** + * Convert the selected attachments to cloud attachments. + * + * @param aAccount the cloud account to upload the files to + */ +function convertSelectedToCloudAttachment(aAccount) { + convertListItemsToCloudAttachment( + [...gAttachmentBucket.selectedItems], + aAccount + ); +} + +/** + * Convert an array of nsIMsgAttachments to cloud attachments. + * + * @param aAttachments an array of nsIMsgAttachments + * @param aAccount the cloud account to upload the files to + */ +function convertToCloudAttachment(aAttachments, aAccount) { + let items = []; + for (let attachment of aAttachments) { + let item = gAttachmentBucket.findItemForAttachment(attachment); + if (item) { + items.push(item); + } + } + + convertListItemsToCloudAttachment(items, aAccount); +} + +/** + * Convert an array of attachments to regular (non-cloud) attachments. + * + * @param aItems an array of <attachmentitem>s containing the attachments in + * question + */ +async function convertListItemsToRegularAttachment(aItems) { + let promises = []; + for (let item of aItems) { + if (!item.attachment.sendViaCloud) { + continue; + } + promises.push( + UpdateAttachment(item, { cloudFileAccount: null }).catch( + showLocalizedCloudFileAlert + ) + ); + } + await Promise.all(promises); +} + +/** + * Convert the selected attachments to regular (non-cloud) attachments. + */ +function convertSelectedToRegularAttachment() { + return convertListItemsToRegularAttachment([ + ...gAttachmentBucket.selectedItems, + ]); +} + +/** + * Convert an array of nsIMsgAttachments to regular (non-cloud) attachments. + * + * @param aAttachments an array of nsIMsgAttachments + */ +function convertToRegularAttachment(aAttachments) { + let items = []; + for (let attachment of aAttachments) { + let item = gAttachmentBucket.findItemForAttachment(attachment); + if (item) { + items.push(item); + } + } + + return convertListItemsToRegularAttachment(items); +} + +/* messageComposeOfflineQuitObserver is notified whenever the network + * connection status has switched to offline, or when the application + * has received a request to quit. + */ +var messageComposeOfflineQuitObserver = { + observe(aSubject, aTopic, aData) { + // sanity checks + if (aTopic == "network:offline-status-changed") { + MessageComposeOfflineStateChanged(Services.io.offline); + } else if ( + aTopic == "quit-application-requested" && + aSubject instanceof Ci.nsISupportsPRBool && + !aSubject.data + ) { + // Check whether to veto the quit request + // (unless another observer already did). + aSubject.data = !ComposeCanClose(); + } + }, +}; + +function AddMessageComposeOfflineQuitObserver() { + Services.obs.addObserver( + messageComposeOfflineQuitObserver, + "network:offline-status-changed" + ); + Services.obs.addObserver( + messageComposeOfflineQuitObserver, + "quit-application-requested" + ); + + // set the initial state of the send button + MessageComposeOfflineStateChanged(Services.io.offline); +} + +function RemoveMessageComposeOfflineQuitObserver() { + Services.obs.removeObserver( + messageComposeOfflineQuitObserver, + "network:offline-status-changed" + ); + Services.obs.removeObserver( + messageComposeOfflineQuitObserver, + "quit-application-requested" + ); +} + +function MessageComposeOfflineStateChanged(goingOffline) { + try { + var sendButton = document.getElementById("button-send"); + var sendNowMenuItem = document.getElementById("menu-item-send-now"); + + if (!gSavedSendNowKey) { + gSavedSendNowKey = sendNowMenuItem.getAttribute("key"); + } + + // don't use goUpdateCommand here ... the defaultController might not be installed yet + updateSendCommands(false); + + if (goingOffline) { + sendButton.label = sendButton.getAttribute("later_label"); + sendButton.setAttribute( + "tooltiptext", + sendButton.getAttribute("later_tooltiptext") + ); + sendNowMenuItem.removeAttribute("key"); + } else { + sendButton.label = sendButton.getAttribute("now_label"); + sendButton.setAttribute( + "tooltiptext", + sendButton.getAttribute("now_tooltiptext") + ); + if (gSavedSendNowKey) { + sendNowMenuItem.setAttribute("key", gSavedSendNowKey); + } + } + } catch (e) {} +} + +function DoCommandPrint() { + let browser = GetCurrentEditorElement(); + browser.contentDocument.title = + document.getElementById("msgSubject").value.trim() || + getComposeBundle().getString("defaultSubject"); + PrintUtils.startPrintWindow(browser.browsingContext, {}); +} + +/** + * Locks/Unlocks the window widgets while a message is being saved/sent. + * Locking means to disable all possible items in the window so that + * the user can't click/activate anything. + * + * @param aDisable true = lock the window. false = unlock the window. + */ +function ToggleWindowLock(aDisable) { + if (aDisable) { + // Save the active element so we can focus it again. + ToggleWindowLock.activeElement = document.activeElement; + } + gWindowLocked = aDisable; + updateAllItems(aDisable); + updateEditableFields(aDisable); + if (!aDisable) { + updateComposeItems(); + // Refocus what had focus when the lock began. + ToggleWindowLock.activeElement?.focus(); + } +} + +/* This function will go away soon as now arguments are passed to the window using a object of type nsMsgComposeParams instead of a string */ +function GetArgs(originalData) { + var args = {}; + + if (originalData == "") { + return null; + } + + var data = ""; + var separator = String.fromCharCode(1); + + var quoteChar = ""; + var prevChar = ""; + var nextChar = ""; + for (let i = 0; i < originalData.length; i++, prevChar = aChar) { + var aChar = originalData.charAt(i); + var aCharCode = originalData.charCodeAt(i); + if (i < originalData.length - 1) { + nextChar = originalData.charAt(i + 1); + } else { + nextChar = ""; + } + + if (aChar == quoteChar && (nextChar == "," || nextChar == "")) { + quoteChar = ""; + data += aChar; + } else if ((aCharCode == 39 || aCharCode == 34) && prevChar == "=") { + // quote or double quote + if (quoteChar == "") { + quoteChar = aChar; + } + data += aChar; + } else if (aChar == ",") { + if (quoteChar == "") { + data += separator; + } else { + data += aChar; + } + } else { + data += aChar; + } + } + + var pairs = data.split(separator); + // dump("Compose: argument: {" + data + "}\n"); + + for (let i = pairs.length - 1; i >= 0; i--) { + var pos = pairs[i].indexOf("="); + if (pos == -1) { + continue; + } + var argname = pairs[i].substring(0, pos); + var argvalue = pairs[i].substring(pos + 1); + if (argvalue.startsWith("'") && argvalue.endsWith("'")) { + args[argname] = argvalue.substring(1, argvalue.length - 1); + } else { + try { + args[argname] = decodeURIComponent(argvalue); + } catch (e) { + args[argname] = argvalue; + } + } + // dump("[" + argname + "=" + args[argname] + "]\n"); + } + return args; +} + +function ComposeFieldsReady() { + // If we are in plain text, we need to set the wrap column + if (!gMsgCompose.composeHTML) { + try { + gMsgCompose.editor.wrapWidth = gMsgCompose.wrapLength; + } catch (e) { + dump("### textEditor.wrapWidth exception text: " + e + " - failed\n"); + } + } + + CompFields2Recipients(gMsgCompose.compFields); + SetComposeWindowTitle(); + updateEditableFields(false); + gLoadingComplete = true; + + // Set up observers to recheck limit and encyption on recipients change. + observeRecipientsChange(); + + // Perform the initial checks. + checkPublicRecipientsLimit(); + checkEncryptionState(); +} + +/** + * Set up observers to recheck limit and encyption on recipients change. + */ +function observeRecipientsChange() { + // Observe childList changes of `To` and `Cc` address rows to check if we need + // to show the public bulk recipients notification according to the threshold. + // So far we're only counting recipient pills, not plain text addresses. + gRecipientObserver = new MutationObserver(function (mutations) { + if (mutations.some(m => m.type == "childList")) { + checkPublicRecipientsLimit(); + } + }); + gRecipientObserver.observe(document.getElementById("toAddrContainer"), { + childList: true, + }); + gRecipientObserver.observe(document.getElementById("ccAddrContainer"), { + childList: true, + }); + + function callCheckEncryptionState() { + // We must not pass the parameters that we get from observing. + checkEncryptionState(); + } + + gRecipientKeysObserver = new MutationObserver(callCheckEncryptionState); + gRecipientKeysObserver.observe(document.getElementById("toAddrContainer"), { + childList: true, + }); + gRecipientKeysObserver.observe(document.getElementById("ccAddrContainer"), { + childList: true, + }); + gRecipientKeysObserver.observe(document.getElementById("bccAddrContainer"), { + childList: true, + }); +} + +// checks if the passed in string is a mailto url, if it is, generates nsIMsgComposeParams +// for the url and returns them. +function handleMailtoArgs(mailtoUrl) { + // see if the string is a mailto url....do this by checking the first 7 characters of the string + if (mailtoUrl.toLowerCase().startsWith("mailto:")) { + // if it is a mailto url, turn the mailto url into a MsgComposeParams object.... + let uri = Services.io.newURI(mailtoUrl); + + if (uri) { + return MailServices.compose.getParamsForMailto(uri); + } + } + + return null; +} + +/** + * Handle ESC keypress from composition window for + * notifications with close button in the + * attachmentNotificationBox. + */ +function handleEsc() { + let activeElement = document.activeElement; + + if (activeElement.id == "messageEditor") { + // Focus within the message body. + let findbar = document.getElementById("FindToolbar"); + if (!findbar.hidden) { + // If findbar is visible hide it. + // Focus on the findbar is handled by findbar itself. + findbar.close(); + } else { + // Close the most recently shown notification. + gComposeNotification.currentNotification?.close(); + } + return; + } + + // If focus is within a notification, close the corresponding notification. + for (let notification of gComposeNotification.allNotifications) { + if (notification.contains(activeElement)) { + notification.close(); + return; + } + } +} + +/** + * This state machine manages all showing and hiding of the attachment + * notification bar. It is only called if any change happened so that + * recalculating of the notification is needed: + * - keywords changed + * - manual reminder was toggled + * - attachments changed + * - manual reminder is disabled + * + * It does not track whether the notification is still up when it should be. + * That allows the user to close it any time without this function showing + * it again. + * We ensure notification is only shown on right events, e.g. only when we have + * keywords and attachments were removed (but not when we have keywords and + * manual reminder was just turned off). We always show the notification + * again if keywords change (if no attachments and no manual reminder). + * + * @param aForce If set to true, notification will be shown immediately if + * there are any keywords. If set to false, it is shown only when + * they have changed. + */ +function manageAttachmentNotification(aForce = false) { + let keywords; + let keywordsCount = 0; + + // First see if the notification is to be hidden due to reasons other than + // not having keywords. + let removeNotification = attachmentNotificationSupressed(); + + // If that is not true, we need to look at the state of keywords. + if (!removeNotification) { + if (attachmentWorker.lastMessage) { + // We know the state of keywords, so process them. + if (attachmentWorker.lastMessage.length) { + keywords = attachmentWorker.lastMessage.join(", "); + keywordsCount = attachmentWorker.lastMessage.length; + } + removeNotification = keywordsCount == 0; + } else { + // We don't know keywords, so get them first. + // If aForce was true, and some keywords are found, we get to run again from + // attachmentWorker.onmessage(). + gAttachmentNotifier.redetectKeywords(aForce); + return; + } + } + + let notification = + gComposeNotification.getNotificationWithValue("attachmentReminder"); + if (removeNotification) { + if (notification) { + gComposeNotification.removeNotification(notification); + } + return; + } + + // We have some keywords, however only pop up the notification if requested + // to do so. + if (!aForce) { + return; + } + + let textValue = getComposeBundle().getString( + "attachmentReminderKeywordsMsgs" + ); + textValue = PluralForm.get(keywordsCount, textValue).replace( + "#1", + keywordsCount + ); + // If the notification already exists, we simply add the new attachment + // specific keywords to the existing notification instead of creating it + // from scratch. + if (notification) { + let msgContainer = notification.messageText.querySelector( + "#attachmentReminderText" + ); + msgContainer.textContent = textValue; + let keywordsContainer = notification.messageText.querySelector( + "#attachmentKeywords" + ); + keywordsContainer.textContent = keywords; + return; + } + + // Construct the notification as we don't have one. + let msg = document.createElement("div"); + msg.onclick = function (event) { + openOptionsDialog("paneCompose", "compositionAttachmentsCategory", { + subdialog: "attachment_reminder_button", + }); + }; + + let msgText = document.createElement("span"); + msg.appendChild(msgText); + msgText.id = "attachmentReminderText"; + msgText.textContent = textValue; + let msgKeywords = document.createElement("span"); + msg.appendChild(msgKeywords); + msgKeywords.id = "attachmentKeywords"; + msgKeywords.textContent = keywords; + let addButton = { + "l10n-id": "add-attachment-notification-reminder2", + callback(aNotificationBar, aButton) { + goDoCommand("cmd_attachFile"); + return true; // keep notification open (the state machine will decide on it later) + }, + }; + + let remindLaterMenuPopup = document.createXULElement("menupopup"); + remindLaterMenuPopup.id = "reminderBarPopup"; + let disableAttachmentReminder = document.createXULElement("menuitem"); + disableAttachmentReminder.id = "disableReminder"; + disableAttachmentReminder.setAttribute( + "label", + getComposeBundle().getString("disableAttachmentReminderButton") + ); + disableAttachmentReminder.addEventListener("command", event => { + gDisableAttachmentReminder = true; + toggleAttachmentReminder(false); + event.stopPropagation(); + }); + remindLaterMenuPopup.appendChild(disableAttachmentReminder); + + // The notification code only deals with buttons but we need a toolbarbutton, + // so we construct it and add it ourselves. + let remindButton = document.createXULElement("toolbarbutton", { + is: "toolbarbutton-menu-button", + }); + remindButton.classList.add("notification-button", "small-button"); + remindButton.setAttribute( + "accessKey", + getComposeBundle().getString("remindLaterButton.accesskey") + ); + remindButton.setAttribute( + "label", + getComposeBundle().getString("remindLaterButton") + ); + remindButton.addEventListener("command", function (event) { + toggleAttachmentReminder(true); + }); + remindButton.appendChild(remindLaterMenuPopup); + + notification = gComposeNotification.appendNotification( + "attachmentReminder", + { + label: "", + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + [addButton] + ); + notification.setAttribute("id", "attachmentNotificationBox"); + + notification.messageText.appendChild(msg); + notification.buttonContainer.appendChild(remindButton); +} + +function clearRecipPillKeyIssues() { + for (let pill of document.querySelectorAll("mail-address-pill.key-issue")) { + pill.classList.remove("key-issue"); + } +} + +/** + * @returns {string[]} - All current recipient email addresses, lowercase. + */ +function getEncryptionCompatibleRecipients() { + let recipientPills = [ + ...document.querySelectorAll( + "#toAddrContainer > mail-address-pill, #ccAddrContainer > mail-address-pill, #bccAddrContainer > mail-address-pill" + ), + ]; + let recipients = [ + ...new Set(recipientPills.map(pill => pill.emailAddress.toLowerCase())), + ]; + return recipients; +} + +const PRErrorCodeSuccess = 0; +const certificateUsageEmailRecipient = 0x0020; + +var gEmailsWithMissingKeys = null; +var gEmailsWithMissingCerts = null; + +/** + * @returns {boolean} true if checking openpgp keys is necessary + */ +function mustCheckRecipientKeys() { + let remindOpenPGP = Services.prefs.getBoolPref( + "mail.openpgp.remind_encryption_possible" + ); + + let autoEnablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_enable", + false + ); + + return ( + isPgpConfigured() && (gSendEncrypted || remindOpenPGP || autoEnablePref) + ); +} + +/** + * Check available OpenPGP public encryption keys for the given email + * addresses. (This function assumes the caller has already called + * mustCheckRecipientKeys() and the result was true.) + * + * gEmailsWithMissingKeys will be set to an array of email addresses + * (a subset of the input) that do NOT have a usable + * (valid + accepted) key. + * + * @param {string[]} recipients - The addresses to lookup. + */ +async function checkRecipientKeys(recipients) { + gEmailsWithMissingKeys = []; + + for (let addr of recipients) { + let keyMetas = await EnigmailKeyRing.getEncryptionKeyMeta(addr); + + if (keyMetas.length == 1 && keyMetas[0].readiness == "alias") { + // Skip if this is an alias email. + continue; + } + + if (!keyMetas.some(k => k.readiness == "accepted")) { + gEmailsWithMissingKeys.push(addr); + continue; + } + } +} + +/** + * @returns {boolean} true if checking s/mime certificates is necessary + */ +function mustCheckRecipientCerts() { + let remindSMime = Services.prefs.getBoolPref( + "mail.smime.remind_encryption_possible" + ); + + let autoEnablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_enable", + false + ); + + return ( + isSmimeEncryptionConfigured() && + (gSendEncrypted || remindSMime || autoEnablePref) + ); +} + +/** + * Check available S/MIME encryption certificates for the given email + * addresses. (This function assumes the caller has already called + * mustCheckRecipientCerts() and the result was true.) + * + * gEmailsWithMissingCerts will be set to an array of email addresses + * (a subset of the input) that do NOT have a usable (valid) certificate. + * + * This function might take significant time to complete, because + * certificate verification involves OCSP, which runs on a background + * thread. + * + * @param {string[]} recipients - The addresses to lookup. + */ +function checkRecipientCerts(recipients) { + return new Promise((resolve, reject) => { + if (gSMPendingCertLookupSet.size) { + reject( + new Error( + "Must not be called while previous checks are still in progress" + ) + ); + } + + gEmailsWithMissingCerts = []; + + function continueCheckRecipientCerts() { + gEmailsWithMissingCerts = recipients.filter( + email => !gSMFields.haveValidCertForEmail(email) + ); + resolve(); + } + + /** @implements {nsIDoneFindCertForEmailCallback} */ + let doneFindCertForEmailCallback = { + QueryInterface: ChromeUtils.generateQI([ + "nsIDoneFindCertForEmailCallback", + ]), + + findCertDone(email, cert) { + let isStaleResult = !gSMPendingCertLookupSet.has(email); + // isStaleResult true means, this recipient was removed by the + // user while we were looking for the cert in the background. + // Let's remember the result, but don't trigger any actions + // based on it. + + if (cert) { + gSMFields.cacheValidCertForEmail(email, cert ? cert.dbKey : ""); + } + if (isStaleResult) { + return; + } + gSMPendingCertLookupSet.delete(email); + if (!cert && !gSMCertsAlreadyLookedUpInLDAP.has(email)) { + let autocompleteLdap = Services.prefs.getBoolPref( + "ldap_2.autoComplete.useDirectory" + ); + + if (autocompleteLdap) { + gSMCertsAlreadyLookedUpInLDAP.add(email); + + let autocompleteDirectory = null; + if (gCurrentIdentity.overrideGlobalPref) { + autocompleteDirectory = gCurrentIdentity.directoryServer; + } else { + autocompleteDirectory = Services.prefs.getCharPref( + "ldap_2.autoComplete.directoryServer" + ); + } + + if (autocompleteDirectory) { + window.openDialog( + "chrome://messenger-smime/content/certFetchingStatus.xhtml", + "", + "chrome,resizable=1,modal=1,dialog=1", + autocompleteDirectory, + [email] + ); + } + + gSMPendingCertLookupSet.add(email); + gSMFields.asyncFindCertByEmailAddr( + email, + doneFindCertForEmailCallback + ); + } + } + + if (gSMPendingCertLookupSet.size) { + // must continue to wait for more queued lookups to complete + return; + } + + // No more lookups pending. + continueCheckRecipientCerts(); + }, + }; + + for (let email of recipients) { + if (gSMFields.haveValidCertForEmail(email)) { + continue; + } + + if (gSMPendingCertLookupSet.has(email)) { + throw new Error(`cert lookup still pending for ${email}`); + } + + gSMPendingCertLookupSet.add(email); + gSMFields.asyncFindCertByEmailAddr(email, doneFindCertForEmailCallback); + } + + // If we haven't queued any lookups, we continue immediately + if (!gSMPendingCertLookupSet.size) { + continueCheckRecipientCerts(); + } + }); +} + +/** + * gCheckEncryptionStateCompletionIsPending means that async work + * started by checkEncryptionState() has not yet completed. + */ +var gCheckEncryptionStateCompletionIsPending = false; + +/** + * gCheckEncryptionStateNeedsRestart means that checkEncryptionState() + * was called, while its async operations were still running. + * The additional to checkEncryptionState() was treated as a no-op, + * but gCheckEncryptionStateNeedsRestart was set to true, to remember + * that checkEncryptionState() must be immediately restarted after its + * previous execution is done. This will the restarted + * checkEncryptionState() execution to detect and handle changes that + * could result in a different state. + */ +var gCheckEncryptionStateNeedsRestart = false; + +/** + * gWasCESTriggeredByComposerChange is used to track whether an + * encryption-state-checked event should be sent after an ongoing + * execution of checkEncryptionState() is done. + * The purpose of the encryption-state-checked event is to allow our + * automated tests to be notified as soon as an automatic call to + * checkEncryptionState() (and all related async calls) is complete, + * which means all automatic adjustments to the global encryption state + * are done, and the automated test code may proceed to compare the + * state to our exptectations. + * We want that event to be sent after modifications were made to the + * composer window itself, such as sender identity and recipients. + * However, we want to ignore calls to checkEncryptionState() that + * were triggered indirectly after OpenPGP keys were changed. + * If an event was originally triggered by a change to OpenPGP keys, + * and the async processing of checkEncryptionState() was still running, + * and another direct change to the composer window was made, which + * shall result in sending a encryption-state-checked after completion, + * then the flag gWasCESTriggeredByComposerChange will be set, + * which will cause the event to be sent after the restarted call + * to checkEncryptionState() is complete. + */ +var gWasCESTriggeredByComposerChange = false; + +/** + * Perform all checks that are necessary to update the state of + * email encryption, based on the current recipients. This should be + * done whenever the recipient list or the status of available keys/certs + * has changed. All automatic actions for encryption related settings + * will be triggered accordingly. + * This function will trigger async activity, and the resulting actions + * (e.g. update of UI elements) may happen after a delay. + * It's safe to call this while processing hasn't completed yet, in this + * scenario the processing will be restarted, once pending + * activity has completed. + * + * @param {string} [trigger] - A string that gives information about + * the reason why this function is being called. + * This parameter is intended to help with automated testing. + * If the trigger string starts with "openpgp-" then no completition + * event will be dispatched. This allows the automated test code to + * wait for events that are directly related to properties of the + * composer window, only. + */ +async function checkEncryptionState(trigger) { + if (!gLoadingComplete) { + // Let's not do this while we're still loading the composer window, + // it can have side effects, see bug 1777683. + // Also, if multiple recipients are added to an email automatically + // e.g. during reply-all, it doesn't make sense to execute this + // function every time after one of them gets added. + return; + } + + if (!/^openpgp-/.test(trigger)) { + gWasCESTriggeredByComposerChange = true; + } + + if (gCheckEncryptionStateCompletionIsPending) { + // avoid concurrency + gCheckEncryptionStateNeedsRestart = true; + return; + } + + let remindSMime = Services.prefs.getBoolPref( + "mail.smime.remind_encryption_possible" + ); + let remindOpenPGP = Services.prefs.getBoolPref( + "mail.openpgp.remind_encryption_possible" + ); + let autoEnablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_enable", + false + ); + + if (!gSendEncrypted && !autoEnablePref && !remindSMime && !remindOpenPGP) { + // No need to check. + updateEncryptionDependencies(); + updateKeyCertNotifications([]); + updateEncryptionTechReminder(null); + if (gWasCESTriggeredByComposerChange) { + document.dispatchEvent(new CustomEvent("encryption-state-checked")); + gWasCESTriggeredByComposerChange = false; + } + return; + } + + let recipients = getEncryptionCompatibleRecipients(); + let checkingCerts = mustCheckRecipientCerts(); + let checkingKeys = mustCheckRecipientKeys(); + + async function continueCheckEncryptionStateSub() { + let canEncryptSMIME = + recipients.length && checkingCerts && !gEmailsWithMissingCerts.length; + let canEncryptOpenPGP = + recipients.length && checkingKeys && !gEmailsWithMissingKeys.length; + + let autoEnabledJustNow = false; + + if ( + gSendEncrypted && + gUserTouchedSendEncrypted && + !isPgpConfigured() && + !isSmimeEncryptionConfigured() + ) { + notifyIdentityCannotEncrypt(true, gCurrentIdentity.email); + } else { + notifyIdentityCannotEncrypt(false, gCurrentIdentity.email); + } + + if ( + !gSendEncrypted && + autoEnablePref && + !gUserTouchedSendEncrypted && + recipients.length && + (canEncryptSMIME || canEncryptOpenPGP) + ) { + if (!canEncryptSMIME) { + gSelectedTechnologyIsPGP = true; + } else if (!canEncryptOpenPGP) { + gSelectedTechnologyIsPGP = false; + } + gSendEncrypted = true; + autoEnabledJustNow = true; + removeAutoDisableNotification(); + } + + if ( + !gIsRelatedToEncryptedOriginal && + !autoEnabledJustNow && + !gUserTouchedSendEncrypted && + gSendEncrypted && + !canEncryptSMIME && + !canEncryptOpenPGP + ) { + // The auto_disable pref is ignored if auto_enable is false + let autoDisablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_disable", + false + ); + if (autoEnablePref && autoDisablePref && !gUserTouchedSendEncrypted) { + gSendEncrypted = false; + let notifyPref = Services.prefs.getBoolPref( + "mail.e2ee.notify_on_auto_disable", + true + ); + if (notifyPref) { + // Most likely the notification is not showing yet, and we + // must append it. (We should have removed an existing + // notification at the time encryption was enabled.) + // However, double check to avoid that we'll show it twice. + const NOTIFICATION_NAME = "e2eeDisableNotification"; + let notification = + gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME); + if (!notification) { + gComposeNotification.appendNotification( + NOTIFICATION_NAME, + { + label: { "l10n-id": "auto-disable-e2ee-warning" }, + priority: gComposeNotification.PRIORITY_WARNING_LOW, + }, + [] + ); + } + } + } + } + + let techPref = gCurrentIdentity.getIntAttribute("e2etechpref"); + + if (gSendEncrypted && canEncryptSMIME && canEncryptOpenPGP) { + // No change if 0 + if (techPref == 1) { + gSelectedTechnologyIsPGP = false; + } else if (techPref == 2) { + gSelectedTechnologyIsPGP = true; + } + } + + if ( + gSendEncrypted && + canEncryptSMIME && + !canEncryptOpenPGP && + gSelectedTechnologyIsPGP + ) { + gSelectedTechnologyIsPGP = false; + } + + if ( + gSendEncrypted && + !canEncryptSMIME && + canEncryptOpenPGP && + !gSelectedTechnologyIsPGP + ) { + gSelectedTechnologyIsPGP = true; + } + + updateEncryptionDependencies(); + + if (!gSendEncrypted) { + updateKeyCertNotifications([]); + if (recipients.length && (canEncryptSMIME || canEncryptOpenPGP)) { + let useTech; + if (canEncryptSMIME && canEncryptOpenPGP) { + if (techPref == 1) { + useTech = "SMIME"; + } else { + useTech = "OpenPGP"; + } + } else { + useTech = canEncryptOpenPGP ? "OpenPGP" : "SMIME"; + } + updateEncryptionTechReminder(useTech); + } else { + updateEncryptionTechReminder(null); + } + } else { + updateKeyCertNotifications( + gSelectedTechnologyIsPGP + ? gEmailsWithMissingKeys + : gEmailsWithMissingCerts + ); + updateEncryptionTechReminder(null); + } + + gCheckEncryptionStateCompletionIsPending = false; + + if (gCheckEncryptionStateNeedsRestart) { + // Recursive call, which is acceptable (and not blocking), + // because necessary long actions will be triggered asynchronously. + gCheckEncryptionStateNeedsRestart = false; + await checkEncryptionState(trigger); + } else if (gWasCESTriggeredByComposerChange) { + document.dispatchEvent(new CustomEvent("encryption-state-checked")); + gWasCESTriggeredByComposerChange = false; + } + } + + let pendingPromises = []; + + if (checkingCerts) { + pendingPromises.push(checkRecipientCerts(recipients)); + } + + if (checkingKeys) { + pendingPromises.push(checkRecipientKeys(recipients)); + } + + gCheckEncryptionStateNeedsRestart = false; + gCheckEncryptionStateCompletionIsPending = true; + + Promise.all(pendingPromises).then(continueCheckEncryptionStateSub); +} + +/** + * Display (or hide) the notification that informs the user that + * encryption is possible (but currently not enabled). + * + * @param {string} technology - The technology that is possible, + * ("OpenPGP" or "SMIME"), or null if none is possible. + */ +function updateEncryptionTechReminder(technology) { + let enableNotification = + gComposeNotification.getNotificationWithValue("enableNotification"); + if (enableNotification) { + gComposeNotification.removeNotification(enableNotification); + } + + if (!technology || (technology != "OpenPGP" && technology != "SMIME")) { + return; + } + + let labelId = + technology == "OpenPGP" + ? "can-encrypt-openpgp-notification" + : "can-encrypt-smime-notification"; + + gComposeNotification.appendNotification( + "enableNotification", + { + label: { "l10n-id": labelId }, + priority: gComposeNotification.PRIORITY_INFO_LOW, + }, + [ + { + "l10n-id": "can-e2e-encrypt-button", + callback() { + gSelectedTechnologyIsPGP = technology == "OpenPGP"; + gSendEncrypted = true; + gUserTouchedSendEncrypted = true; + checkEncryptionState(); + return true; + }, + }, + ] + ); +} + +/** + * Display (or hide) the notification that informs the user that + * encryption isn't possible, because the currently selected Sender + * (From) identity isn't configured for end-to-end-encryption. + * + * @param {boolean} show - Show if true, hide if false. + * @param {string} addr - email address to show in notification + */ +async function notifyIdentityCannotEncrypt(show, addr) { + const NOTIFICATION_NAME = "IdentityCannotEncrypt"; + + let notification = + gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME); + + if (show) { + if (!notification) { + gComposeNotification.appendNotification( + NOTIFICATION_NAME, + { + label: await document.l10n.formatValue( + "openpgp-key-issue-notification-from", + { + addr, + } + ), + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + [] + ); + } + } else if (notification) { + gComposeNotification.removeNotification(notification); + } +} + +/** + * Show an appropriate notification based on the given list of + * email addresses that cannot be used with email encryption + * (because of missing usable OpenPGP public keys or S/MIME certs). + * The list may be empty, which means no notification will be shown + * (or existing notifications will be removed). + * + * @param {string[]} emailsWithMissing - The email addresses that prevent + * using encryption, because certs/keys are missing. + */ +function updateKeyCertNotifications(emailsWithMissing) { + const NOTIFICATION_NAME = "keyNotification"; + + let notification = + gComposeNotification.getNotificationWithValue(NOTIFICATION_NAME); + if (notification) { + gComposeNotification.removeNotification(notification); + } + + // Always refresh the pills UI. + clearRecipPillKeyIssues(); + + // Interrupt if we don't have any issue. + if (!emailsWithMissing.length) { + return; + } + + // Update recipient pills. + for (let pill of document.querySelectorAll("mail-address-pill")) { + if ( + emailsWithMissing.includes(pill.emailAddress.toLowerCase()) && + !pill.classList.contains("invalid-address") + ) { + pill.classList.add("key-issue"); + } + } + + /** + * Display the new key notification. + */ + let buttons = []; + buttons.push({ + "l10n-id": "key-notification-disable-encryption", + callback() { + gUserTouchedSendEncrypted = true; + gSendEncrypted = false; + checkEncryptionState(); + return true; + }, + }); + + if (gSelectedTechnologyIsPGP) { + buttons.push({ + "l10n-id": "key-notification-resolve", + callback() { + showMessageComposeSecurityStatus(); + return true; + }, + }); + } + + let label; + + if (emailsWithMissing.length == 1) { + let id = gSelectedTechnologyIsPGP + ? "openpgp-key-issue-notification-single" + : "smime-cert-issue-notification-single"; + label = { + "l10n-id": id, + "l10n-args": { addr: emailsWithMissing[0] }, + }; + } else { + let id = gSelectedTechnologyIsPGP + ? "openpgp-key-issue-notification-multi" + : "smime-cert-issue-notification-multi"; + + label = { + "l10n-id": id, + "l10n-args": { count: emailsWithMissing.length }, + }; + } + + gComposeNotification.appendNotification( + NOTIFICATION_NAME, + { + label, + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + buttons + ); +} + +/** + * Returns whether the attachment notification should be suppressed regardless + * of the state of keywords. + */ +function attachmentNotificationSupressed() { + return ( + gDisableAttachmentReminder || + gManualAttachmentReminder || + gAttachmentBucket.getRowCount() + ); +} + +var attachmentWorker = new Worker("resource:///modules/AttachmentChecker.jsm"); + +// The array of currently found keywords. Or null if keyword detection wasn't +// run yet so we don't know. +attachmentWorker.lastMessage = null; + +attachmentWorker.onerror = function (error) { + console.error("Attachment Notification Worker error!!! " + error.message); + throw error; +}; + +/** + * Called when attachmentWorker finishes checking of the message for keywords. + * + * @param event If defined, event.data contains an array of found keywords. + * @param aManage If set to true and we determine keywords have changed, + * manage the notification. + * If set to false, just store the new keyword list but do not + * touch the notification. That effectively eats the + * "keywords changed" event which usually shows the notification + * if it was hidden. See manageAttachmentNotification(). + */ +attachmentWorker.onmessage = function (event, aManage = true) { + // Exit if keywords haven't changed. + if ( + !event || + (attachmentWorker.lastMessage && + event.data.toString() == attachmentWorker.lastMessage.toString()) + ) { + return; + } + + let data = event ? event.data : []; + attachmentWorker.lastMessage = data.slice(0); + if (aManage) { + manageAttachmentNotification(true); + } +}; + +/** + * Update attachment-related internal flags, UI, and commands. + * Called when number of attachments changes. + * + * @param aShowPane {string} "show": show the attachment pane + * "hide": hide the attachment pane + * omitted: just update without changing pane visibility + * @param aContentChanged {Boolean} optional value to assign to gContentChanged; + * defaults to true. + */ +function AttachmentsChanged(aShowPane, aContentChanged = true) { + gContentChanged = aContentChanged; + updateAttachmentPane(aShowPane); + manageAttachmentNotification(true); + updateAttachmentItems(); +} + +/** + * This functions returns an array of valid spellcheck languages. It checks + * that a dictionary exists for the language passed in, if any. It also + * retrieves the corresponding preference and ensures that a dictionary exists. + * If not, it adjusts the preference accordingly. + * When the nominated dictionary does not exist, the effects are very confusing + * to the user: Inline spell checking does not work, although the option is + * selected and a spell check dictionary seems to be selected in the options + * dialog (the dropdown shows the first list member if the value is not in + * the list). It is not at all obvious that the preference value is wrong. + * This case can happen two scenarios: + * 1) The dictionary that was selected in the preference is removed. + * 2) The selected dictionary changes the way it announces itself to the system, + * so for example "it_IT" changes to "it-IT" and the previously stored + * preference value doesn't apply any more. + * + * @param {string[]|null} [draftLanguages] - Languages that the message was + * composed in. + * @returns {string[]} + */ +function getValidSpellcheckerDictionaries(draftLanguages) { + let prefValue = Services.prefs.getCharPref("spellchecker.dictionary"); + let spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService( + Ci.mozISpellCheckingEngine + ); + let dictionaries = Array.from(new Set(prefValue?.split(","))); + + let dictList = spellChecker.getDictionaryList(); + let count = dictList.length; + + if (count == 0) { + // If there are no dictionaries, we can't check the value, so return it. + return dictionaries; + } + + // Make sure that the draft language contains a valid value. + if ( + draftLanguages && + draftLanguages.every(language => dictList.includes(language)) + ) { + return draftLanguages; + } + + // Make sure preference contains a valid value. + if (dictionaries.every(language => dictList.includes(language))) { + return dictionaries; + } + + // Set a valid value, any value will do. + Services.prefs.setCharPref("spellchecker.dictionary", dictList[0]); + return [dictList[0]]; +} + +var dictionaryRemovalObserver = { + observe(aSubject, aTopic, aData) { + if (aTopic != "spellcheck-dictionary-remove") { + return; + } + let spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService( + Ci.mozISpellCheckingEngine + ); + + let dictList = spellChecker.getDictionaryList(); + let languages = Array.from(gActiveDictionaries); + languages = languages.filter(lang => dictList.includes(lang)); + if (languages.length === 0) { + // Set a valid language from the preference. + let prefValue = Services.prefs.getCharPref("spellchecker.dictionary"); + let prefLanguages = prefValue?.split(",") ?? []; + languages = prefLanguages.filter(lang => dictList.includes(lang)); + if (prefLanguages.length != languages.length && languages.length > 0) { + // Fix the preference while we're here. We know it's invalid. + Services.prefs.setCharPref( + "spellchecker.dictionary", + languages.join(",") + ); + } + } + // Only update the language if we will still be left with any active choice. + if (languages.length > 0) { + ComposeChangeLanguage(languages); + } + }, + + isAdded: false, + + addObserver() { + Services.obs.addObserver(this, "spellcheck-dictionary-remove"); + this.isAdded = true; + }, + + removeObserver() { + if (this.isAdded) { + Services.obs.removeObserver(this, "spellcheck-dictionary-remove"); + this.isAdded = false; + } + }, +}; + +function EditorClick(event) { + if (event.target.matches(".remove-card")) { + let card = event.target.closest(".moz-card"); + let url = card.querySelector(".url").href; + if (card.matches(".url-replaced")) { + card.replaceWith(url); + } else { + card.remove(); + } + } else if (event.target.matches(`.add-card[data-opened='${gOpened}']`)) { + let url = event.target.getAttribute("data-url"); + let meRect = document.getElementById("messageEditor").getClientRects()[0]; + let settings = document.getElementById("linkPreviewSettings"); + let settingsW = 500; + settings.style.position = "fixed"; + settings.style.left = + Math.max(settingsW + 20, event.clientX) - settingsW + "px"; + settings.style.top = meRect.top + event.clientY + 20 + "px"; + settings.hidden = false; + event.target.remove(); + settings.querySelector(".close").onclick = event => { + settings.hidden = true; + }; + settings.querySelector(".preview-replace").onclick = event => { + addLinkPreview(url, true); + settings.hidden = true; + }; + settings.querySelector(".preview-autoadd").onclick = event => { + Services.prefs.setBoolPref( + "mail.compose.add_link_preview", + event.target.checked + ); + }; + settings.querySelector(".preview-replace").focus(); + settings.onkeydown = event => { + if (event.key == "Escape") { + settings.hidden = true; + } + }; + } +} + +/** + * Grab Open Graph or Twitter card data from the URL and insert a link preview + * into the editor. If no proper data could be found, nothing is inserted. + * + * @param {string} url - The URL to add preview for. + */ +async function addLinkPreview(url) { + return fetch(url) + .then(response => response.text()) + .then(text => { + let doc = new DOMParser().parseFromString(text, "text/html"); + + // If the url has an Open Graph or Twitter card, create a nicer + // representation and use that instead. + // @see https://ogp.me/ + // @see https://developer.twitter.com/en/docs/twitter-for-websites/cards/ + // Also look for standard meta information as a fallback. + + let title = + doc + .querySelector("meta[property='og:title'],meta[name='twitter:title']") + ?.getAttribute("content") || + doc.querySelector("title")?.textContent.trim(); + let description = doc + .querySelector( + "meta[property='og:description'],meta[name='twitter:description'],meta[name='description']" + ) + ?.getAttribute("content"); + + // Handle the case where we didn't get proper data. + if (!title && !description) { + console.debug(`No link preview data for url=${url}`); + return; + } + + let image = doc + .querySelector("meta[property='og:image']") + ?.getAttribute("content"); + let alt = + doc + .querySelector("meta[property='og:image:alt']") + ?.getAttribute("content") || ""; + if (!image) { + image = doc + .querySelector("meta[name='twitter:image']") + ?.getAttribute("content"); + alt = + doc + .querySelector("meta[name='twitter:image:alt']") + ?.getAttribute("content") || ""; + } + let imgIsTouchIcon = false; + if (!image) { + image = doc + .querySelector( + `link[rel='icon']:is( + [sizes~='any'], + [sizes~='196x196' i], + [sizes~='192x192' i] + [sizes~='180x180' i], + [sizes~='128x128' i] + )` + ) + ?.getAttribute("href"); + alt = ""; + imgIsTouchIcon = Boolean(image); + } + + // Grab our template and fill in the variables. + let card = document + .getElementById("dataCardTemplate") + .content.cloneNode(true).firstElementChild; + card.id = "card-" + Date.now(); + card.querySelector("img").src = image; + card.querySelector("img").alt = alt; + card.querySelector(".title").textContent = title; + + card.querySelector(".description").textContent = description; + card.querySelector(".url").textContent = "🔗 " + url; + card.querySelector(".url").href = url; + card.querySelector(".url").title = new URL(url).hostname; + card.querySelector(".site").textContent = new URL(url).hostname; + + // twitter:card "summary" = Summary Card + // twitter:card "summary_large_image" = Summary Card with Large Image + if ( + !imgIsTouchIcon && + (doc.querySelector( + "meta[name='twitter:card'][content='summary_large_image']" + ) || + doc + .querySelector("meta[property='og:image:width']") + ?.getAttribute("content") >= 600) + ) { + card.querySelector("img").style.width = "600px"; + } + + if (!image) { + card.querySelector(".card-pic").remove(); + } + + // If subject is empty, set that as well. + let subject = document.getElementById("msgSubject"); + if (!subject.value && title) { + subject.value = title; + } + + // Select the inserted URL so that if the preview is found one can + // use undo to remove it and only use the URL instead. + // Only do it if there was no typing after the url. + let selection = getBrowser().contentDocument.getSelection(); + let n = selection.focusNode; + if (n.textContent.endsWith(url)) { + selection.extend(n, n.textContent.lastIndexOf(url)); + card.classList.add("url-replaced"); + } + + // Add a line after the card. Otherwise it's hard to continue writing. + let line = GetCurrentEditor().returnInParagraphCreatesNewParagraph + ? "<p> </p>" + : "<br />"; + card.classList.add("loading"); // Used for fade-in effect. + getBrowser().contentDocument.execCommand( + "insertHTML", + false, + card.outerHTML + line + ); + let cardInDoc = getBrowser().contentDocument.getElementById(card.id); + cardInDoc.classList.remove("loading"); + }); +} + +/** + * On paste or drop, we may want to modify the content before inserting it into + * the editor, replacing file URLs with data URLs when appropriate. + */ +function onPasteOrDrop(e) { + if (!gMsgCompose.composeHTML) { + // We're in the plain text editor. Nothing to do here. + return; + } + gMsgCompose.allowRemoteContent = true; + + // For paste use e.clipboardData, for drop use e.dataTransfer. + let dataTransfer = "clipboardData" in e ? e.clipboardData : e.dataTransfer; + if ( + Services.prefs.getBoolPref("mail.compose.add_link_preview", false) && + !Services.io.offline && + !dataTransfer.types.includes("text/html") + ) { + let type = dataTransfer.types.find(t => + ["text/uri-list", "text/x-moz-url", "text/plain"].includes(t) + ); + if (type) { + let url = dataTransfer.getData(type).split("\n")[0].trim(); + if (/^https?:\/\/\S+$/.test(url)) { + e.preventDefault(); // We'll handle the pasting manually. + getBrowser().contentDocument.execCommand("insertHTML", false, url); + addLinkPreview(url); + return; + } + } + } + + if (!dataTransfer.types.includes("text/html")) { + return; + } + + // Ok, we have html content to paste. + let html = dataTransfer.getData("text/html"); + let doc = new DOMParser().parseFromString(html, "text/html"); + let tmpD = Services.dirsvc.get("TmpD", Ci.nsIFile); + let pendingConversions = 0; + let needToPreventDefault = true; + for (let img of doc.images) { + if (!/^file:/i.test(img.src)) { + // Doesn't start with file:. Nothing to do here. + continue; + } + + // This may throw if the URL is invalid for the OS. + let nsFile; + try { + nsFile = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler) + .getFileFromURLSpec(img.src); + } catch (ex) { + continue; + } + + if (!nsFile.exists()) { + continue; + } + + if (!tmpD.contains(nsFile)) { + // Not anywhere under the temp dir. + continue; + } + + let contentType = Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromFile(nsFile); + if (!contentType.startsWith("image/")) { + continue; + } + + // If we ever get here, we need to prevent the default paste or drop since + // the code below will do its own insertion. + if (needToPreventDefault) { + e.preventDefault(); + needToPreventDefault = false; + } + + File.createFromNsIFile(nsFile).then(function (file) { + if (file.lastModified < Date.now() - 60000) { + // Not put in temp in the last minute. May be something other than + // a copy-paste. Let's not allow that. + return; + } + + let doTheInsert = function () { + // Now run it through sanitation to make sure there wasn't any + // unwanted things in the content. + let ParserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils + ); + let html2 = ParserUtils.sanitize( + doc.documentElement.innerHTML, + ParserUtils.SanitizerAllowStyle + ); + getBrowser().contentDocument.execCommand("insertHTML", false, html2); + }; + + // Everything checks out. Convert file to data URL. + let reader = new FileReader(); + reader.addEventListener("load", function () { + let dataURL = reader.result; + pendingConversions--; + img.src = dataURL; + if (pendingConversions == 0) { + doTheInsert(); + } + }); + reader.addEventListener("error", function () { + pendingConversions--; + if (pendingConversions == 0) { + doTheInsert(); + } + }); + + pendingConversions++; + reader.readAsDataURL(file); + }); + } +} + +/* eslint-disable complexity */ +async function ComposeStartup() { + // Findbar overlay + if (!document.getElementById("findbar-replaceButton")) { + let replaceButton = document.createXULElement("toolbarbutton"); + replaceButton.setAttribute("id", "findbar-replaceButton"); + replaceButton.setAttribute("class", "toolbarbutton-1 tabbable"); + replaceButton.setAttribute( + "label", + getComposeBundle().getString("replaceButton.label") + ); + replaceButton.setAttribute( + "accesskey", + getComposeBundle().getString("replaceButton.accesskey") + ); + replaceButton.setAttribute( + "tooltiptext", + getComposeBundle().getString("replaceButton.tooltip") + ); + replaceButton.setAttribute("oncommand", "findbarFindReplace();"); + + let findbar = document.getElementById("FindToolbar"); + let lastButton = findbar.getElement("find-entire-word"); + let tSeparator = document.createXULElement("toolbarseparator"); + tSeparator.setAttribute("id", "findbar-beforeReplaceSeparator"); + lastButton.parentNode.insertBefore( + replaceButton, + lastButton.nextElementSibling + ); + lastButton.parentNode.insertBefore( + tSeparator, + lastButton.nextElementSibling + ); + } + + var params = null; // New way to pass parameters to the compose window as a nsIMsgComposeParameters object + var args = null; // old way, parameters are passed as a string + gBodyFromArgs = false; + + if (window.arguments && window.arguments[0]) { + try { + if (window.arguments[0] instanceof Ci.nsIMsgComposeParams) { + params = window.arguments[0]; + gBodyFromArgs = params.composeFields && params.composeFields.body; + } else { + params = handleMailtoArgs(window.arguments[0]); + } + } catch (ex) { + dump("ERROR with parameters: " + ex + "\n"); + } + + // if still no dice, try and see if the params is an old fashioned list of string attributes + // XXX can we get rid of this yet? + if (!params) { + args = GetArgs(window.arguments[0]); + } + } + + // Set a sane starting width/height for all resolutions on new profiles. + // Do this before the window loads. + if (!document.documentElement.hasAttribute("width")) { + // Prefer 860x800. + let defaultHeight = Math.min(screen.availHeight, 800); + let defaultWidth = Math.min(screen.availWidth, 860); + + // On small screens, default to maximized state. + if (defaultHeight <= 600) { + document.documentElement.setAttribute("sizemode", "maximized"); + } + + document.documentElement.setAttribute("width", defaultWidth); + document.documentElement.setAttribute("height", defaultHeight); + // Make sure we're safe at the left/top edge of screen + document.documentElement.setAttribute("screenX", screen.availLeft); + document.documentElement.setAttribute("screenY", screen.availTop); + } + + // Observe dictionary removals. + dictionaryRemovalObserver.addObserver(); + + let messageEditor = document.getElementById("messageEditor"); + messageEditor.addEventListener("paste", onPasteOrDrop); + messageEditor.addEventListener("drop", onPasteOrDrop); + + let identityList = document.getElementById("msgIdentity"); + if (identityList) { + FillIdentityList(identityList); + } + + if (!params) { + // This code will go away soon as now arguments are passed to the window using a object of type nsMsgComposeParams instead of a string + + params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance( + Ci.nsIMsgComposeParams + ); + params.composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + if (args) { + // Convert old fashion arguments into params + var composeFields = params.composeFields; + if (args.bodyislink == "true") { + params.bodyIsLink = true; + } + if (args.type) { + params.type = args.type; + } + if (args.format) { + // Only use valid values. + if ( + args.format == Ci.nsIMsgCompFormat.PlainText || + args.format == Ci.nsIMsgCompFormat.HTML || + args.format == Ci.nsIMsgCompFormat.OppositeOfDefault + ) { + params.format = args.format; + } else if (args.format.toLowerCase().trim() == "html") { + params.format = Ci.nsIMsgCompFormat.HTML; + } else if (args.format.toLowerCase().trim() == "text") { + params.format = Ci.nsIMsgCompFormat.PlainText; + } + } + if (args.originalMsgURI) { + params.originalMsgURI = args.originalMsgURI; + } + if (args.preselectid) { + params.identity = MailServices.accounts.getIdentity(args.preselectid); + } + if (args.from) { + composeFields.from = args.from; + } + if (args.to) { + composeFields.to = args.to; + } + if (args.cc) { + composeFields.cc = args.cc; + } + if (args.bcc) { + composeFields.bcc = args.bcc; + } + if (args.newsgroups) { + composeFields.newsgroups = args.newsgroups; + } + if (args.subject) { + composeFields.subject = args.subject; + } + if (args.attachment && window.arguments[1] instanceof Ci.nsICommandLine) { + let attachmentList = args.attachment.split(","); + for (let attachmentName of attachmentList) { + // resolveURI does all the magic around working out what the + // attachment is, including web pages, and generating the correct uri. + let uri = window.arguments[1].resolveURI(attachmentName); + let attachment = Cc[ + "@mozilla.org/messengercompose/attachment;1" + ].createInstance(Ci.nsIMsgAttachment); + // If uri is for a file and it exists set the attachment size. + if (uri instanceof Ci.nsIFileURL) { + if (uri.file.exists()) { + attachment.size = uri.file.fileSize; + } else { + attachment = null; + } + } + + // Only want to attach if a file that exists or it is not a file. + if (attachment) { + attachment.url = uri.spec; + composeFields.addAttachment(attachment); + } else { + let title = getComposeBundle().getString("errorFileAttachTitle"); + let msg = getComposeBundle().getFormattedString( + "errorFileAttachMessage", + [attachmentName] + ); + Services.prompt.alert(null, title, msg); + } + } + } + if (args.newshost) { + composeFields.newshost = args.newshost; + } + if (args.message) { + let msgFile = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + if (PathUtils.parent(args.message) == ".") { + let workingDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile); + args.message = PathUtils.join( + workingDir.path, + PathUtils.filename(args.message) + ); + } + msgFile.initWithPath(args.message); + + if (!msgFile.exists()) { + let title = getComposeBundle().getString("errorFileMessageTitle"); + let msg = getComposeBundle().getFormattedString( + "errorFileMessageMessage", + [args.message] + ); + Services.prompt.alert(null, title, msg); + } else { + let data = ""; + let fstream = null; + let cstream = null; + + try { + fstream = Cc[ + "@mozilla.org/network/file-input-stream;1" + ].createInstance(Ci.nsIFileInputStream); + cstream = Cc[ + "@mozilla.org/intl/converter-input-stream;1" + ].createInstance(Ci.nsIConverterInputStream); + fstream.init(msgFile, -1, 0, 0); // Open file in default/read-only mode. + cstream.init(fstream, "UTF-8", 0, 0); + + let str = {}; + let read = 0; + + do { + // Read as much as we can and put it in str.value. + read = cstream.readString(0xffffffff, str); + data += str.value; + } while (read != 0); + } catch (e) { + let title = getComposeBundle().getString("errorFileMessageTitle"); + let msg = getComposeBundle().getFormattedString( + "errorLoadFileMessageMessage", + [args.message] + ); + Services.prompt.alert(null, title, msg); + } finally { + if (cstream) { + cstream.close(); + } + if (fstream) { + fstream.close(); + } + } + + if (data) { + let pos = data.search(/\S/); // Find first non-whitespace character. + + if ( + params.format != Ci.nsIMsgCompFormat.PlainText && + (args.message.endsWith(".htm") || + args.message.endsWith(".html") || + data.substr(pos, 14).toLowerCase() == "<!doctype html" || + data.substr(pos, 5).toLowerCase() == "<html") + ) { + // We replace line breaks because otherwise they'll be converted to + // <br> in nsMsgCompose::BuildBodyMessageAndSignature(). + // Don't do the conversion if the user asked explicitly for plain text. + data = data.replace(/\r?\n/g, " "); + } + gBodyFromArgs = true; + composeFields.body = data; + } + } + } else if (args.body) { + gBodyFromArgs = true; + composeFields.body = args.body; + } + } + } + + gComposeType = params.type; + + // Detect correct identity when missing or mismatched. An identity with no + // email is likely not valid. + // When editing a draft, 'params.identity' is pre-populated with the identity + // that created the draft or the identity owning the draft folder for a + // "foreign" draft, see ComposeMessage() in mailCommands.js. We don't want the + // latter so use the creator identity which could be null. + // Only do this detection for drafts and templates. + // Redirect will have from set as the original sender and we don't want to + // warn about that. + if ( + gComposeType == Ci.nsIMsgCompType.Draft || + gComposeType == Ci.nsIMsgCompType.Template + ) { + let creatorKey = params.composeFields.creatorIdentityKey; + params.identity = creatorKey + ? MailServices.accounts.getIdentity(creatorKey) + : null; + } + + let from = null; + // Get the from address from the headers. For Redirect, from is set to + // the original author, so don't look at it here. + if (params.composeFields.from && gComposeType != Ci.nsIMsgCompType.Redirect) { + let fromAddrs = MailServices.headerParser.parseEncodedHeader( + params.composeFields.from, + null + ); + if (fromAddrs.length) { + from = fromAddrs[0].email.toLowerCase(); + } + } + + if ( + !params.identity || + !params.identity.email || + (from && !emailSimilar(from, params.identity.email)) + ) { + let identities = MailServices.accounts.allIdentities; + let suitableCount = 0; + + // Search for a matching identity. + if (from) { + for (let ident of identities) { + if (ident.email && from == ident.email.toLowerCase()) { + if (suitableCount == 0) { + params.identity = ident; + } + suitableCount++; + if (suitableCount > 1) { + // No need to find more, it's already not unique. + break; + } + } + } + } + + if (!params.identity || !params.identity.email) { + let identity = null; + // No preset identity and no match, so use the default account. + let defaultAccount = MailServices.accounts.defaultAccount; + if (defaultAccount) { + identity = defaultAccount.defaultIdentity; + } + if (!identity) { + // Get the first identity we have in the list. + let identitykey = identityList + .getItemAtIndex(0) + .getAttribute("identitykey"); + identity = MailServices.accounts.getIdentity(identitykey); + } + params.identity = identity; + } + + // Warn if no or more than one match was found. + // But don't warn for +suffix additions (a+b@c.com). + if ( + from && + (suitableCount > 1 || + (suitableCount == 0 && !emailSimilar(from, params.identity.email))) + ) { + gComposeNotificationBar.setIdentityWarning(params.identity.identityName); + } + } + + if (params.identity) { + identityList.selectedItem = identityList.getElementsByAttribute( + "identitykey", + params.identity.key + )[0]; + } + + // Here we set the From from the original message, be it a draft or another + // message, for example a template, we want to "edit as new". + // Only do this if the message is our own draft or template or any type of reply. + if ( + params.composeFields.from && + (params.composeFields.creatorIdentityKey || + gComposeType == Ci.nsIMsgCompType.Reply || + gComposeType == Ci.nsIMsgCompType.ReplyAll || + gComposeType == Ci.nsIMsgCompType.ReplyToSender || + gComposeType == Ci.nsIMsgCompType.ReplyToGroup || + gComposeType == Ci.nsIMsgCompType.ReplyToSenderAndGroup || + gComposeType == Ci.nsIMsgCompType.ReplyToList) + ) { + let from = MailServices.headerParser + .parseEncodedHeader(params.composeFields.from, null) + .join(", "); + if (from != identityList.value) { + MakeFromFieldEditable(true); + identityList.value = from; + } + } + LoadIdentity(true); + + // Get the <editor> element to startup an editor + var editorElement = GetCurrentEditorElement(); + + // Remember the original message URI. When editing a draft which is a reply + // or forwarded message, this gets overwritten by the ancestor's message URI so + // the disposition flags ("replied" or "forwarded") can be set on the ancestor. + // For our purposes we need the URI of the message being processed, not its + // original ancestor. + gOriginalMsgURI = params.originalMsgURI; + gMsgCompose = MailServices.compose.initCompose( + params, + window, + editorElement.docShell + ); + + // If a message is a draft, we rely on draft status flags to decide + // about encryption setting. Don't set gIsRelatedToEncryptedOriginal + // simply because a message was saved as an encrypted draft, because + // we save draft messages encrypted as soon as the account is able + // to encrypt, regardless of the user's desire for encryption for + // this message. + + if ( + gComposeType != Ci.nsIMsgCompType.Draft && + gComposeType != Ci.nsIMsgCompType.Template && + gEncryptedURIService && + gEncryptedURIService.isEncrypted(gMsgCompose.originalMsgURI) + ) { + gIsRelatedToEncryptedOriginal = true; + } + + gMsgCompose.addMsgSendListener(gSendListener); + + document + .getElementById("dsnMenu") + .setAttribute("checked", gMsgCompose.compFields.DSN); + document + .getElementById("cmd_attachVCard") + .setAttribute("checked", gMsgCompose.compFields.attachVCard); + document + .getElementById("cmd_attachPublicKey") + .setAttribute("checked", gAttachMyPublicPGPKey); + toggleAttachmentReminder(gMsgCompose.compFields.attachmentReminder); + initSendFormatMenu(); + + let editortype = gMsgCompose.composeHTML ? "htmlmail" : "textmail"; + editorElement.makeEditable(editortype, true); + + // setEditorType MUST be called before setContentWindow + if (gMsgCompose.composeHTML) { + initLocalFontFaceMenu(document.getElementById("FontFacePopup")); + } else { + // We are editing in plain text mode, so hide the formatting menus and the + // output format selector. + document.getElementById("FormatToolbar").hidden = true; + document.getElementById("formatMenu").hidden = true; + document.getElementById("insertMenu").hidden = true; + document.getElementById("menu_showFormatToolbar").hidden = true; + document.getElementById("outputFormatMenu").hidden = true; + } + + // Do setup common to Message Composer and Web Composer. + EditorSharedStartup(); + ToggleReturnReceipt(gMsgCompose.compFields.returnReceipt); + + if (params.bodyIsLink) { + let body = gMsgCompose.compFields.body; + if (gMsgCompose.composeHTML) { + let cleanBody; + try { + cleanBody = decodeURI(body); + } catch (e) { + cleanBody = body; + } + + body = body.replace(/&/g, "&"); + gMsgCompose.compFields.body = + '<br /><a href="' + body + '">' + cleanBody + "</a><br />"; + } else { + gMsgCompose.compFields.body = "\n<" + body + ">\n"; + } + } + + document.getElementById("msgSubject").value = gMsgCompose.compFields.subject; + + // Do not await async calls before registering the stateListener, otherwise it + // will miss states. + gMsgCompose.RegisterStateListener(stateListener); + + let addedAttachmentItems = await AddAttachments( + gMsgCompose.compFields.attachments, + false + ); + // If any of the pre-loaded attachments is a cloudFile, this is most probably a + // re-opened draft. Restore the cloudFile information. + for (let attachmentItem of addedAttachmentItems) { + if ( + attachmentItem.attachment.sendViaCloud && + attachmentItem.attachment.contentLocation && + attachmentItem.attachment.cloudFileAccountKey && + attachmentItem.attachment.cloudPartHeaderData + ) { + let byteString = atob(attachmentItem.attachment.cloudPartHeaderData); + let uploadFromDraft = JSON.parse( + MailStringUtils.byteStringToString(byteString) + ); + if (uploadFromDraft && uploadFromDraft.path && uploadFromDraft.name) { + let cloudFileUpload; + let cloudFileAccount = cloudFileAccounts.getAccount( + attachmentItem.attachment.cloudFileAccountKey + ); + let bigFile = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + bigFile.initWithPath(uploadFromDraft.path); + + if (cloudFileAccount) { + // Try to find the upload for the draft attachment in the already known + // uploads. + cloudFileUpload = cloudFileAccount + .getPreviousUploads() + .find( + upload => + upload.url == attachmentItem.attachment.contentLocation && + upload.url == uploadFromDraft.url && + upload.id == uploadFromDraft.id && + upload.name == uploadFromDraft.name && + upload.size == uploadFromDraft.size && + upload.path == uploadFromDraft.path && + upload.serviceName == uploadFromDraft.serviceName && + upload.serviceIcon == uploadFromDraft.serviceIcon && + upload.serviceUrl == uploadFromDraft.serviceUrl && + upload.downloadPasswordProtected == + uploadFromDraft.downloadPasswordProtected && + upload.downloadLimit == uploadFromDraft.downloadLimit && + upload.downloadExpiryDate == uploadFromDraft.downloadExpiryDate + ); + if (!cloudFileUpload) { + // Create a new upload from the data stored in the draft. + cloudFileUpload = cloudFileAccount.newUploadForFile( + bigFile, + uploadFromDraft + ); + } + // A restored cloudFile may have been send/used already in a previous + // session, or may be changed and reverted again by not saving a draft. + // Mark it as immutable. + cloudFileAccount.markAsImmutable(cloudFileUpload.id); + attachmentItem.cloudFileAccount = cloudFileAccount; + attachmentItem.cloudFileUpload = cloudFileUpload; + } else { + attachmentItem.cloudFileUpload = uploadFromDraft; + delete attachmentItem.cloudFileUpload.id; + } + + // Restore file information from the linked real file. + attachmentItem.attachment.name = uploadFromDraft.name; + attachmentItem.attachment.size = uploadFromDraft.size; + let bigAttachment; + if (bigFile.exists()) { + bigAttachment = FileToAttachment(bigFile); + } + if (bigAttachment && bigAttachment.size == uploadFromDraft.size) { + // Remove the temporary html placeholder file. + let uri = Services.io + .newURI(attachmentItem.attachment.url) + .QueryInterface(Ci.nsIFileURL); + await IOUtils.remove(uri.file.path); + + attachmentItem.attachment.url = bigAttachment.url; + attachmentItem.attachment.contentType = ""; + attachmentItem.attachment.temporary = false; + } + + await updateAttachmentItemProperties(attachmentItem); + continue; + } + } + // Did not find the required data in the draft to reconstruct the cloudFile + // information. Fall back to no-draft-restore-support. + attachmentItem.attachment.sendViaCloud = false; + } + + if (Services.prefs.getBoolPref("mail.compose.show_attachment_pane")) { + toggleAttachmentPane("show"); + } + + // Fill custom headers. + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + for (let i = 0; i < otherHeaders.length; i++) { + if (gMsgCompose.compFields.otherHeaders[i]) { + let row = document.getElementById(`addressRow${otherHeaders[i]}`); + addressRowSetVisibility(row, true); + let input = document.getElementById(`${otherHeaders[i]}AddrInput`); + input.value = gMsgCompose.compFields.otherHeaders[i]; + } + } + + document + .getElementById("msgcomposeWindow") + .dispatchEvent( + new Event("compose-window-init", { bubbles: false, cancelable: true }) + ); + + dispatchAttachmentBucketEvent( + "attachments-added", + gMsgCompose.compFields.attachments + ); + + // Add an observer to be called when document is done loading, + // which creates the editor. + try { + GetCurrentCommandManager().addCommandObserver( + gMsgEditorCreationObserver, + "obs_documentCreated" + ); + + // Load empty page to create the editor. The "?compose" is there so this + // URL does not exactly match "about:blank", which has some drawbacks. In + // particular it prevents WebExtension content scripts from running in + // this document. + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }; + editorElement.webNavigation.loadURI( + Services.io.newURI("about:blank?compose"), + loadURIOptions + ); + } catch (e) { + console.error(e); + } + + gEditingDraft = gMsgCompose.compFields.draftId; + + // Set up contacts sidebar. + let pageURL = document.URL; + let contactsSplitter = document.getElementById("contactsSplitter"); + let contactsShown = Services.xulStore.getValue( + pageURL, + "contactsSplitter", + "shown" + ); + let contactsWidth = Services.xulStore.getValue( + pageURL, + "contactsSplitter", + "width" + ); + contactsSplitter.width = + contactsWidth == "" ? null : parseFloat(contactsWidth); + setContactsSidebarVisibility(contactsShown == "true", false); + contactsSplitter.addEventListener("splitter-resized", () => { + let width = contactsSplitter.width; + Services.xulStore.setValue( + pageURL, + "contactsSplitter", + "width", + width == null ? "" : String(width) + ); + }); + contactsSplitter.addEventListener("splitter-collapsed", () => { + Services.xulStore.setValue(pageURL, "contactsSplitter", "shown", "false"); + }); + contactsSplitter.addEventListener("splitter-expanded", () => { + Services.xulStore.setValue(pageURL, "contactsSplitter", "shown", "true"); + }); + + // Update the priority button. + if (gMsgCompose.compFields.priority) { + updatePriorityToolbarButton(gMsgCompose.compFields.priority); + } + + gAutoSaveInterval = Services.prefs.getBoolPref("mail.compose.autosave") + ? Services.prefs.getIntPref("mail.compose.autosaveinterval") * 60000 + : 0; + + if (gAutoSaveInterval) { + gAutoSaveTimeout = setTimeout(AutoSave, gAutoSaveInterval); + } + + gAutoSaveKickedIn = false; +} +/* eslint-enable complexity */ + +function splitEmailAddress(aEmail) { + let at = aEmail.lastIndexOf("@"); + return at != -1 ? [aEmail.slice(0, at), aEmail.slice(at + 1)] : [aEmail, ""]; +} + +// Emails are equal ignoring +suffixes (email+suffix@example.com). +function emailSimilar(a, b) { + if (!a || !b) { + return a == b; + } + a = splitEmailAddress(a.toLowerCase()); + b = splitEmailAddress(b.toLowerCase()); + return a[1] == b[1] && a[0].split("+", 1)[0] == b[0].split("+", 1)[0]; +} + +// The new, nice, simple way of getting notified when a new editor has been created +var gMsgEditorCreationObserver = { + observe(aSubject, aTopic, aData) { + if (aTopic == "obs_documentCreated") { + var editor = GetCurrentEditor(); + if (editor && GetCurrentCommandManager() == aSubject) { + InitEditor(); + } + // Now that we know this document is an editor, update commands now if + // the document has focus, or next time it receives focus via + // CommandUpdate_MsgCompose() + if (gLastWindowToHaveFocus == document.commandDispatcher.focusedWindow) { + updateComposeItems(); + } else { + gLastWindowToHaveFocus = null; + } + } + }, +}; + +/** + * Adjust sign/encrypt settings after the identity was switched. + * + * @param {?nsIMsgIdentity} prevIdentity - The previously selected + * identity, when switching to a different identity. + * Null on initial identity setup. + */ +async function adjustEncryptAfterIdentityChange(prevIdentity) { + let identityHasConfiguredSMIME = + isSmimeSigningConfigured() || isSmimeEncryptionConfigured(); + + let identityHasConfiguredOpenPGP = isPgpConfigured(); + + // Show widgets based on the technologies available across all identities. + let allEmailIdentities = MailServices.accounts.allIdentities.filter( + i => i.email + ); + let anyIdentityHasConfiguredOpenPGP = allEmailIdentities.some(i => + i.getUnicharAttribute("openpgp_key_id") + ); + let anyIdentityHasConfiguredSMIMEEncryption = allEmailIdentities.some(i => + i.getUnicharAttribute("encryption_cert_name") + ); + + // Disable encryption widgets if this identity has no encryption configured. + // However, if encryption is currently enabled, we must keep it enabled, + // to allow the user to manually disable encryption (we don't disable + // encryption automatically, as the user might have seen that it is + // enabled and might rely on it). + let e2eeConfigured = + identityHasConfiguredOpenPGP || identityHasConfiguredSMIME; + + let autoEnablePref = Services.prefs.getBoolPref( + "mail.e2ee.auto_enable", + false + ); + + // If neither OpenPGP nor SMIME are configured for any identity, + // then hide the entire menu. + let encOpt = document.getElementById("button-encryption-options"); + if (encOpt) { + encOpt.hidden = + !anyIdentityHasConfiguredOpenPGP && + !anyIdentityHasConfiguredSMIMEEncryption; + encOpt.disabled = !e2eeConfigured && !gSendEncrypted; + document.getElementById("encTech_OpenPGP_Toolbar").disabled = + !identityHasConfiguredOpenPGP; + document.getElementById("encTech_SMIME_Toolbar").disabled = + !identityHasConfiguredSMIME; + } + document.getElementById("encryptionMenu").hidden = + !anyIdentityHasConfiguredOpenPGP && + !anyIdentityHasConfiguredSMIMEEncryption; + + // Show menu items only if both technologies are available. + document.getElementById("encTech_OpenPGP_Menubar").hidden = + !anyIdentityHasConfiguredOpenPGP || + !anyIdentityHasConfiguredSMIMEEncryption; + document.getElementById("encTech_SMIME_Menubar").hidden = + !anyIdentityHasConfiguredOpenPGP || + !anyIdentityHasConfiguredSMIMEEncryption; + document.getElementById("encryptionOptionsSeparator_Menubar").hidden = + !anyIdentityHasConfiguredOpenPGP || + !anyIdentityHasConfiguredSMIMEEncryption; + + let encToggle = document.getElementById("button-encryption"); + if (encToggle) { + encToggle.disabled = !e2eeConfigured && !gSendEncrypted; + } + let sigToggle = document.getElementById("button-signing"); + if (sigToggle) { + sigToggle.disabled = !e2eeConfigured; + } + + document.getElementById("encryptionMenu").disabled = + !e2eeConfigured && !gSendEncrypted; + + // Enable the encryption menus of the technologies that are configured for + // this identity. + document.getElementById("encTech_OpenPGP_Menubar").disabled = + !identityHasConfiguredOpenPGP; + + document.getElementById("encTech_SMIME_Menubar").disabled = + !identityHasConfiguredSMIME; + + if (!prevIdentity) { + // For identities without any e2ee setup, we want a good default + // technology selection. Avoid a technology that isn't configured + // anywhere. + + if (identityHasConfiguredOpenPGP) { + gSelectedTechnologyIsPGP = true; + } else if (identityHasConfiguredSMIME) { + gSelectedTechnologyIsPGP = false; + } else { + gSelectedTechnologyIsPGP = anyIdentityHasConfiguredOpenPGP; + } + + if (identityHasConfiguredOpenPGP) { + if (!identityHasConfiguredSMIME) { + gSelectedTechnologyIsPGP = true; + } else { + // both are configured + let techPref = gCurrentIdentity.getIntAttribute("e2etechpref"); + gSelectedTechnologyIsPGP = techPref != 1; + } + } + + gSendSigned = false; + + if (autoEnablePref) { + gSendEncrypted = gIsRelatedToEncryptedOriginal; + } else { + gSendEncrypted = + gIsRelatedToEncryptedOriginal || + ((identityHasConfiguredOpenPGP || identityHasConfiguredSMIME) && + gCurrentIdentity.encryptionPolicy > 0); + } + + await checkEncryptionState(); + return; + } + + // Not initialCall (switching from, or changed recipients) + + // If the new identity has only one technology configured, + // which is different than the currently selected technology, + // then switch over to that other technology. + // However, if the new account doesn't have any technology + // configured, then it doesn't really matter, so let's keep what's + // currently selected for consistency (in case the user switches + // the identity again). + if ( + gSelectedTechnologyIsPGP && + !identityHasConfiguredOpenPGP && + identityHasConfiguredSMIME + ) { + gSelectedTechnologyIsPGP = false; + } else if ( + !gSelectedTechnologyIsPGP && + !identityHasConfiguredSMIME && + identityHasConfiguredOpenPGP + ) { + gSelectedTechnologyIsPGP = true; + } + + if ( + !autoEnablePref && + !gSendEncrypted && + !gUserTouchedEncryptSubject && + prevIdentity.encryptionPolicy == 0 && + gCurrentIdentity.encryptionPolicy > 0 + ) { + gSendEncrypted = true; + } + + await checkEncryptionState(); +} + +async function ComposeLoad() { + updateTroubleshootMenuItem(); + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + + AddMessageComposeOfflineQuitObserver(); + + BondOpenPGP.init(); + + // Give the message header a minimum height based on its current height, + // before more recipient rows are revealed in #extraAddressRowsArea. This + // ensures that the area cannot be shrunk below its current height by the + // #headersSplitter. + // NOTE: At this stage, we only expect the "To" row to be visible within the + // recipients container. + let messageHeader = document.getElementById("MsgHeadersToolbar"); + let recipientsContainer = document.getElementById("recipientsContainer"); + // In the unlikely situation where the recipients container is already + // overflowing, we make sure to increase the minHeight by the overflow. + let headerHeight = + messageHeader.clientHeight + + recipientsContainer.scrollHeight - + recipientsContainer.clientHeight; + messageHeader.style.minHeight = `${headerHeight}px`; + + // Setup the attachment bucket. + gAttachmentBucket = document.getElementById("attachmentBucket"); + + let attachmentArea = document.getElementById("attachmentArea"); + attachmentArea.addEventListener("toggle", attachmentAreaOnToggle); + + // Setup the attachment animation counter. + gAttachmentCounter = document.getElementById("newAttachmentIndicator"); + gAttachmentCounter.addEventListener( + "animationend", + toggleAttachmentAnimation + ); + + // Set up the drag & drop event listeners. + let messageArea = document.getElementById("messageArea"); + messageArea.addEventListener("dragover", event => + envelopeDragObserver.onDragOver(event) + ); + messageArea.addEventListener("dragleave", event => + envelopeDragObserver.onDragLeave(event) + ); + messageArea.addEventListener("drop", event => + envelopeDragObserver.onDrop(event) + ); + + // Setup the attachment overlay animation listeners. + let overlay = document.getElementById("dropAttachmentOverlay"); + overlay.addEventListener("animationend", e => { + // Make the overlay constantly visible If the user is dragging a file over + // the compose windown. + if (e.animationName == "showing-animation") { + // We don't remove the "showing" class here since the dragOver event will + // keep adding it and we would have a flashing effect. + overlay.classList.add("show"); + return; + } + + // Permanently hide the overlay after the hiding animation ended. + if (e.animationName == "hiding-animation") { + overlay.classList.remove("show", "hiding"); + // Remove the hover class from the child items to reset the style. + document.getElementById("addInline").classList.remove("hover"); + document.getElementById("addAsAttachment").classList.remove("hover"); + } + }); + + if (otherHeaders) { + let extraAddressRowsMenu = document.getElementById("extraAddressRowsMenu"); + + let existingTypes = Array.from( + document.querySelectorAll(".address-row"), + row => row.dataset.recipienttype + ); + + for (let header of otherHeaders) { + if (existingTypes.includes(header)) { + continue; + } + existingTypes.push(header); + + header = header.trim(); + let recipient = { + rowId: `addressRow${header}`, + labelId: `${header}AddrLabel`, + containerId: `${header}AddrContainer`, + inputId: `${header}AddrInput`, + showRowMenuItemId: `${header}ShowAddressRowMenuItem`, + type: header, + }; + + let newEls = recipientsContainer.buildRecipientRow(recipient, true); + + recipientsContainer.appendChild(newEls.row); + extraAddressRowsMenu.appendChild(newEls.showRowMenuItem); + } + } + + try { + SetupCommandUpdateHandlers(); + await ComposeStartup(); + } catch (ex) { + console.error(ex); + Services.prompt.alert( + window, + getComposeBundle().getString("initErrorDlogTitle"), + getComposeBundle().getString("initErrorDlgMessage") + ); + + MsgComposeCloseWindow(); + return; + } + + ToolbarIconColor.init(); + + // initialize the customizeDone method on the customizeable toolbar + var toolbox = document.getElementById("compose-toolbox"); + toolbox.customizeDone = function (aEvent) { + MailToolboxCustomizeDone(aEvent, "CustomizeComposeToolbar"); + }; + + updateAttachmentPane(); + updateAriaLabelsAndTooltipsOfAllAddressRows(); + + for (let input of document.querySelectorAll(".address-row-input")) { + input.onBeforeHandleKeyDown = event => + addressInputOnBeforeHandleKeyDown(event); + } + + top.controllers.appendController(SecurityController); + gMsgCompose.compFields.composeSecure = null; + gSMFields = Cc[ + "@mozilla.org/messengercompose/composesecure;1" + ].createInstance(Ci.nsIMsgComposeSecure); + if (gSMFields) { + gMsgCompose.compFields.composeSecure = gSMFields; + } + + // Set initial encryption settings. + adjustEncryptAfterIdentityChange(null); + + ExtensionParent.apiManager.emit( + "extension-browser-inserted", + GetCurrentEditorElement() + ); + + setComposeLabelsAndMenuItems(); + setKeyboardShortcuts(); + + gFocusAreas = [ + { + // #abContactsPanel. + // NOTE: If focus is within the browser shadow document, then the + // top.document.activeElement points to the browser, which is below + // #contactsSidebar. + root: document.getElementById("contactsSidebar"), + focus: focusContactsSidebarSearchInput, + }, + { + // #msgIdentity, .recipient-button and #extraAddressRowsMenuButton. + root: document.getElementById("top-gradient-box"), + focus: focusMsgIdentity, + }, + ...Array.from(document.querySelectorAll(".address-row"), row => { + return { root: row, focus: focusAddressRowInput }; + }), + { + root: document.getElementById("subject-box"), + focus: focusSubjectInput, + }, + // "#FormatToolbox" cannot receive focus. + { + // #messageEditor and #FindToolbar + root: document.getElementById("messageArea"), + focus: focusMsgBody, + }, + { + root: document.getElementById("attachmentArea"), + focus: focusAttachmentBucket, + }, + { + root: document.getElementById("compose-notification-bottom"), + focus: focusNotification, + }, + { + root: document.getElementById("status-bar"), + focus: focusStatusBar, + }, + ]; + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); +} + +/** + * Add fluent strings to labels and menu items requiring a shortcut key. + */ +function setComposeLabelsAndMenuItems() { + // To field. + document.l10n.setAttributes( + document.getElementById("menu_showToField"), + "show-to-row-main-menuitem", + { + key: SHOW_TO_KEY, + } + ); + document.l10n.setAttributes( + document.getElementById("addr_toShowAddressRowMenuItem"), + "show-to-row-extra-menuitem" + ); + document.l10n.setAttributes( + document.getElementById("addr_toShowAddressRowButton"), + "show-to-row-button", + { + key: SHOW_TO_KEY, + } + ); + + // Cc field. + document.l10n.setAttributes( + document.getElementById("menu_showCcField"), + "show-cc-row-main-menuitem", + { + key: SHOW_CC_KEY, + } + ); + document.l10n.setAttributes( + document.getElementById("addr_ccShowAddressRowMenuItem"), + "show-cc-row-extra-menuitem" + ); + document.l10n.setAttributes( + document.getElementById("addr_ccShowAddressRowButton"), + "show-cc-row-button", + { + key: SHOW_CC_KEY, + } + ); + + // Bcc field. + document.l10n.setAttributes( + document.getElementById("menu_showBccField"), + "show-bcc-row-main-menuitem", + { + key: SHOW_BCC_KEY, + } + ); + document.l10n.setAttributes( + document.getElementById("addr_bccShowAddressRowMenuItem"), + "show-bcc-row-extra-menuitem" + ); + document.l10n.setAttributes( + document.getElementById("addr_bccShowAddressRowButton"), + "show-bcc-row-button", + { + key: SHOW_BCC_KEY, + } + ); +} + +/** + * Add a keydown document event listener for international keyboard shortcuts. + */ +async function setKeyboardShortcuts() { + let [filePickerKey, toggleBucketKey] = await l10nCompose.formatValues([ + { id: "trigger-attachment-picker-key" }, + { id: "toggle-attachment-pane-key" }, + ]); + + document.addEventListener("keydown", event => { + // Return if we don't have the right modifier combination, CTRL/CMD + SHIFT, + // or if the pressed key is a modifier (each modifier will keep firing + // keydown event until another key is pressed in addition). + if ( + !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) || + !event.shiftKey || + ["Shift", "Control", "Meta"].includes(event.key) + ) { + return; + } + + // Always use lowercase to compare the key and avoid OS inconsistencies: + // For Cmd/Ctrl+Shift+A, on Mac, key = "a" vs. on Windows/Linux, key = "A". + switch (event.key.toLowerCase()) { + // Always prevent the default behavior of the keydown if we intercepted + // the key in order to avoid triggering OS specific shortcuts. + case filePickerKey.toLowerCase(): + // Ctrl/Cmd+Shift+A. + event.preventDefault(); + goDoCommand("cmd_attachFile"); + break; + case toggleBucketKey.toLowerCase(): + // Ctrl/Cmd+Shift+M. + event.preventDefault(); + goDoCommand("cmd_toggleAttachmentPane"); + break; + case SHOW_TO_KEY.toLowerCase(): + // Ctrl/Cmd+Shift+T. + event.preventDefault(); + showAndFocusAddressRow("addressRowTo"); + break; + case SHOW_CC_KEY.toLowerCase(): + // Ctrl/Cmd+Shift+C. + event.preventDefault(); + showAndFocusAddressRow("addressRowCc"); + break; + case SHOW_BCC_KEY.toLowerCase(): + // Ctrl/Cmd+Shift+B. + event.preventDefault(); + showAndFocusAddressRow("addressRowBcc"); + break; + } + }); + + document.addEventListener("keypress", event => { + // If the user presses Esc and the drop attachment overlay is still visible, + // call the onDragLeave() method to properly hide it. + if ( + event.key == "Escape" && + document + .getElementById("dropAttachmentOverlay") + .classList.contains("show") + ) { + envelopeDragObserver.onDragLeave(event); + } + }); +} + +function ComposeUnload() { + // Send notification that the window is going away completely. + document + .getElementById("msgcomposeWindow") + .dispatchEvent( + new Event("compose-window-unload", { bubbles: false, cancelable: false }) + ); + + GetCurrentCommandManager().removeCommandObserver( + gMsgEditorCreationObserver, + "obs_documentCreated" + ); + UnloadCommandUpdateHandlers(); + + // In some tests, the window is closed so quickly that the observer + // hasn't fired and removed itself yet, so let's remove it here. + spellCheckReadyObserver.removeObserver(); + // Stop spell checker so personal dictionary is saved. + enableInlineSpellCheck(false); + + EditorCleanup(); + + if (gMsgCompose) { + gMsgCompose.removeMsgSendListener(gSendListener); + } + + RemoveMessageComposeOfflineQuitObserver(); + gAttachmentNotifier.shutdown(); + ToolbarIconColor.uninit(); + + // Stop observing dictionary removals. + dictionaryRemovalObserver.removeObserver(); + + if (gMsgCompose) { + // Notify the SendListener that Send has been aborted and Stopped + gMsgCompose.onSendNotPerformed(null, Cr.NS_ERROR_ABORT); + gMsgCompose.UnregisterStateListener(stateListener); + } + if (gAutoSaveTimeout) { + clearTimeout(gAutoSaveTimeout); + } + if (msgWindow) { + msgWindow.closeWindow(); + } + + ReleaseGlobalVariables(); + + top.controllers.removeController(SecurityController); + + // This destroys the window for us. + MsgComposeCloseWindow(); +} + +function onEncryptionChoice(value) { + switch (value) { + case "OpenPGP": + if (isPgpConfigured()) { + gSelectedTechnologyIsPGP = true; + checkEncryptionState(); + } + break; + + case "SMIME": + if (isSmimeEncryptionConfigured()) { + gSelectedTechnologyIsPGP = false; + checkEncryptionState(); + } + break; + + case "enc": + toggleEncryptMessage(); + break; + + case "encsub": + gEncryptSubject = !gEncryptSubject; + gUserTouchedEncryptSubject = true; + updateEncryptedSubject(); + break; + + case "sig": + toggleGlobalSignMessage(); + break; + + case "status": + showMessageComposeSecurityStatus(); + break; + + case "manager": + openKeyManager(); + break; + } +} + +var SecurityController = { + supportsCommand(command) { + switch (command) { + case "cmd_viewSecurityStatus": + return true; + + default: + return false; + } + }, + + isCommandEnabled(command) { + switch (command) { + case "cmd_viewSecurityStatus": + return true; + + default: + return false; + } + }, +}; + +function updateEncryptOptionsMenuElements() { + let encOpt = document.getElementById("button-encryption-options"); + if (encOpt) { + document.l10n.setAttributes( + encOpt, + gSelectedTechnologyIsPGP + ? "encryption-options-openpgp" + : "encryption-options-smime" + ); + document.l10n.setAttributes( + document.getElementById("menu_recipientStatus_Toolbar"), + gSelectedTechnologyIsPGP ? "menu-manage-keys" : "menu-view-certificates" + ); + document.getElementById("menu_securityEncryptSubject_Toolbar").hidden = + !gSelectedTechnologyIsPGP; + } + document.l10n.setAttributes( + document.getElementById("menu_recipientStatus_Menubar"), + gSelectedTechnologyIsPGP ? "menu-manage-keys" : "menu-view-certificates" + ); + document.getElementById("menu_securityEncryptSubject_Menubar").hidden = + !gSelectedTechnologyIsPGP; +} + +/** + * Update the aria labels of all non-custom address inputs and all pills in the + * addressing area. Also update the tooltips of the close labels of all address + * rows, including custom header fields. + */ +async function updateAriaLabelsAndTooltipsOfAllAddressRows() { + for (let row of document + .getElementById("recipientsContainer") + .querySelectorAll(".address-row")) { + updateAriaLabelsOfAddressRow(row); + updateTooltipsOfAddressRow(row); + } +} + +/** + * Update the aria labels of the address input and all pills of an address row. + * This is needed whenever a pill gets added or removed, because the aria label + * of each pill contains the current count of all pills in that row ("1 of n"). + * + * @param {Element} row - The address row. + */ +async function updateAriaLabelsOfAddressRow(row) { + // Bail out for custom header input where pills are disabled. + if (row.classList.contains("address-row-raw")) { + return; + } + let input = row.querySelector(".address-row-input"); + + let type = row.querySelector(".address-label-container > label").value; + let pills = row.querySelectorAll("mail-address-pill"); + + input.setAttribute( + "aria-label", + await l10nCompose.formatValue("address-input-type-aria-label", { + type, + count: pills.length, + }) + ); + + for (let pill of pills) { + pill.setAttribute( + "aria-label", + await l10nCompose.formatValue("pill-aria-label", { + email: pill.fullAddress, + count: pills.length, + }) + ); + } +} + +/** + * Update the tooltip of the close label of an address row. + * + * @param {Element} row - The address row. + */ +function updateTooltipsOfAddressRow(row) { + let type = row.querySelector(".address-label-container > label").value; + let el = row.querySelector(".remove-field-button"); + document.l10n.setAttributes(el, "remove-address-row-button", { type }); +} + +function onSendSMIME() { + let emailAddresses = []; + + try { + if (!gMsgCompose.compFields.composeSecure.requireEncryptMessage) { + return; + } + + for (let email of getEncryptionCompatibleRecipients()) { + if (!gSMFields.haveValidCertForEmail(email)) { + emailAddresses.push(email); + } + } + } catch (e) { + return; + } + + if (emailAddresses.length == 0) { + return; + } + + // The rules here: If the current identity has a directoryServer set, then + // use that, otherwise, try the global preference instead. + + let autocompleteDirectory; + + // Does the current identity override the global preference? + if (gCurrentIdentity.overrideGlobalPref) { + autocompleteDirectory = gCurrentIdentity.directoryServer; + } else if (Services.prefs.getBoolPref("ldap_2.autoComplete.useDirectory")) { + // Try the global one + autocompleteDirectory = Services.prefs.getCharPref( + "ldap_2.autoComplete.directoryServer" + ); + } + + if (autocompleteDirectory) { + window.openDialog( + "chrome://messenger-smime/content/certFetchingStatus.xhtml", + "", + "chrome,modal,resizable,centerscreen", + autocompleteDirectory, + emailAddresses + ); + } +} + +// Add-ons can override this to customize the behavior. +function DoSpellCheckBeforeSend() { + return Services.prefs.getBoolPref("mail.SpellCheckBeforeSend"); +} + +/** + * Updates gMsgCompose.compFields to match the UI. + * + * @returns {nsIMsgCompFields} + */ +function GetComposeDetails() { + let msgCompFields = gMsgCompose.compFields; + + Recipients2CompFields(msgCompFields); + let addresses = MailServices.headerParser.makeFromDisplayAddress( + document.getElementById("msgIdentity").value + ); + msgCompFields.from = MailServices.headerParser.makeMimeHeader(addresses); + msgCompFields.subject = document.getElementById("msgSubject").value; + Attachments2CompFields(msgCompFields); + + return msgCompFields; +} + +/** + * Updates the UI to match newValues. + * + * @param {object} newValues - New values to use. Values that should not change + * should be null or not present. + * @param {string} [newValues.to] + * @param {string} [newValues.cc] + * @param {string} [newValues.bcc] + * @param {string} [newValues.replyTo] + * @param {string} [newValues.newsgroups] + * @param {string} [newValues.followupTo] + * @param {string} [newValues.subject] + * @param {string} [newValues.body] + * @param {string} [newValues.plainTextBody] + */ +function SetComposeDetails(newValues) { + if (newValues.identityKey !== null) { + let identityList = document.getElementById("msgIdentity"); + for (let menuItem of identityList.menupopup.children) { + if (menuItem.getAttribute("identitykey") == newValues.identityKey) { + identityList.selectedItem = menuItem; + LoadIdentity(false); + break; + } + } + } + CompFields2Recipients(newValues); + if (typeof newValues.subject == "string") { + gMsgCompose.compFields.subject = document.getElementById( + "msgSubject" + ).value = newValues.subject; + SetComposeWindowTitle(); + } + if ( + typeof newValues.body == "string" && + typeof newValues.plainTextBody == "string" + ) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + let editor = GetCurrentEditor(); + if (typeof newValues.body == "string") { + if (!IsHTMLEditor()) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + editor.rebuildDocumentFromSource(newValues.body); + gMsgCompose.bodyModified = true; + } + if (typeof newValues.plainTextBody == "string") { + editor.selectAll(); + // Remove \r from line endings, which cause extra newlines (bug 1672407). + let mailEditor = editor.QueryInterface(Ci.nsIEditorMailSupport); + if (newValues.plainTextBody === "") { + editor.deleteSelection(editor.eNone, editor.eStrip); + } else { + mailEditor.insertTextWithQuotations( + newValues.plainTextBody.replaceAll("\r\n", "\n") + ); + } + gMsgCompose.bodyModified = true; + } + gContentChanged = true; +} + +/** + * Handles message sending operations. + * + * @param {nsIMsgCompDeliverMode} mode - The delivery mode of the operation. + */ +async function GenericSendMessage(msgType) { + let msgCompFields = GetComposeDetails(); + + // Some other msgCompFields have already been updated instantly in their + // respective toggle functions, e.g. ToggleReturnReceipt(), ToggleDSN(), + // ToggleAttachVCard(), and toggleAttachmentReminder(). + + let sending = + msgType == Ci.nsIMsgCompDeliverMode.Now || + msgType == Ci.nsIMsgCompDeliverMode.Later || + msgType == Ci.nsIMsgCompDeliverMode.Background; + + // Notify about a new message being prepared for sending. + window.dispatchEvent( + new CustomEvent("compose-prepare-message-start", { + detail: { msgType }, + }) + ); + + try { + if (sending) { + // Since the onBeforeSend event can manipulate compose details, execute it + // before the final sanity checks. + try { + await new Promise((resolve, reject) => { + let beforeSendEvent = new CustomEvent("beforesend", { + cancelable: true, + detail: { + resolve, + reject, + }, + }); + window.dispatchEvent(beforeSendEvent); + if (!beforeSendEvent.defaultPrevented) { + resolve(); + } + }); + } catch (ex) { + throw new Error(`Send aborted by an onBeforeSend event`); + } + + expandRecipients(); + // Check if e-mail addresses are complete, in case user turned off + // autocomplete to local domain. + if (!CheckValidEmailAddress(msgCompFields)) { + throw new Error(`Send aborted: invalid recipient address found`); + } + + // Do we need to check the spelling? + if (DoSpellCheckBeforeSend()) { + // We disable spellcheck for the following -subject line, attachment + // pane, identity and addressing widget therefore we need to explicitly + // focus on the mail body when we have to do a spellcheck. + focusMsgBody(); + window.cancelSendMessage = false; + window.openDialog( + "chrome://messenger/content/messengercompose/EdSpellCheck.xhtml", + "_blank", + "dialog,close,titlebar,modal,resizable", + true, + true, + false + ); + + if (window.cancelSendMessage) { + throw new Error(`Send aborted by the user: spelling errors found`); + } + } + + // Strip trailing spaces and long consecutive WSP sequences from the + // subject line to prevent getting only WSP chars on a folded line. + let subject = msgCompFields.subject; + let fixedSubject = subject.replace(/\s{74,}/g, " ").trimRight(); + if (fixedSubject != subject) { + subject = fixedSubject; + msgCompFields.subject = fixedSubject; + document.getElementById("msgSubject").value = fixedSubject; + } + + // Remind the person if there isn't a subject + if (subject == "") { + if ( + Services.prompt.confirmEx( + window, + getComposeBundle().getString("subjectEmptyTitle"), + getComposeBundle().getString("subjectEmptyMessage"), + Services.prompt.BUTTON_TITLE_IS_STRING * + Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_IS_STRING * + Services.prompt.BUTTON_POS_1, + getComposeBundle().getString("sendWithEmptySubjectButton"), + getComposeBundle().getString("cancelSendingButton"), + null, + null, + { value: 0 } + ) == 1 + ) { + document.getElementById("msgSubject").focus(); + throw new Error(`Send aborted by the user: subject missing`); + } + } + + // Attachment Reminder: Alert the user if + // - the user requested "Remind me later" from either the notification bar or the menu + // (alert regardless of the number of files already attached: we can't guess for how many + // or which files users want the reminder, and guessing wrong will annoy them a lot), OR + // - the aggressive pref is set and the latest notification is still showing (implying + // that the message has no attachment(s) yet, message still contains some attachment + // keywords, and notification was not dismissed). + if ( + gManualAttachmentReminder || + (Services.prefs.getBoolPref( + "mail.compose.attachment_reminder_aggressive" + ) && + gComposeNotification.getNotificationWithValue("attachmentReminder")) + ) { + let flags = + Services.prompt.BUTTON_POS_0 * + Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_IS_STRING; + let hadForgotten = Services.prompt.confirmEx( + window, + getComposeBundle().getString("attachmentReminderTitle"), + getComposeBundle().getString("attachmentReminderMsg"), + flags, + getComposeBundle().getString("attachmentReminderFalseAlarm"), + getComposeBundle().getString("attachmentReminderYesIForgot"), + null, + null, + { value: 0 } + ); + // Deactivate manual attachment reminder after showing the alert to avoid alert loop. + // We also deactivate reminder when user ignores alert with [x] or [ESC]. + if (gManualAttachmentReminder) { + toggleAttachmentReminder(false); + } + + if (hadForgotten) { + throw new Error(`Send aborted by the user: attachment missing`); + } + } + + // Aggressive many public recipients prompt. + let publicRecipientCount = getPublicAddressPillsCount(); + if ( + Services.prefs.getBoolPref( + "mail.compose.warn_public_recipients.aggressive" + ) && + publicRecipientCount >= + Services.prefs.getIntPref( + "mail.compose.warn_public_recipients.threshold" + ) + ) { + let flags = + Services.prompt.BUTTON_POS_0 * + Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_IS_STRING; + let [title, msg, cancel, send] = l10nComposeSync.formatValuesSync([ + "many-public-recipients-prompt-title", + { + id: "many-public-recipients-prompt-msg", + args: { count: getPublicAddressPillsCount() }, + }, + "many-public-recipients-prompt-cancel", + "many-public-recipients-prompt-send", + ]); + let willCancel = Services.prompt.confirmEx( + window, + title, + msg, + flags, + send, + cancel, + null, + null, + { value: 0 } + ); + + if (willCancel) { + if (!gRecipientObserver) { + // Re-create this observer as it is destroyed when the user dismisses + // the warning. + gRecipientObserver = new MutationObserver(function (mutations) { + if (mutations.some(m => m.type == "childList")) { + checkPublicRecipientsLimit(); + } + }); + } + checkPublicRecipientsLimit(); + throw new Error( + `Send aborted by the user: too many public recipients found` + ); + } + } + + // Check if the user tries to send a message to a newsgroup through a mail + // account. + var currentAccountKey = getCurrentAccountKey(); + let account = MailServices.accounts.getAccount(currentAccountKey); + if ( + account.incomingServer.type != "nntp" && + msgCompFields.newsgroups != "" + ) { + const kDontAskAgainPref = "mail.compose.dontWarnMail2Newsgroup"; + // default to ask user if the pref is not set + let dontAskAgain = Services.prefs.getBoolPref(kDontAskAgainPref); + if (!dontAskAgain) { + let checkbox = { value: false }; + let okToProceed = Services.prompt.confirmCheck( + window, + getComposeBundle().getString("noNewsgroupSupportTitle"), + getComposeBundle().getString("recipientDlogMessage"), + getComposeBundle().getString("CheckMsg"), + checkbox + ); + if (!okToProceed) { + throw new Error(`Send aborted by the user: wrong account used`); + } + + if (checkbox.value) { + Services.prefs.setBoolPref(kDontAskAgainPref, true); + } + } + + // remove newsgroups to prevent news_p to be set + // in nsMsgComposeAndSend::DeliverMessage() + msgCompFields.newsgroups = ""; + } + + if (Services.prefs.getBoolPref("mail.compose.add_link_preview", true)) { + // Remove any card "close" button from content before sending. + for (let close of getBrowser().contentDocument.querySelectorAll( + ".moz-card .remove-card" + )) { + close.remove(); + } + } + + let sendFormat = determineSendFormat(); + switch (sendFormat) { + case Ci.nsIMsgCompSendFormat.PlainText: + msgCompFields.forcePlainText = true; + msgCompFields.useMultipartAlternative = false; + break; + case Ci.nsIMsgCompSendFormat.HTML: + msgCompFields.forcePlainText = false; + msgCompFields.useMultipartAlternative = false; + break; + case Ci.nsIMsgCompSendFormat.Both: + msgCompFields.forcePlainText = false; + msgCompFields.useMultipartAlternative = true; + break; + default: + throw new Error(`Invalid send format ${sendFormat}`); + } + } + + await CompleteGenericSendMessage(msgType); + window.dispatchEvent(new CustomEvent("compose-prepare-message-success")); + } catch (exception) { + console.error(exception); + window.dispatchEvent( + new CustomEvent("compose-prepare-message-failure", { + detail: { exception }, + }) + ); + } +} + +/** + * Finishes message sending. This should ONLY be called directly from + * GenericSendMessage. This is a separate function so that it can be easily mocked + * in tests. + * + * @param msgType nsIMsgCompDeliverMode of the operation. + */ +async function CompleteGenericSendMessage(msgType) { + // hook for extra compose pre-processing + Services.obs.notifyObservers(window, "mail:composeOnSend"); + + if (!gSelectedTechnologyIsPGP) { + gMsgCompose.compFields.composeSecure.requireEncryptMessage = gSendEncrypted; + gMsgCompose.compFields.composeSecure.signMessage = gSendSigned; + onSendSMIME(); + } + + let sendError = null; + try { + // Just before we try to send the message, fire off the + // compose-send-message event for listeners, so they can do + // any pre-security work before sending. + var event = document.createEvent("UIEvents"); + event.initEvent("compose-send-message", false, true); + var msgcomposeWindow = document.getElementById("msgcomposeWindow"); + msgcomposeWindow.setAttribute("msgtype", msgType); + msgcomposeWindow.dispatchEvent(event); + if (event.defaultPrevented) { + throw Components.Exception( + "compose-send-message prevented", + Cr.NS_ERROR_ABORT + ); + } + + gAutoSaving = msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft; + + // disable the ui if we're not auto-saving + if (!gAutoSaving) { + ToggleWindowLock(true); + } else { + // If we're auto saving, mark the body as not changed here, and not + // when the save is done, because the user might change it between now + // and when the save is done. + SetContentAndBodyAsUnmodified(); + } + + // Keep track of send/saved cloudFiles and mark them as immutable. + let items = [...gAttachmentBucket.itemChildren]; + for (let item of items) { + if (item.attachment.sendViaCloud && item.cloudFileAccount) { + item.cloudFileAccount.markAsImmutable(item.cloudFileUpload.id); + } + } + + var progress = Cc["@mozilla.org/messenger/progress;1"].createInstance( + Ci.nsIMsgProgress + ); + if (progress) { + progress.registerListener(progressListener); + if ( + msgType == Ci.nsIMsgCompDeliverMode.Save || + msgType == Ci.nsIMsgCompDeliverMode.SaveAsDraft || + msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft || + msgType == Ci.nsIMsgCompDeliverMode.SaveAsTemplate + ) { + gSaveOperationInProgress = true; + } else { + gSendOperationInProgress = true; + } + } + msgWindow.domWindow = window; + msgWindow.rootDocShell.allowAuth = true; + await gMsgCompose.sendMsg( + msgType, + gCurrentIdentity, + getCurrentAccountKey(), + msgWindow, + progress + ); + } catch (ex) { + console.error("GenericSendMessage FAILED: " + ex); + ToggleWindowLock(false); + sendError = ex; + } + + if ( + msgType == Ci.nsIMsgCompDeliverMode.Now || + msgType == Ci.nsIMsgCompDeliverMode.Later || + msgType == Ci.nsIMsgCompDeliverMode.Background + ) { + window.dispatchEvent(new CustomEvent("aftersend")); + + let maxSize = + Services.prefs.getIntPref("mail.compose.big_attachments.threshold_kb") * + 1024; + let items = [...gAttachmentBucket.itemChildren]; + + // When any big attachment is not sent via filelink, increment + // `tb.filelink.ignored`. + if ( + items.some( + item => item.attachment.size >= maxSize && !item.attachment.sendViaCloud + ) + ) { + Services.telemetry.scalarAdd("tb.filelink.ignored", 1); + } + } else if ( + msgType == Ci.nsIMsgCompDeliverMode.Save || + msgType == Ci.nsIMsgCompDeliverMode.SaveAsDraft || + msgType == Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft || + msgType == Ci.nsIMsgCompDeliverMode.SaveAsTemplate + ) { + window.dispatchEvent(new CustomEvent("aftersave")); + } + + if (sendError) { + throw sendError; + } +} + +/** + * Check if the given email address is valid (contains an @). + * + * @param {string} address - The email address string to check. + */ +function isValidAddress(address) { + return address.includes("@", 1) && !address.endsWith("@"); +} + +/** + * Check if the given news address is valid (contains a dot). + * + * @param {string} address - The news address string to check. + */ +function isValidNewsAddress(address) { + return address.includes(".", 1) && !address.endsWith("."); +} + +/** + * Force the focus on the autocomplete input if the user clicks on an empty + * area of the address container. + * + * @param {Event} event - the event triggered by the click. + */ +function focusAddressInputOnClick(event) { + let container = event.target; + if (container.classList.contains("address-container")) { + container.querySelector(".address-row-input").focus(); + } +} + +/** + * Keep the Send buttons disabled until any recipient is entered. + */ +function updateSendLock() { + gSendLocked = true; + if (!gMsgCompose) { + return; + } + + const addressRows = [ + "toAddrContainer", + "ccAddrContainer", + "bccAddrContainer", + "newsgroupsAddrContainer", + ]; + + for (let parentID of addressRows) { + if (!gSendLocked) { + break; + } + + let parent = document.getElementById(parentID); + + if (!parent) { + continue; + } + + for (let address of parent.querySelectorAll(".address-pill")) { + let listNames = MimeParser.parseHeaderField( + address.fullAddress, + MimeParser.HEADER_ADDRESS + ); + let isMailingList = + listNames.length > 0 && + MailServices.ab.mailListNameExists(listNames[0].name); + + if ( + isValidAddress(address.emailAddress) || + isMailingList || + address.emailInput.classList.contains("news-input") + ) { + gSendLocked = false; + break; + } + } + } + + // Check the non pillified input text inside the autocomplete input fields. + for (let input of document.querySelectorAll( + ".address-row:not(.hidden):not(.address-row-raw) .address-row-input" + )) { + let inputValueTrim = input.value.trim(); + // If there's no text in the input, proceed with next input. + if (!inputValueTrim) { + continue; + } + // If text contains " >> " (typically from an unfinished autocompletion), + // lock Send and return. + if (inputValueTrim.includes(" >> ")) { + gSendLocked = true; + return; + } + + // If we find at least one valid pill, and in spite of potential other + // invalid pills or invalid addresses in the input, enable the Send button. + // It might be disabled again if the above autocomplete artifact is present + // in a subsequent row, to prevent sending the artifact as a valid address. + if ( + input.classList.contains("news-input") + ? isValidNewsAddress(inputValueTrim) + : isValidAddress(inputValueTrim) + ) { + gSendLocked = false; + } + } +} + +/** + * Check if the entered addresses are valid and alert the user if they are not. + * + * @param aMsgCompFields A nsIMsgCompFields object containing the fields to check. + */ +function CheckValidEmailAddress(aMsgCompFields) { + let invalidStr; + let recipientCount = 0; + // Check that each of the To, CC, and BCC recipients contains a '@'. + for (let type of ["to", "cc", "bcc"]) { + let recipients = aMsgCompFields.splitRecipients( + aMsgCompFields[type], + false + ); + // MsgCompFields contains only non-empty recipients. + recipientCount += recipients.length; + for (let recipient of recipients) { + if (!isValidAddress(recipient)) { + invalidStr = recipient; + break; + } + } + if (invalidStr) { + break; + } + } + + if (recipientCount == 0 && aMsgCompFields.newsgroups.trim() == "") { + Services.prompt.alert( + window, + getComposeBundle().getString("addressInvalidTitle"), + getComposeBundle().getString("noRecipients") + ); + return false; + } + + if (invalidStr) { + Services.prompt.alert( + window, + getComposeBundle().getString("addressInvalidTitle"), + getComposeBundle().getFormattedString("addressInvalid", [invalidStr], 1) + ); + return false; + } + + return true; +} + +/** + * Cycle through all the currently visible autocomplete addressing rows and + * generate pills for those inputs with leftover strings. Do the same if we + * have a pill currently being edited. This is necessary in case a user writes + * an extra address and clicks "Send" or "Save as..." before the text is + * converted into a pill. The input onBlur doesn't work if the click interaction + * happens on the window's menu bar. + */ +async function pillifyRecipients() { + for (let input of document.querySelectorAll( + ".address-row:not(.hidden):not(.address-row-raw) .address-row-input" + )) { + // If we find a leftover string in the input field, create a pill. If the + // newly created pill is not a valid address, the sending will stop. + if (input.value.trim()) { + recipientAddPills(input); + } + } + + // Update the currently editing pill, if any. + // It's impossible to edit more than one pill at once. + await document.querySelector("mail-address-pill.editing")?.updatePill(); +} + +/** + * Handle the dragover event on a recipient disclosure label. + * + * @param {Event} - The DOM dragover event on a recipient disclosure label. + */ +function showAddressRowButtonOnDragover(event) { + // Prevent dragover event's default action (which resets the current drag + // operation to "none"). + event.preventDefault(); +} + +/** + * Handle the drop event on a recipient disclosure label. + * + * @param {Event} - The DOM drop event on a recipient disclosure label. + */ +function showAddressRowButtonOnDrop(event) { + if (event.dataTransfer.types.includes("text/pills")) { + // If the dragged data includes the type "text/pills", we believe that + // the user is dragging our own pills, so we try to move the selected pills + // to the address row of the recipient label they were dropped on (Cc, Bcc, + // etc.), which will also show the row if needed. If there are no selected + // pills (so "text/pills" was generated elsewhere), moveSelectedPills() will + // bail out and we'll do nothing. + let row = document.getElementById(event.target.dataset.addressRow); + document.getElementById("recipientsContainer").moveSelectedPills(row); + } +} + +/** + * Command handler: Cut the selected pills. + */ +function cutSelectedPillsOnCommand() { + document.getElementById("recipientsContainer").cutSelectedPills(); +} + +/** + * Command handler: Copy the selected pills. + */ +function copySelectedPillsOnCommand() { + document.getElementById("recipientsContainer").copySelectedPills(); +} + +/** + * Command handler: Select the focused pill and all siblings in the same + * address row. + * + * @param {Element} focusPill - The focused <mail-address-pill> element. + */ +function selectAllSiblingPillsOnCommand(focusPill) { + let recipientsContainer = document.getElementById("recipientsContainer"); + // First deselect all pills to ensure that no pills outside the current + // address row are selected, e.g. when this action was triggered from + // context menu on already selected pill(s). + recipientsContainer.deselectAllPills(); + // Select all pills of the current address row. + recipientsContainer.selectSiblingPills(focusPill); +} + +/** + * Command handler: Select all recipient pills in the addressing area. + */ +function selectAllPillsOnCommand() { + document.getElementById("recipientsContainer").selectAllPills(); +} + +/** + * Command handler: Delete the selected pills. + */ +function deleteSelectedPillsOnCommand() { + document.getElementById("recipientsContainer").removeSelectedPills(); +} + +/** + * Command handler: Move the selected pills to another address row. + * + * @param {string} rowId - The id of the address row to move to. + */ +function moveSelectedPillsOnCommand(rowId) { + document + .getElementById("recipientsContainer") + .moveSelectedPills(document.getElementById(rowId)); +} + +/** + * Check if there are too many public recipients and offer to send them as BCC. + */ +function checkPublicRecipientsLimit() { + let notification = gComposeNotification.getNotificationWithValue( + "warnPublicRecipientsNotification" + ); + + let recipLimit = Services.prefs.getIntPref( + "mail.compose.warn_public_recipients.threshold" + ); + + let publicAddressPillsCount = getPublicAddressPillsCount(); + + if (publicAddressPillsCount < recipLimit) { + if (notification) { + gComposeNotification.removeNotification(notification); + } + return; + } + + // Reuse the existing notification since one is shown already. + if (notification) { + if (publicAddressPillsCount > 1) { + document.l10n.setAttributes( + notification.messageText, + "public-recipients-notice-multi", + { + count: publicAddressPillsCount, + } + ); + } else { + document.l10n.setAttributes( + notification.messageText, + "public-recipients-notice-single" + ); + } + return; + } + + // Construct the notification as we don't have one. + let bccButton = { + "l10n-id": "many-public-recipients-bcc", + callback() { + // Get public addresses before we remove the pills. + let publicAddresses = getPublicAddressPills().map( + pill => pill.fullAddress + ); + + addressRowClearPills(document.getElementById("addressRowTo")); + addressRowClearPills(document.getElementById("addressRowCc")); + // Add previously public address pills to Bcc address row and select them. + let bccRow = document.getElementById("addressRowBcc"); + addressRowAddRecipientsArray(bccRow, publicAddresses, true); + // Focus last added pill to prevent sticky selection with focus elsewhere. + bccRow.querySelector("mail-address-pill:last-of-type").focus(); + return false; + }, + }; + + let ignoreButton = { + "l10n-id": "many-public-recipients-ignore", + callback() { + gRecipientObserver.disconnect(); + gRecipientObserver = null; + // After closing notification with `Keep Recipients Public`, actively + // manage focus to prevent weird focus change e.g. to Contacts Sidebar. + // If focus was in addressing area before, restore that as the user might + // dismiss the notification when it appears while still adding recipients. + if (gLastFocusElement?.classList.contains("address-input")) { + gLastFocusElement.focus(); + return false; + } + + // Otherwise if there's no subject yet, focus that (ux-error-prevention). + let msgSubject = document.getElementById("msgSubject"); + if (!msgSubject.value) { + msgSubject.focus(); + return false; + } + + // Otherwise default to focusing message body. + document.getElementById("messageEditor").focus(); + return false; + }, + }; + + // NOTE: setting "public-recipients-notice-single" below, after the notification + // has been appended, so that the notification can be found and no further + // notifications are appended. + notification = gComposeNotification.appendNotification( + "warnPublicRecipientsNotification", + { + label: "", // "public-recipients-notice-single" + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + eventCallback(state) { + if (state == "dismissed") { + ignoreButton.callback(); + } + }, + }, + [bccButton, ignoreButton] + ); + + if (notification) { + if (publicAddressPillsCount > 1) { + document.l10n.setAttributes( + notification.messageText, + "public-recipients-notice-multi", + { + count: publicAddressPillsCount, + } + ); + } else { + document.l10n.setAttributes( + notification.messageText, + "public-recipients-notice-single" + ); + } + } +} + +/** + * Get all the address pills in the "To" and "Cc" fields. + * + * @returns {Element[]} All <mail-address-pill> elements in "To" and "CC" fields. + */ +function getPublicAddressPills() { + return [ + ...document.querySelectorAll("#toAddrContainer > mail-address-pill"), + ...document.querySelectorAll("#ccAddrContainer > mail-address-pill"), + ]; +} + +/** + * Gets the count of all the address pills in the "To" and "Cc" fields. This + * takes mailing lists into consideration as well. + */ +function getPublicAddressPillsCount() { + let pills = getPublicAddressPills(); + return pills.reduce( + (total, pill) => + pill.isMailList ? total + pill.listAddressCount : total + 1, + 0 + ); +} + +/** + * Check for Bcc recipients in an encrypted message and warn the user. + * The warning is not shown if the only Bcc recipient is the sender. + */ +async function checkEncryptedBccRecipients() { + let notification = gComposeNotification.getNotificationWithValue( + "warnEncryptedBccRecipients" + ); + + if (!gWantCannotEncryptBCCNotification) { + if (notification) { + gComposeNotification.removeNotification(notification); + } + return; + } + + let bccRecipients = [ + ...document.querySelectorAll("#bccAddrContainer > mail-address-pill"), + ]; + let bccIsSender = bccRecipients.every( + pill => pill.emailAddress == gCurrentIdentity.email + ); + + if (!gSendEncrypted || !bccRecipients.length || bccIsSender) { + if (notification) { + gComposeNotification.removeNotification(notification); + } + return; + } + + if (notification) { + return; + } + + let ignoreButton = { + "l10n-id": "encrypted-bcc-ignore-button", + callback() { + gWantCannotEncryptBCCNotification = false; + return false; + }, + }; + + gComposeNotification.appendNotification( + "warnEncryptedBccRecipients", + { + label: await document.l10n.formatValue("encrypted-bcc-warning"), + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + eventCallback(state) { + if (state == "dismissed") { + ignoreButton.callback(); + } + }, + }, + [ignoreButton] + ); +} + +async function SendMessage() { + await pillifyRecipients(); + let sendInBackground = Services.prefs.getBoolPref( + "mailnews.sendInBackground" + ); + if (sendInBackground && AppConstants.platform != "macosx") { + let count = [...Services.wm.getEnumerator(null)].length; + if (count == 1) { + sendInBackground = false; + } + } + + await GenericSendMessage( + sendInBackground + ? Ci.nsIMsgCompDeliverMode.Background + : Ci.nsIMsgCompDeliverMode.Now + ); + ExitFullscreenMode(); +} + +async function SendMessageWithCheck() { + await pillifyRecipients(); + var warn = Services.prefs.getBoolPref("mail.warn_on_send_accel_key"); + + if (warn) { + let bundle = getComposeBundle(); + let checkValue = { value: false }; + let buttonPressed = Services.prompt.confirmEx( + window, + bundle.getString("sendMessageCheckWindowTitle"), + bundle.getString("sendMessageCheckLabel"), + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1, + bundle.getString("sendMessageCheckSendButtonLabel"), + null, + null, + bundle.getString("CheckMsg"), + checkValue + ); + if (buttonPressed != 0) { + return; + } + if (checkValue.value) { + Services.prefs.setBoolPref("mail.warn_on_send_accel_key", false); + } + } + + let sendInBackground = Services.prefs.getBoolPref( + "mailnews.sendInBackground" + ); + + let mode; + if (Services.io.offline) { + mode = Ci.nsIMsgCompDeliverMode.Later; + } else { + mode = sendInBackground + ? Ci.nsIMsgCompDeliverMode.Background + : Ci.nsIMsgCompDeliverMode.Now; + } + await GenericSendMessage(mode); + ExitFullscreenMode(); +} + +async function SendMessageLater() { + await pillifyRecipients(); + await GenericSendMessage(Ci.nsIMsgCompDeliverMode.Later); + ExitFullscreenMode(); +} + +function ExitFullscreenMode() { + // On OS X we need to deliberately exit full screen mode after sending. + if (AppConstants.platform == "macosx") { + window.fullScreen = false; + } +} + +function Save() { + switch (defaultSaveOperation) { + case "file": + SaveAsFile(false); + break; + case "template": + SaveAsTemplate(false).catch(console.error); + break; + default: + SaveAsDraft(false).catch(console.error); + break; + } +} + +function SaveAsFile(saveAs) { + GetCurrentEditorElement().contentDocument.title = + document.getElementById("msgSubject").value; + + if (gMsgCompose.bodyConvertible() == Ci.nsIMsgCompConvertible.Plain) { + SaveDocument(saveAs, false, "text/plain"); + } else { + SaveDocument(saveAs, false, "text/html"); + } + defaultSaveOperation = "file"; +} + +async function SaveAsDraft() { + gAutoSaveKickedIn = false; + gEditingDraft = true; + + await pillifyRecipients(); + await GenericSendMessage(Ci.nsIMsgCompDeliverMode.SaveAsDraft); + defaultSaveOperation = "draft"; +} + +async function SaveAsTemplate() { + gAutoSaveKickedIn = false; + gEditingDraft = false; + + await pillifyRecipients(); + let savedReferences = null; + if (gMsgCompose && gMsgCompose.compFields) { + // Clear References header. When we use the template, we don't want that + // header, yet, "edit as new message" maintains it. So we need to clear + // it when saving the template. + // Note: The In-Reply-To header is the last entry in the references header, + // so it will get cleared as well. + savedReferences = gMsgCompose.compFields.references; + gMsgCompose.compFields.references = null; + } + + await GenericSendMessage(Ci.nsIMsgCompDeliverMode.SaveAsTemplate); + defaultSaveOperation = "template"; + + if (savedReferences) { + gMsgCompose.compFields.references = savedReferences; + } +} + +// Sets the additional FCC, in addition to the default FCC. +function MessageFcc(aFolder) { + if (!gMsgCompose) { + return; + } + + var msgCompFields = gMsgCompose.compFields; + if (!msgCompFields) { + return; + } + + // Get the uri for the folder to FCC into. + var fccURI = aFolder.URI; + msgCompFields.fcc2 = msgCompFields.fcc2 == fccURI ? "nocopy://" : fccURI; +} + +function updateOptionsMenu() { + setSecuritySettings("_Menubar"); + + let menuItem = document.getElementById("menu_inlineSpellCheck"); + if (gSpellCheckingEnabled) { + menuItem.setAttribute("checked", "true"); + } else { + menuItem.removeAttribute("checked"); + } +} + +function updatePriorityMenu() { + if (gMsgCompose) { + var msgCompFields = gMsgCompose.compFields; + if (msgCompFields && msgCompFields.priority) { + var priorityMenu = document.getElementById("priorityMenu"); + priorityMenu.querySelector('[checked="true"]').removeAttribute("checked"); + priorityMenu + .querySelector('[value="' + msgCompFields.priority + '"]') + .setAttribute("checked", "true"); + } + } +} + +function updatePriorityToolbarButton(newPriorityValue) { + var prioritymenu = document.getElementById("priorityMenu-button"); + if (prioritymenu) { + prioritymenu.value = newPriorityValue; + } +} + +function PriorityMenuSelect(target) { + if (gMsgCompose) { + var msgCompFields = gMsgCompose.compFields; + if (msgCompFields) { + msgCompFields.priority = target.getAttribute("value"); + } + + // keep priority toolbar button in synch with possible changes via the menu item + updatePriorityToolbarButton(target.getAttribute("value")); + } +} + +/** + * Initialise the send format menu using the current gMsgCompose.compFields. + */ +function initSendFormatMenu() { + let formatToId = new Map([ + [Ci.nsIMsgCompSendFormat.PlainText, "format_plain"], + [Ci.nsIMsgCompSendFormat.HTML, "format_html"], + [Ci.nsIMsgCompSendFormat.Both, "format_both"], + [Ci.nsIMsgCompSendFormat.Auto, "format_auto"], + ]); + + let sendFormat = gMsgCompose.compFields.deliveryFormat; + + if (sendFormat == Ci.nsIMsgCompSendFormat.Unset) { + sendFormat = Services.prefs.getIntPref( + "mail.default_send_format", + Ci.nsIMsgCompSendFormat.Auto + ); + + if (!formatToId.has(sendFormat)) { + // Unknown preference value. + sendFormat = Ci.nsIMsgCompSendFormat.Auto; + } + } + + // Make the composition field uses the same as determined above. Specifically, + // if the deliveryFormat was Unset, we now set it to a specific value. + gMsgCompose.compFields.deliveryFormat = sendFormat; + + for (let [format, id] of formatToId.entries()) { + let menuitem = document.getElementById(id); + menuitem.value = String(format); + if (format == sendFormat) { + menuitem.setAttribute("checked", "true"); + } else { + menuitem.removeAttribute("checked"); + } + } + + document + .getElementById("outputFormatMenu") + .addEventListener("command", event => { + let prevSendFormat = gMsgCompose.compFields.deliveryFormat; + let newSendFormat = parseInt(event.target.value, 10); + gMsgCompose.compFields.deliveryFormat = newSendFormat; + gContentChanged = prevSendFormat != newSendFormat; + }); +} + +/** + * Walk through a plain text list of recipients and add them to the inline spell + * checker ignore list, e.g. to avoid that known recipient names get marked + * wrong in message body. + * + * @param {string} aAddressesToAdd - A (comma-separated) recipient(s) string. + */ +function addRecipientsToIgnoreList(aAddressesToAdd) { + if (gSpellCheckingEnabled) { + // break the list of potentially many recipients back into individual names + let addresses = + MailServices.headerParser.parseEncodedHeader(aAddressesToAdd); + let tokenizedNames = []; + + // Each name could consist of multiple word delimited by either commas or spaces, i.e. Green Lantern + // or Lantern,Green. Tokenize on comma first, then tokenize again on spaces. + for (let addr of addresses) { + if (!addr.name) { + continue; + } + let splitNames = addr.name.split(","); + for (let i = 0; i < splitNames.length; i++) { + // now tokenize off of white space + let splitNamesFromWhiteSpaceArray = splitNames[i].split(" "); + for ( + let whiteSpaceIndex = 0; + whiteSpaceIndex < splitNamesFromWhiteSpaceArray.length; + whiteSpaceIndex++ + ) { + if (splitNamesFromWhiteSpaceArray[whiteSpaceIndex]) { + tokenizedNames.push(splitNamesFromWhiteSpaceArray[whiteSpaceIndex]); + } + } + } + } + spellCheckReadyObserver.addWordsToIgnore(tokenizedNames); + } +} + +/** + * Observer waiting for spell checker to become initialized or to complete + * checking. When it fires, it pushes new words to be ignored to the speller. + */ +var spellCheckReadyObserver = { + _topic: "inlineSpellChecker-spellCheck-ended", + + _ignoreWords: [], + + observe(aSubject, aTopic, aData) { + if (aTopic != this._topic) { + return; + } + + this.removeObserver(); + this._addWords(); + }, + + _isAdded: false, + + addObserver() { + if (this._isAdded) { + return; + } + + Services.obs.addObserver(this, this._topic); + this._isAdded = true; + }, + + removeObserver() { + if (!this._isAdded) { + return; + } + + Services.obs.removeObserver(this, this._topic); + this._clearPendingWords(); + this._isAdded = false; + }, + + addWordsToIgnore(aIgnoreWords) { + this._ignoreWords.push(...aIgnoreWords); + let checker = GetCurrentEditorSpellChecker(); + if (!checker || checker.spellCheckPending) { + // spellchecker is enabled, but we must wait for its init to complete + this.addObserver(); + } else { + this._addWords(); + } + }, + + _addWords() { + // At the time the speller finally got initialized, we may already be closing + // the compose together with the speller, so we need to check if they + // are still valid. + let checker = GetCurrentEditorSpellChecker(); + if (gMsgCompose && checker?.enableRealTimeSpell) { + checker.ignoreWords(this._ignoreWords); + } + this._clearPendingWords(); + }, + + _clearPendingWords() { + this._ignoreWords.length = 0; + }, +}; + +/** + * Called if the list of recipients changed in any way. + * + * @param {boolean} automatic - Set to true if the change of recipients was + * invoked programmatically and should not be considered a change of message + * content. + */ +function onRecipientsChanged(automatic) { + if (!automatic) { + gContentChanged = true; + } + updateSendCommands(true); +} + +/** + * Show the popup identified by aPopupID + * at the anchor element identified by aAnchorID. + * + * Note: All but the first 2 parameters are identical with the parameters of + * the openPopup() method of XUL popup element. For details, please consult docs. + * Except aPopupID, all parameters are optional. + * Example: showPopupById("aPopupID", "aAnchorID"); + * + * @param aPopupID the ID of the popup element to be shown + * @param aAnchorID the ID of an element to which the popup should be anchored + * @param aPosition a single-word alignment value for the position parameter + * of openPopup() method; defaults to "after_start" if omitted. + * @param x x offset from default position + * @param y y offset from default position + * @param isContextMenu {boolean} For details, see documentation. + * @param attributesOverride {boolean} whether the position attribute on the + * popup node overrides the position parameter + * @param triggerEvent the event that triggered the popup + */ +function showPopupById( + aPopupID, + aAnchorID, + aPosition = "after_start", + x, + y, + isContextMenu, + attributesOverride, + triggerEvent +) { + let popup = document.getElementById(aPopupID); + let anchor = document.getElementById(aAnchorID); + popup.openPopup( + anchor, + aPosition, + x, + y, + isContextMenu, + attributesOverride, + triggerEvent + ); +} + +function InitLanguageMenu() { + var languageMenuList = document.getElementById("languageMenuList"); + if (!languageMenuList) { + return; + } + + var spellChecker = Cc["@mozilla.org/spellchecker/engine;1"].getService( + Ci.mozISpellCheckingEngine + ); + + // Get the list of dictionaries from + // the spellchecker. + + var dictList = spellChecker.getDictionaryList(); + + let extraItemCount = dictList.length === 0 ? 1 : 2; + + // If dictionary count hasn't changed then no need to update the menu. + if (dictList.length + extraItemCount == languageMenuList.childElementCount) { + return; + } + + var sortedList = gSpellChecker.sortDictionaryList(dictList); + + let getMoreItem = document.createXULElement("menuitem"); + document.l10n.setAttributes(getMoreItem, "spell-add-dictionaries"); + getMoreItem.addEventListener("command", event => { + event.stopPropagation(); + openDictionaryList(); + }); + let getMoreArray = [getMoreItem]; + + if (extraItemCount > 1) { + getMoreArray.unshift(document.createXULElement("menuseparator")); + } + + // Remove any languages from the list. + languageMenuList.replaceChildren( + ...sortedList.map(dict => { + let item = document.createXULElement("menuitem"); + item.setAttribute("label", dict.displayName); + item.setAttribute("value", dict.localeCode); + item.setAttribute("type", "checkbox"); + item.setAttribute("selection-type", "multiple"); + if (dictList.length > 1) { + item.setAttribute("closemenu", "none"); + } + return item; + }), + ...getMoreArray + ); +} + +function OnShowDictionaryMenu(aTarget) { + InitLanguageMenu(); + + for (let item of aTarget.children) { + item.setAttribute( + "checked", + gActiveDictionaries.has(item.getAttribute("value")) + ); + } +} + +function languageMenuListOpened() { + document + .getElementById("languageStatusButton") + .setAttribute("aria-expanded", "true"); +} + +function languageMenuListClosed() { + document + .getElementById("languageStatusButton") + .setAttribute("aria-expanded", "false"); +} + +/** + * Set of the active dictionaries. We maintain this cached state so we don't + * need a spell checker instance to know the active dictionaries. This is + * especially relevant when inline spell checking is disabled. + * + * @type {Set<string>} + */ +var gActiveDictionaries = new Set(); +/** + * Change the language of the composition and if we are using inline + * spell check, recheck the message with the new dictionary. + * + * Note: called from the "Check Spelling" panel in SelectLanguage(). + * + * @param {string[]} languages - New languages to set. + */ +async function ComposeChangeLanguage(languages) { + let currentLanguage = document.documentElement.getAttribute("lang"); + if ( + (languages.length === 1 && currentLanguage != languages[0]) || + languages.length !== 1 + ) { + let languageToSet = ""; + if (languages.length === 1) { + languageToSet = languages[0]; + } + // Update the document language as well. + document.documentElement.setAttribute("lang", languageToSet); + } + + await gSpellChecker?.selectDictionaries(languages); + + let checker = GetCurrentEditorSpellChecker(); + if (checker?.spellChecker) { + await checker.spellChecker.setCurrentDictionaries(languages); + } + // Update subject spell checker languages. If for some reason the spell + // checker isn't ready yet, don't auto-create it, hence pass 'false'. + let subjectSpellChecker = checker?.spellChecker + ? document.getElementById("msgSubject").editor.getInlineSpellChecker(false) + : null; + if (subjectSpellChecker?.spellChecker) { + await subjectSpellChecker.spellChecker.setCurrentDictionaries(languages); + } + + // now check the document over again with the new dictionary + if (gSpellCheckingEnabled) { + if (checker?.spellChecker) { + checker.spellCheckRange(null); + } + + if (subjectSpellChecker?.spellChecker) { + // Also force a recheck of the subject. + subjectSpellChecker.spellCheckRange(null); + } + } + + await updateLanguageInStatusBar(languages); + + // Update the language in the composition fields, so we can save it + // to the draft next time. + if (gMsgCompose?.compFields) { + let langs = ""; + if (!Services.prefs.getBoolPref("mail.suppress_content_language")) { + langs = languages.join(", "); + } + gMsgCompose.compFields.contentLanguage = langs; + } + + gActiveDictionaries = new Set(languages); + + // Notify compose WebExtension API about changed dictionaries. + window.dispatchEvent( + new CustomEvent("active-dictionaries-changed", { + detail: languages.join(","), + }) + ); +} + +/** + * Change the language of the composition and if we are using inline + * spell check, recheck the message with the new dictionary. + * + * @param {Event} event - Event of selecting an item in the spelling button + * menulist popup. + */ +function ChangeLanguage(event) { + let curLangs = new Set(gActiveDictionaries); + if (curLangs.has(event.target.value)) { + curLangs.delete(event.target.value); + } else { + curLangs.add(event.target.value); + } + ComposeChangeLanguage(Array.from(curLangs)); + event.stopPropagation(); +} + +/** + * Update the active dictionaries in the status bar. + * + * @param {string[]} dictionaries + */ +async function updateLanguageInStatusBar(dictionaries) { + // HACK: calling sortDictionaryList (in InitLanguageMenu) may fail the first + // time due to synchronous loading of the .ftl files. If we load the files + // and wait for a known value asynchronously, no such failure will happen. + await new Localization([ + "toolkit/intl/languageNames.ftl", + "toolkit/intl/regionNames.ftl", + ]).formatValue("language-name-en"); + + InitLanguageMenu(); + let languageMenuList = document.getElementById("languageMenuList"); + let languageStatusButton = document.getElementById("languageStatusButton"); + if (!languageMenuList || !languageStatusButton) { + return; + } + + if (!dictionaries) { + dictionaries = Array.from(gActiveDictionaries); + } + let listFormat = new Intl.ListFormat(undefined, { + type: "conjunction", + style: "short", + }); + let languages = []; + let item = languageMenuList.firstElementChild; + + // No status display, if there is only one or no spelling dictionary available. + if (languageMenuList.childElementCount <= 3) { + languageStatusButton.hidden = true; + languageStatusButton.textContent = ""; + return; + } + + languageStatusButton.hidden = false; + while (item) { + if (item.tagName.toLowerCase() === "menuseparator") { + break; + } + if (dictionaries.includes(item.getAttribute("value"))) { + languages.push(item.getAttribute("label")); + } + item = item.nextElementSibling; + } + if (languages.length > 0) { + languageStatusButton.textContent = listFormat.format(languages); + } else { + languageStatusButton.textContent = listFormat.format(dictionaries); + } +} + +/** + * Toggle Return Receipt (Disposition-Notification-To: header). + * + * @param {boolean} [forcedState] - Forced state to use for returnReceipt. + * If not set, the current state will be toggled. + */ +function ToggleReturnReceipt(forcedState) { + let msgCompFields = gMsgCompose.compFields; + if (!msgCompFields) { + return; + } + if (forcedState === undefined) { + msgCompFields.returnReceipt = !msgCompFields.returnReceipt; + gReceiptOptionChanged = true; + } else { + if (msgCompFields.returnReceipt != forcedState) { + gReceiptOptionChanged = true; + } + msgCompFields.returnReceipt = forcedState; + } + for (let item of document.querySelectorAll(`menuitem[command="cmd_toggleReturnReceipt"], + toolbarbutton[command="cmd_toggleReturnReceipt"]`)) { + item.setAttribute("checked", msgCompFields.returnReceipt); + } +} + +function ToggleDSN(target) { + let msgCompFields = gMsgCompose.compFields; + if (msgCompFields) { + msgCompFields.DSN = !msgCompFields.DSN; + target.setAttribute("checked", msgCompFields.DSN); + gDSNOptionChanged = true; + } +} + +function ToggleAttachVCard(target) { + var msgCompFields = gMsgCompose.compFields; + if (msgCompFields) { + msgCompFields.attachVCard = !msgCompFields.attachVCard; + target.setAttribute("checked", msgCompFields.attachVCard); + gAttachVCardOptionChanged = true; + } +} + +/** + * Toggles or sets the status of manual Attachment Reminder, i.e. whether + * the user will get the "Attachment Reminder" alert before sending or not. + * Toggles checkmark on "Remind me later" menuitem and internal + * gManualAttachmentReminder flag accordingly. + * + * @param aState (optional) true = activate reminder. + * false = deactivate reminder. + * (default) = toggle reminder state. + */ +function toggleAttachmentReminder(aState = !gManualAttachmentReminder) { + gManualAttachmentReminder = aState; + document.getElementById("cmd_remindLater").setAttribute("checked", aState); + gMsgCompose.compFields.attachmentReminder = aState; + + // If we enabled manual reminder, the reminder can't be turned off. + if (aState) { + gDisableAttachmentReminder = false; + } + + manageAttachmentNotification(false); +} + +/** + * Triggers or removes the CSS animation for the counter of newly uploaded + * attachments. + */ +function toggleAttachmentAnimation() { + gAttachmentCounter.classList.toggle("is_animating"); +} + +function FillIdentityList(menulist) { + let accounts = FolderUtils.allAccountsSorted(true); + + let accountHadSeparator = false; + let firstAccountWithIdentities = true; + for (let account of accounts) { + let identities = account.identities; + + if (identities.length == 0) { + continue; + } + + let needSeparator = identities.length > 1; + if (needSeparator || accountHadSeparator) { + // Separate identities from this account from the previous + // account's identities if there is more than 1 in the current + // or previous account. + if (!firstAccountWithIdentities) { + // only if this is not the first account shown + let separator = document.createXULElement("menuseparator"); + menulist.menupopup.appendChild(separator); + } + accountHadSeparator = needSeparator; + } + firstAccountWithIdentities = false; + + for (let i = 0; i < identities.length; i++) { + let identity = identities[i]; + let item = menulist.appendItem( + identity.identityName, + identity.fullAddress, + account.incomingServer.prettyName + ); + item.setAttribute("identitykey", identity.key); + item.setAttribute("accountkey", account.key); + if (i == 0) { + // Mark the first identity as default. + item.setAttribute("default", "true"); + } + // Create the menuitem description and add it after the last label in the + // menuitem internals. + let desc = document.createXULElement("label"); + desc.value = item.getAttribute("description"); + desc.classList.add("menu-description"); + desc.setAttribute("crop", "end"); + item.querySelector("label:last-child").after(desc); + } + } + + menulist.menupopup.appendChild(document.createXULElement("menuseparator")); + menulist.menupopup + .appendChild(document.createXULElement("menuitem")) + .setAttribute("command", "cmd_customizeFromAddress"); +} + +function getCurrentAccountKey() { + // Get the account's key. + let identityList = document.getElementById("msgIdentity"); + return identityList.getAttribute("accountkey"); +} + +function getCurrentIdentityKey() { + // Get the identity key. + return gCurrentIdentity.key; +} + +function AdjustFocus() { + // If is NNTP account, check the newsgroup field. + let account = MailServices.accounts.getAccount(getCurrentAccountKey()); + let accountType = account.incomingServer.type; + + let element = + accountType == "nntp" + ? document.getElementById("newsgroupsAddrContainer") + : document.getElementById("toAddrContainer"); + + // Focus on the recipient input field if no pills are present. + if (element.querySelectorAll("mail-address-pill").length == 0) { + element.querySelector(".address-row-input").focus(); + return; + } + + // Focus subject if empty. + element = document.getElementById("msgSubject"); + if (element.value == "") { + element.focus(); + return; + } + + // Focus message body. + focusMsgBody(); +} + +/** + * Set the compose window title with flavors (Write | Print Preview). + * + * @param isPrintPreview (optional) true: Set title for 'Print Preview' window. + * false: Set title for 'Write' window (default). + */ +function SetComposeWindowTitle(isPrintPreview = false) { + let aStringName = isPrintPreview + ? "windowTitlePrintPreview" + : "windowTitleWrite"; + let subject = + document.getElementById("msgSubject").value.trim() || + getComposeBundle().getString("defaultSubject"); + let brandBundle = document.getElementById("brandBundle"); + let brandShortName = brandBundle.getString("brandShortName"); + let newTitle = getComposeBundle().getFormattedString(aStringName, [ + subject, + brandShortName, + ]); + document.title = newTitle; + if (AppConstants.platform == "macosx") { + document.getElementById("titlebar-title-label").value = newTitle; + } +} + +// Check for changes to document and allow saving before closing +// This is hooked up to the OS's window close widget (e.g., "X" for Windows) +function ComposeCanClose() { + // No open compose window? + if (!gMsgCompose) { + return true; + } + + // Do this early, so ldap sessions have a better chance to + // cleanup after themselves. + if (gSendOperationInProgress || gSaveOperationInProgress) { + let result; + + let brandBundle = document.getElementById("brandBundle"); + let brandShortName = brandBundle.getString("brandShortName"); + let promptTitle = gSendOperationInProgress + ? getComposeBundle().getString("quitComposeWindowTitle") + : getComposeBundle().getString("quitComposeWindowSaveTitle"); + let promptMsg = gSendOperationInProgress + ? getComposeBundle().getFormattedString( + "quitComposeWindowMessage2", + [brandShortName], + 1 + ) + : getComposeBundle().getFormattedString( + "quitComposeWindowSaveMessage", + [brandShortName], + 1 + ); + let quitButtonLabel = getComposeBundle().getString( + "quitComposeWindowQuitButtonLabel2" + ); + let waitButtonLabel = getComposeBundle().getString( + "quitComposeWindowWaitButtonLabel2" + ); + + result = Services.prompt.confirmEx( + window, + promptTitle, + promptMsg, + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1, + waitButtonLabel, + quitButtonLabel, + null, + null, + { value: 0 } + ); + + if (result == 1) { + gMsgCompose.abort(); + return true; + } + return false; + } + + // Returns FALSE only if user cancels save action + if ( + gContentChanged || + gMsgCompose.bodyModified || + gAutoSaveKickedIn || + gReceiptOptionChanged || + gDSNOptionChanged + ) { + // call window.focus, since we need to pop up a dialog + // and therefore need to be visible (to prevent user confusion) + window.focus(); + let draftFolderURI = gCurrentIdentity.draftFolder; + let draftFolderName = + MailUtils.getOrCreateFolder(draftFolderURI).prettyName; + let result = Services.prompt.confirmEx( + window, + getComposeBundle().getString("saveDlogTitle"), + getComposeBundle().getFormattedString("saveDlogMessages3", [ + draftFolderName, + ]), + Services.prompt.BUTTON_TITLE_SAVE * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 + + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_2, + null, + null, + getComposeBundle().getString("discardButtonLabel"), + null, + { value: 0 } + ); + switch (result) { + case 0: // Save + // Since we're going to save the message, we tell toolkit that + // the close command failed, by returning false, and then + // we close the window ourselves after the save is done. + gCloseWindowAfterSave = true; + // We catch the exception because we need to tell toolkit that it + // shouldn't close the window, because we're going to close it + // ourselves. If we don't tell toolkit that, and then close the window + // ourselves, the toolkit code that keeps track of the open windows + // gets off by one and the app can close unexpectedly on os's that + // shutdown the app when the last window is closed. + GenericSendMessage(Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft).catch( + console.error + ); + return false; + case 1: // Cancel + return false; + case 2: // Don't Save + // don't delete the draft if we didn't start off editing a draft + // and the user hasn't explicitly saved it. + if (!gEditingDraft && gAutoSaveKickedIn) { + RemoveDraft(); + } + // Remove auto-saved draft created during "edit template". + if (gMsgCompose.compFields.templateId && gAutoSaveKickedIn) { + RemoveDraft(); + } + break; + } + } + + return true; +} + +function RemoveDraft() { + try { + var draftUri = gMsgCompose.compFields.draftId; + var msgKey = draftUri.substr(draftUri.indexOf("#") + 1); + let folder = MailUtils.getExistingFolder(gMsgCompose.savedFolderURI); + if (!folder) { + return; + } + try { + if (folder.getFlag(Ci.nsMsgFolderFlags.Drafts)) { + let msgHdr = folder.GetMessageHeader(msgKey); + folder.deleteMessages([msgHdr], null, true, false, null, false); + } + } catch (ex) { + // couldn't find header - perhaps an imap folder. + var imapFolder = folder.QueryInterface(Ci.nsIMsgImapMailFolder); + if (imapFolder) { + imapFolder.storeImapFlags( + Ci.nsMsgFolderFlags.Expunged, + true, + [msgKey], + null + ); + } + } + } catch (ex) {} +} + +function SetContentAndBodyAsUnmodified() { + gMsgCompose.bodyModified = false; + gContentChanged = false; +} + +function MsgComposeCloseWindow() { + if (gMsgCompose) { + gMsgCompose.CloseWindow(); + } else { + window.close(); + } +} + +function GetLastAttachDirectory() { + var lastDirectory; + + try { + lastDirectory = Services.prefs.getComplexValue( + kComposeAttachDirPrefName, + Ci.nsIFile + ); + } catch (ex) { + // this will fail the first time we attach a file + // as we won't have a pref value. + lastDirectory = null; + } + + return lastDirectory; +} + +// attachedLocalFile must be a nsIFile +function SetLastAttachDirectory(attachedLocalFile) { + try { + let file = attachedLocalFile.QueryInterface(Ci.nsIFile); + let parent = file.parent.QueryInterface(Ci.nsIFile); + + Services.prefs.setComplexValue( + kComposeAttachDirPrefName, + Ci.nsIFile, + parent + ); + } catch (ex) { + dump("error: SetLastAttachDirectory failed: " + ex + "\n"); + } +} + +function AttachFile() { + if (gAttachmentBucket.itemCount) { + // If there are existing attachments already, restore attachment pane before + // showing the file picker so that user can see them while adding more. + toggleAttachmentPane("show"); + } + + // Get file using nsIFilePicker and convert to URL + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init( + window, + getComposeBundle().getString("chooseFileToAttach"), + Ci.nsIFilePicker.modeOpenMultiple + ); + + let lastDirectory = GetLastAttachDirectory(); + if (lastDirectory) { + fp.displayDirectory = lastDirectory; + } + + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !fp.files) { + return; + } + + let file; + let attachments = []; + + for (file of [...fp.files]) { + attachments.push(FileToAttachment(file)); + } + + AddAttachments(attachments); + SetLastAttachDirectory(file); + }); +} + +/** + * Convert an nsIFile instance into an nsIMsgAttachment. + * + * @param file the nsIFile + * @returns an attachment pointing to the file + */ +function FileToAttachment(file) { + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + let attachment = Cc[ + "@mozilla.org/messengercompose/attachment;1" + ].createInstance(Ci.nsIMsgAttachment); + + attachment.url = fileHandler.getURLSpecFromActualFile(file); + attachment.size = file.fileSize; + return attachment; +} + +async function messageAttachmentToFile(attachment) { + let pathTempDir = PathUtils.join( + PathUtils.tempDir, + "pid-" + Services.appinfo.processID + ); + await IOUtils.makeDirectory(pathTempDir, { permissions: 0o700 }); + let pathTempFile = await IOUtils.createUniqueFile( + pathTempDir, + attachment.name.replaceAll(/[/:*?\"<>|]/g, "_"), + 0o600 + ); + let tempFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + tempFile.initWithPath(pathTempFile); + let extAppLauncher = Cc[ + "@mozilla.org/uriloader/external-helper-app-service;1" + ].getService(Ci.nsPIExternalAppLauncher); + extAppLauncher.deleteTemporaryFileOnExit(tempFile); + + let service = MailServices.messageServiceFromURI(attachment.url); + let bytes = await new Promise((resolve, reject) => { + let streamlistener = { + _data: [], + _stream: null, + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + if (!this._stream) { + this._stream = Cc[ + "@mozilla.org/scriptableinputstream;1" + ].createInstance(Ci.nsIScriptableInputStream); + this._stream.init(aInputStream); + } + this._data.push(this._stream.read(aCount)); + }, + onStartRequest() {}, + onStopRequest(aRequest, aStatus) { + if (aStatus == Cr.NS_OK) { + resolve(this._data.join("")); + } else { + console.error(aStatus); + reject(); + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIStreamListener", + "nsIRequestObserver", + ]), + }; + + service.streamMessage( + attachment.url, + streamlistener, + null, // aMsgWindow + null, // aUrlListener + false, // aConvertData + "" //aAdditionalHeader + ); + }); + await IOUtils.write( + pathTempFile, + lazy.MailStringUtils.byteStringToUint8Array(bytes) + ); + return tempFile; +} + +/** + * Add a list of attachment objects as attachments. The attachment URLs must + * be set. + * + * @param {nsIMsgAttachment[]} aAttachments - Objects to add as attachments. + * @param {boolean} [aContentChanged=true] - Optional value to assign gContentChanged + * after adding attachments. + */ +async function AddAttachments(aAttachments, aContentChanged = true) { + let addedAttachments = []; + let items = []; + + for (let attachment of aAttachments) { + if (!attachment?.url || DuplicateFileAlreadyAttached(attachment)) { + continue; + } + + if (!attachment.name) { + attachment.name = gMsgCompose.AttachmentPrettyName(attachment.url, null); + } + + // For security reasons, don't allow *-message:// uris to leak out. + // We don't want to reveal the .slt path (for mailbox://), or the username + // or hostname. + // Don't allow file or mail/news protocol uris to leak out either. + if ( + /^mailbox-message:|^imap-message:|^news-message:/i.test(attachment.name) + ) { + attachment.name = getComposeBundle().getString( + "messageAttachmentSafeName" + ); + } else if (/^file:|^mailbox:|^imap:|^s?news:/i.test(attachment.name)) { + attachment.name = getComposeBundle().getString("partAttachmentSafeName"); + } + + // Create temporary files for message attachments. + if ( + /^mailbox-message:|^imap-message:|^news-message:/i.test(attachment.url) + ) { + try { + let messageFile = await messageAttachmentToFile(attachment); + // Store the original mailbox:// url in contentLocation. + attachment.contentLocation = attachment.url; + attachment.url = Services.io.newFileURI(messageFile).spec; + } catch (ex) { + console.error( + `Could not save message attachment ${attachment.url} as file: ${ex}` + ); + } + } + + if ( + attachment.msgUri && + /^mailbox-message:|^imap-message:|^news-message:/i.test( + attachment.msgUri + ) && + attachment.url && + /^mailbox:|^imap:|^s?news:/i.test(attachment.url) + ) { + // This is an attachment of another message, create a temporary file and + // update the url. + let pathTempDir = PathUtils.join( + PathUtils.tempDir, + "pid-" + Services.appinfo.processID + ); + await IOUtils.makeDirectory(pathTempDir, { permissions: 0o700 }); + let tempDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + tempDir.initWithPath(pathTempDir); + + let tempFile = gMessenger.saveAttachmentToFolder( + attachment.contentType, + attachment.url, + encodeURIComponent(attachment.name), + attachment.msgUri, + tempDir + ); + let extAppLauncher = Cc[ + "@mozilla.org/uriloader/external-helper-app-service;1" + ].getService(Ci.nsPIExternalAppLauncher); + extAppLauncher.deleteTemporaryFileOnExit(tempFile); + // Store the original mailbox:// url in contentLocation. + attachment.contentLocation = attachment.url; + attachment.url = Services.io.newFileURI(tempFile).spec; + } + + let item = gAttachmentBucket.appendItem(attachment); + addedAttachments.push(attachment); + + let tooltiptext; + try { + tooltiptext = decodeURI(attachment.url); + } catch { + tooltiptext = attachment.url; + } + item.setAttribute("tooltiptext", tooltiptext); + item.addEventListener("command", OpenSelectedAttachment); + items.push(item); + } + + if (addedAttachments.length > 0) { + // Trigger a visual feedback to let the user know how many attachments have + // been added. + gAttachmentCounter.textContent = `+${addedAttachments.length}`; + toggleAttachmentAnimation(); + + // Move the focus on the last attached file so the user can see a visual + // feedback of what was added. + gAttachmentBucket.selectedIndex = gAttachmentBucket.getIndexOfItem( + items[items.length - 1] + ); + + // Ensure the selected item is visible and if not the box will scroll to it. + gAttachmentBucket.ensureIndexIsVisible(gAttachmentBucket.selectedIndex); + + AttachmentsChanged("show", aContentChanged); + dispatchAttachmentBucketEvent("attachments-added", addedAttachments); + + // Set min height for the attachment bucket. + if (!gAttachmentBucket.style.minHeight) { + // Min height is the height of the first child plus padding and border. + // Note: we assume the computed styles have px values. + let bucketStyle = getComputedStyle(gAttachmentBucket); + let childStyle = getComputedStyle(gAttachmentBucket.firstChild); + let minHeight = + gAttachmentBucket.firstChild.getBoundingClientRect().height + + parseFloat(childStyle.marginBlockStart) + + parseFloat(childStyle.marginBlockEnd) + + parseFloat(bucketStyle.paddingBlockStart) + + parseFloat(bucketStyle.paddingBlockEnd) + + parseFloat(bucketStyle.borderBlockStartWidth) + + parseFloat(bucketStyle.borderBlockEndWidth); + gAttachmentBucket.style.minHeight = `${minHeight}px`; + } + } + + // Always show the attachment pane if we have any attachment, to prevent + // keeping the panel collapsed when the user interacts with the attachment + // button. + if (gAttachmentBucket.itemCount) { + toggleAttachmentPane("show"); + } + + return items; +} + +/** + * Returns a sorted-by-index, "non-live" array of attachment list items. + * + * @param aAscending {boolean}: true (default): sort return array ascending + * false : sort return array descending + * @param aSelectedOnly {boolean}: true: return array of selected items only. + * false (default): return array of all items. + * + * @returns {Array} an array of (all | selected) listItem elements in + * attachmentBucket listbox, "non-live" and sorted by their index + * in the list; [] if there are (no | no selected) attachments. + */ +function attachmentsGetSortedArray(aAscending = true, aSelectedOnly = false) { + let listItems; + + if (aSelectedOnly) { + // Selected attachments only. + if (!gAttachmentBucket.selectedCount) { + return []; + } + + // gAttachmentBucket.selectedItems is a "live" and "unordered" node list + // (items get added in the order they were added to the selection). But we + // want a stable ("non-live") array of selected items, sorted by their index + // in the list. + listItems = [...gAttachmentBucket.selectedItems]; + } else { + // All attachments. + if (!gAttachmentBucket.itemCount) { + return []; + } + + listItems = [...gAttachmentBucket.itemChildren]; + } + + if (aAscending) { + listItems.sort( + (a, b) => + gAttachmentBucket.getIndexOfItem(a) - + gAttachmentBucket.getIndexOfItem(b) + ); + } else { + // descending + listItems.sort( + (a, b) => + gAttachmentBucket.getIndexOfItem(b) - + gAttachmentBucket.getIndexOfItem(a) + ); + } + return listItems; +} + +/** + * Returns a sorted-by-index, "non-live" array of selected attachment list items. + * + * @param aAscending {boolean}: true (default): sort return array ascending + * false : sort return array descending + * @returns {Array} an array of selected listitem elements in attachmentBucket + * listbox, "non-live" and sorted by their index in the list; + * [] if no attachments selected + */ +function attachmentsSelectionGetSortedArray(aAscending = true) { + return attachmentsGetSortedArray(aAscending, true); +} + +/** + * Return true if the selected attachment items are a coherent block in the list, + * otherwise false. + * + * @param aListPosition (optional) - "top" : Return true only if the block is + * at the top of the list. + * "bottom": Return true only if the block is + * at the bottom of the list. + * @returns {boolean} true : The selected attachment items are a coherent block + * (at the list edge if/as specified by 'aListPosition'), + * or only 1 item selected. + * false: The selected attachment items are NOT a coherent block + * (at the list edge if/as specified by 'aListPosition'), + * or no attachments selected, or no attachments, + * or no attachmentBucket. + */ +function attachmentsSelectionIsBlock(aListPosition) { + if (!gAttachmentBucket.selectedCount) { + // No attachments selected, no attachments, or no attachmentBucket. + return false; + } + + let selItems = attachmentsSelectionGetSortedArray(); + let indexFirstSelAttachment = gAttachmentBucket.getIndexOfItem(selItems[0]); + let indexLastSelAttachment = gAttachmentBucket.getIndexOfItem( + selItems[gAttachmentBucket.selectedCount - 1] + ); + let isBlock = + indexFirstSelAttachment == + indexLastSelAttachment + 1 - gAttachmentBucket.selectedCount; + + switch (aListPosition) { + case "top": + // True if selection is a coherent block at the top of the list. + return indexFirstSelAttachment == 0 && isBlock; + case "bottom": + // True if selection is a coherent block at the bottom of the list. + return ( + indexLastSelAttachment == gAttachmentBucket.itemCount - 1 && isBlock + ); + default: + // True if selection is a coherent block. + return isBlock; + } +} + +function AttachPage() { + let result = { value: "http://" }; + if ( + Services.prompt.prompt( + window, + getComposeBundle().getString("attachPageDlogTitle"), + getComposeBundle().getString("attachPageDlogMessage"), + result, + null, + { value: 0 } + ) + ) { + if (result.value.length <= "http://".length) { + // Nothing filled, just show the dialog again. + AttachPage(); + return; + } + + let attachment = Cc[ + "@mozilla.org/messengercompose/attachment;1" + ].createInstance(Ci.nsIMsgAttachment); + attachment.url = result.value; + AddAttachments([attachment]); + } +} + +/** + * Check if the given attachment already exists in the attachment bucket. + * + * @param nsIMsgAttachment - the attachment to check + * @returns true if the attachment is already attached + */ +function DuplicateFileAlreadyAttached(attachment) { + for (let item of gAttachmentBucket.itemChildren) { + if (item.attachment && item.attachment.url) { + if (item.attachment.url == attachment.url) { + return true; + } + // Also check, if an attachment has been saved as a temporary file and its + // original url is a match. + if ( + item.attachment.contentLocation && + item.attachment.contentLocation == attachment.url + ) { + return true; + } + } + } + + return false; +} + +function Attachments2CompFields(compFields) { + // First, we need to clear all attachment in the compose fields. + compFields.removeAttachments(); + + for (let item of gAttachmentBucket.itemChildren) { + if (item.attachment) { + compFields.addAttachment(item.attachment); + } + } +} + +async function RemoveAllAttachments() { + // Ensure that attachment pane is shown before removing all attachments. + toggleAttachmentPane("show"); + + if (!gAttachmentBucket.itemCount) { + return; + } + + await RemoveAttachments(gAttachmentBucket.itemChildren); +} + +/** + * Show or hide the attachment pane after updating its header bar information + * (number and total file size of attachments) and tooltip. + * + * @param aShowBucket {Boolean} true: show the attachment pane + * false (or omitted): hide the attachment pane + */ +function UpdateAttachmentBucket(aShowBucket) { + updateAttachmentPane(aShowBucket ? "show" : "hide"); +} + +/** + * Update the header bar information (number and total file size of attachments) + * and tooltip of attachment pane, then (optionally) show or hide the pane. + * + * @param aShowPane {string} "show": show the attachment pane + * "hide": hide the attachment pane + * omitted: just update without changing pane visibility + */ +function updateAttachmentPane(aShowPane) { + let count = gAttachmentBucket.itemCount; + + document.l10n.setAttributes( + document.getElementById("attachmentBucketCount"), + "attachment-bucket-count-value", + { + count, + } + ); + + let attachmentsSize = 0; + for (let item of gAttachmentBucket.itemChildren) { + gAttachmentBucket.invalidateItem(item); + attachmentsSize += item.cloudHtmlFileSize + ? item.cloudHtmlFileSize + : item.attachment.size; + } + + document.getElementById("attachmentBucketSize").textContent = + count > 0 ? gMessenger.formatFileSize(attachmentsSize) : ""; + + document + .getElementById("composeContentBox") + .classList.toggle("attachment-area-hidden", !count); + + attachmentBucketUpdateTooltips(); + + // If aShowPane argument is omitted, it's just updating, so we're done. + if (aShowPane === undefined) { + return; + } + + // Otherwise, show or hide the panel per aShowPane argument. + toggleAttachmentPane(aShowPane); +} + +async function RemoveSelectedAttachment() { + if (!gAttachmentBucket.selectedCount) { + return; + } + + await RemoveAttachments(gAttachmentBucket.selectedItems); +} + +/** + * Removes the provided attachmentItems from the composer and deletes all + * associated cloud files. + * + * Note: Cloud file delete errors are not considered to be fatal errors. They do + * not prevent the attachments from being removed from the composer. Such + * errors are caught and logged to the console. + * + * @param {DOMNode[]} items - AttachmentItems to be removed + */ +async function RemoveAttachments(items) { + // Remember the current focus index so we can try to restore it when done. + let focusIndex = gAttachmentBucket.currentIndex; + + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + let removedAttachments = []; + + let promises = []; + for (let i = items.length - 1; i >= 0; i--) { + let item = items[i]; + + if (item.attachment.sendViaCloud && item.cloudFileAccount) { + if (item.uploading) { + let file = fileHandler.getFileFromURLSpec(item.attachment.url); + promises.push( + item.uploading + .cancelFileUpload(window, file) + .catch(ex => console.warn(ex.message)) + ); + } else { + promises.push( + item.cloudFileAccount + .deleteFile(window, item.cloudFileUpload.id) + .catch(ex => console.warn(ex.message)) + ); + } + } + + removedAttachments.push(item.attachment); + // Let's release the attachment object held by the node else it won't go + // away until the window is destroyed + item.attachment = null; + item.remove(); + } + + if (removedAttachments.length > 0) { + // Bug 1661507 workaround: Force update of selectedCount and selectedItem, + // both wrong after item removal, to avoid confusion for listening command + // controllers. + gAttachmentBucket.clearSelection(); + + AttachmentsChanged(); + dispatchAttachmentBucketEvent("attachments-removed", removedAttachments); + } + + // Collapse the attachment container if all the items have been deleted. + if (!gAttachmentBucket.itemCount) { + toggleAttachmentPane("hide"); + } else { + // Try to restore the original focused item or somewhere close by. + gAttachmentBucket.currentIndex = + focusIndex < gAttachmentBucket.itemCount + ? focusIndex + : gAttachmentBucket.itemCount - 1; + } + + await Promise.all(promises); +} + +async function RenameSelectedAttachment() { + if (gAttachmentBucket.selectedItems.length != 1) { + // Not one attachment selected. + return; + } + + let item = gAttachmentBucket.getSelectedItem(0); + let originalName = item.attachment.name; + let attachmentName = { value: originalName }; + if ( + Services.prompt.prompt( + window, + getComposeBundle().getString("renameAttachmentTitle"), + getComposeBundle().getString("renameAttachmentMessage"), + attachmentName, + null, + { value: 0 } + ) + ) { + if (attachmentName.value == "" || attachmentName.value == originalName) { + // Name was not filled nor changed, bail out. + return; + } + try { + await UpdateAttachment(item, { + name: attachmentName.value, + relatedCloudFileUpload: item.CloudFileUpload, + }); + } catch (ex) { + showLocalizedCloudFileAlert(ex); + } + } +} + +/* eslint-disable complexity */ +/** + * Move selected attachment(s) within the attachment list. + * + * @param {string} aDirection - The direction in which to move the attachments. + * "left" : Move attachments left in the list. + * "right" : Move attachments right in the list. + * "top" : Move attachments to the top of the list. + * "bottom" : Move attachments to the bottom of the list. + * "bundleUp" : Move attachments together (upwards). + * "bundleDown": Move attachments together (downwards). + * "toggleSort": Sort attachments alphabetically (toggle). + */ +function moveSelectedAttachments(aDirection) { + // Command controllers will bail out if no or all attachments are selected, + // or if block selections can't be moved, or if other direction-specific + // adverse circumstances prevent the intended movement. + if (!aDirection) { + return; + } + + // Ensure focus on gAttachmentBucket when we're coming from + // 'Reorder Attachments' panel. + gAttachmentBucket.focus(); + + // Get a sorted and "non-live" array of gAttachmentBucket.selectedItems. + let selItems = attachmentsSelectionGetSortedArray(); + + // In case of misspelled aDirection. + let visibleIndex = gAttachmentBucket.currentIndex; + // Keep track of the item we had focused originally. Deselect it though, + // since listbox gets confused if you move its focused item around. + let focusItem = gAttachmentBucket.currentItem; + gAttachmentBucket.currentItem = null; + let upwards; + let targetItem; + + switch (aDirection) { + case "left": + case "right": + // Move selected attachments upwards/downwards. + upwards = aDirection == "left"; + let blockItems = []; + + for (let item of selItems) { + // Handle adjacent selected items en block, via blockItems array. + blockItems.push(item); // Add current selItem to blockItems. + let nextItem = item.nextElementSibling; + if (!nextItem || !nextItem.selected) { + // If current selItem is the last blockItem, check out its adjacent + // item in the intended direction to see if there's room for moving. + // Note that the block might contain one or more items. + let checkItem = upwards + ? blockItems[0].previousElementSibling + : nextItem; + // If block-adjacent checkItem exists (and is not selected because + // then it would be part of the block), we can move the block to the + // right position. + if (checkItem) { + targetItem = upwards + ? // Upwards: Insert block items before checkItem, + // i.e. before previousElementSibling of block. + checkItem + : // Downwards: Insert block items *after* checkItem, + // i.e. *before* nextElementSibling.nextElementSibling of block, + // which works according to spec even if that's null. + checkItem.nextElementSibling; + // Move current blockItems. + for (let blockItem of blockItems) { + gAttachmentBucket.insertBefore(blockItem, targetItem); + } + } + // Else if checkItem doesn't exist, the block is already at the edge + // of the list, so we can't move it in the intended direction. + blockItems.length = 0; // Either way, we're done with the current block. + } + // Else if current selItem is NOT the end of the current block, proceed: + // Add next selItem to the block and see if that's the end of the block. + } // Next selItem. + + // Ensure helpful visibility of moved items (scroll into view if needed): + // If first item of selection is now at the top, first list item. + // Else if last item of selection is now at the bottom, last list item. + // Otherwise, let's see where we are going by ensuring visibility of the + // nearest unselected sibling of selection according to direction of move. + if (gAttachmentBucket.getIndexOfItem(selItems[0]) == 0) { + visibleIndex = 0; + } else if ( + gAttachmentBucket.getIndexOfItem(selItems[selItems.length - 1]) == + gAttachmentBucket.itemCount - 1 + ) { + visibleIndex = gAttachmentBucket.itemCount - 1; + } else if (upwards) { + visibleIndex = gAttachmentBucket.getIndexOfItem( + selItems[0].previousElementSibling + ); + } else { + visibleIndex = gAttachmentBucket.getIndexOfItem( + selItems[selItems.length - 1].nextElementSibling + ); + } + break; + + case "top": + case "bottom": + case "bundleUp": + case "bundleDown": + // Bundle selected attachments to top/bottom of the list or upwards/downwards. + + upwards = ["top", "bundleUp"].includes(aDirection); + // Downwards: Reverse order of selItems so we can use the same algorithm. + if (!upwards) { + selItems.reverse(); + } + + if (["top", "bottom"].includes(aDirection)) { + let listEdgeItem = gAttachmentBucket.getItemAtIndex( + upwards ? 0 : gAttachmentBucket.itemCount - 1 + ); + let selEdgeItem = selItems[0]; + if (selEdgeItem != listEdgeItem) { + // Top/Bottom: Move the first/last selected item to the edge of the list + // so that we always have an initial anchor target block in the right + // place, so we can use the same algorithm for top/bottom and + // inner bundling. + targetItem = upwards + ? // Upwards: Insert before first list item. + listEdgeItem + : // Downwards: Insert after last list item, i.e. + // *before* non-existing listEdgeItem.nextElementSibling, + // which is null. It works because it's a feature. + null; + gAttachmentBucket.insertBefore(selEdgeItem, targetItem); + } + } + // We now have a selected block (at least one item) at the target position. + // Let's find the end (inner edge) of that block and move only the + // remaining selected items to avoid unnecessary moves. + targetItem = null; + for (let item of selItems) { + if (targetItem) { + // We know where to move it, so move it! + gAttachmentBucket.insertBefore(item, targetItem); + if (!upwards) { + // Downwards: As selItems are reversed, and there's no insertAfter() + // method to insert *after* a stable target, we need to insert + // *before* the first item of the target block at target position, + // which is the current selItem which we've just moved onto the block. + targetItem = item; + } + } else { + // If there's no targetItem yet, find the inner edge of the target block. + let nextItem = upwards + ? item.nextElementSibling + : item.previousElementSibling; + if (!nextItem.selected) { + // If nextItem is not selected, current selItem is the inner edge of + // the initial anchor target block, so we can set targetItem. + targetItem = upwards + ? // Upwards: set stable targetItem. + nextItem + : // Downwards: set initial targetItem. + item; + } + // Else if nextItem is selected, it is still part of initial anchor + // target block, so just proceed to look for the edge of that block. + } + } // next selItem + + // Ensure visibility of first/last selected item after the move. + visibleIndex = gAttachmentBucket.getIndexOfItem(selItems[0]); + break; + + case "toggleSort": + // Sort the selected attachments alphabetically after moving them together. + // The command updater of cmd_sortAttachmentsToggle toggles the sorting + // direction based on the current sorting and block status of the selection. + + let toggleCmd = document.getElementById("cmd_sortAttachmentsToggle"); + let sortDirection = + toggleCmd.getAttribute("sortdirection") || "ascending"; + let sortItems; + let sortSelection; + + if (gAttachmentBucket.selectedCount > 1) { + // Sort selected attachments only. + sortSelection = true; + sortItems = selItems; + // Move selected attachments together before sorting as a block. + goDoCommand("cmd_moveAttachmentBundleUp"); + + // Find the end of the selected block to find our targetItem. + for (let item of selItems) { + let nextItem = item.nextElementSibling; + if (!nextItem || !nextItem.selected) { + // If there's no nextItem (block at list bottom), or nextItem is + // not selected, we've reached the end of the block. + // Set the block's nextElementSibling as targetItem and exit loop. + // Works by definition even if nextElementSibling aka nextItem is null. + targetItem = nextItem; + break; + } + // else if (nextItem && nextItem.selected), nextItem is still part of + // the block, so proceed with checking its nextElementSibling. + } // next selItem + } else { + // Sort all attachments. + sortSelection = false; + sortItems = attachmentsGetSortedArray(); + targetItem = null; // Insert at the end of the list. + } + // Now let's sort our sortItems according to sortDirection. + if (sortDirection == "ascending") { + sortItems.sort((a, b) => + a.attachment.name.localeCompare(b.attachment.name) + ); + } else { + // "descending" + sortItems.sort((a, b) => + b.attachment.name.localeCompare(a.attachment.name) + ); + } + + // Insert sortItems in new order before the nextElementSibling of the block. + for (let item of sortItems) { + gAttachmentBucket.insertBefore(item, targetItem); + } + + if (sortSelection) { + // After sorting selection: Ensure visibility of first selected item. + visibleIndex = gAttachmentBucket.getIndexOfItem(selItems[0]); + } else { + // After sorting all items: Ensure visibility of selected item, + // otherwise first list item. + visibleIndex = + selItems.length == 1 ? gAttachmentBucket.selectedIndex : 0; + } + break; + } // end switch (aDirection) + + // Restore original focus. + gAttachmentBucket.currentItem = focusItem; + // Ensure smart visibility of a relevant item according to direction. + gAttachmentBucket.ensureIndexIsVisible(visibleIndex); + + // Moving selected items around does not trigger auto-updating of our command + // handlers, so we must do it now as the position of selected items has changed. + updateReorderAttachmentsItems(); +} +/* eslint-enable complexity */ + +/** + * Toggle attachment pane view state: show or hide it. + * If aAction parameter is omitted, toggle current view state. + * + * @param {string} [aAction = "toggle"] - "show": show attachment pane + * "hide": hide attachment pane + * "toggle": toggle attachment pane + */ +function toggleAttachmentPane(aAction = "toggle") { + let attachmentArea = document.getElementById("attachmentArea"); + + if (aAction == "toggle") { + // Interrupt if we don't have any attachment as we don't want nor need to + // show an empty container. + if (!gAttachmentBucket.itemCount) { + return; + } + + if (attachmentArea.open && document.activeElement != gAttachmentBucket) { + // Interrupt and move the focus to the attachment pane if it's already + // visible but not currently focused. + moveFocusToAttachmentPane(); + return; + } + + // Toggle attachment pane. + attachmentArea.open = !attachmentArea.open; + } else { + attachmentArea.open = aAction != "hide"; + } +} + +/** + * Update the #attachmentArea according to its open state. + */ +function attachmentAreaOnToggle() { + let attachmentArea = document.getElementById("attachmentArea"); + let bucketHasFocus = document.activeElement == gAttachmentBucket; + if (attachmentArea.open && !bucketHasFocus) { + moveFocusToAttachmentPane(); + } else if (!attachmentArea.open && bucketHasFocus) { + // Move the focus to the message body only if the bucket was focused. + focusMsgBody(); + } + + // Make the splitter non-interactive whilst the bucket is hidden. + document + .getElementById("composeContentBox") + .classList.toggle("attachment-bucket-closed", !attachmentArea.open); + + // Update the checkmark on menuitems hooked up with cmd_toggleAttachmentPane. + // Menuitem does not have .checked property nor .toggleAttribute(), sigh. + for (let menuitem of document.querySelectorAll( + 'menuitem[command="cmd_toggleAttachmentPane"]' + )) { + if (attachmentArea.open) { + menuitem.setAttribute("checked", "true"); + continue; + } + menuitem.removeAttribute("checked"); + } + + // Update the title based on the collapsed status of the bucket. + document.l10n.setAttributes( + attachmentArea.querySelector("summary"), + attachmentArea.open ? "attachment-area-hide" : "attachment-area-show" + ); +} + +/** + * Ensure the focus is properly moved to the Attachment Bucket, and to the first + * available item if present. + */ +function moveFocusToAttachmentPane() { + gAttachmentBucket.focus(); + + if (gAttachmentBucket.currentItem) { + gAttachmentBucket.ensureElementIsVisible(gAttachmentBucket.currentItem); + } +} + +function showReorderAttachmentsPanel() { + // Ensure attachment pane visibility as it might be collapsed. + toggleAttachmentPane("show"); + showPopupById( + "reorderAttachmentsPanel", + "attachmentBucket", + "after_start", + 15, + 0 + ); + // After the panel is shown, focus attachmentBucket so that keyboard + // operation for selecting and moving attachment items works; the panel + // helpfully presents the keyboard shortcuts for moving things around. + // Bucket focus is also required because the panel will only close with ESC + // or attachmentBucketOnBlur(), and that's because we're using noautohide as + // event.preventDefault() of onpopuphiding event fails when the panel + // is auto-hiding, but we don't want panel to hide when focus goes to bucket. + gAttachmentBucket.focus(); +} + +/** + * Returns a string representing the current sort order of selected attachment + * items by their names. We don't check if selected items form a coherent block + * or not; use attachmentsSelectionIsBlock() to check on that. + * + * @returns {string} "ascending" : Sort order is ascending. + * "descending": Sort order is descending. + * "equivalent": The names of all selected items are equivalent. + * "" : There's no sort order, or only 1 item selected, + * or no items selected, or no attachments, + * or no attachmentBucket. + */ +function attachmentsSelectionGetSortOrder() { + return attachmentsGetSortOrder(true); +} + +/** + * Returns a string representing the current sort order of attachment items + * by their names. + * + * @param aSelectedOnly {boolean}: true: return sort order of selected items only. + * false (default): return sort order of all items. + * + * @returns {string} "ascending" : Sort order is ascending. + * "descending": Sort order is descending. + * "equivalent": The names of the items are equivalent. + * "" : There's no sort order, or no attachments, + * or no attachmentBucket; or (with aSelectedOnly), + * only 1 item selected, or no items selected. + */ +function attachmentsGetSortOrder(aSelectedOnly = false) { + let listItems; + if (aSelectedOnly) { + if (gAttachmentBucket.selectedCount <= 1) { + return ""; + } + + listItems = attachmentsSelectionGetSortedArray(); + } else { + // aSelectedOnly == false + if (!gAttachmentBucket.itemCount) { + return ""; + } + + listItems = attachmentsGetSortedArray(); + } + + // We're comparing each item to the next item, so exclude the last item. + let listItems1 = listItems.slice(0, -1); + let someAscending; + let someDescending; + + // Check if some adjacent items are sorted ascending. + someAscending = listItems1.some( + (item, index) => + item.attachment.name.localeCompare(listItems[index + 1].attachment.name) < + 0 + ); + + // Check if some adjacent items are sorted descending. + someDescending = listItems1.some( + (item, index) => + item.attachment.name.localeCompare(listItems[index + 1].attachment.name) > + 0 + ); + + // Unsorted (but not all equivalent in sort order) + if (someAscending && someDescending) { + return ""; + } + + if (someAscending && !someDescending) { + return "ascending"; + } + + if (someDescending && !someAscending) { + return "descending"; + } + + // No ascending pairs, no descending pairs, so all equivalent in sort order. + // if (!someAscending && !someDescending) + return "equivalent"; +} + +function reorderAttachmentsPanelOnPopupShowing() { + let panel = document.getElementById("reorderAttachmentsPanel"); + let buttonsNodeList = panel.querySelectorAll(".panelButton"); + let buttons = [...buttonsNodeList]; // convert NodeList to Array + // Let's add some pretty keyboard shortcuts to the buttons. + buttons.forEach(btn => { + if (btn.hasAttribute("key")) { + btn.setAttribute("prettykey", getPrettyKey(btn.getAttribute("key"))); + } + }); + // Focus attachment bucket to activate attachmentBucketController, which is + // required for updating the reorder commands. + gAttachmentBucket.focus(); + // We're updating commands before showing the panel so that button states + // don't change after the panel is shown, and also because focus is still + // in attachment bucket right now, which is required for updating them. + updateReorderAttachmentsItems(); +} + +function attachmentHeaderContextOnPopupShowing() { + let initiallyShowItem = document.getElementById( + "attachmentHeaderContext_initiallyShowItem" + ); + + initiallyShowItem.setAttribute( + "checked", + Services.prefs.getBoolPref("mail.compose.show_attachment_pane") + ); +} + +function toggleInitiallyShowAttachmentPane(aMenuItem) { + Services.prefs.setBoolPref( + "mail.compose.show_attachment_pane", + aMenuItem.getAttribute("checked") + ); +} + +/** + * Handle blur event on attachment pane and control visibility of + * reorderAttachmentsPanel. + */ +function attachmentBucketOnBlur() { + let reorderAttachmentsPanel = document.getElementById( + "reorderAttachmentsPanel" + ); + // If attachment pane has really lost focus, and if reorderAttachmentsPanel is + // not currently in the process of showing up, hide reorderAttachmentsPanel. + // Otherwise, keep attachments selected and the reorderAttachmentsPanel open + // when reordering and after renaming via dialog. + if ( + document.activeElement.id != "attachmentBucket" && + reorderAttachmentsPanel.state != "showing" + ) { + reorderAttachmentsPanel.hidePopup(); + } +} + +/** + * Handle the keypress on the attachment bucket. + * + * @param {Event} event - The keypress DOM Event. + */ +function attachmentBucketOnKeyPress(event) { + // Interrupt if the Alt modifier is pressed, meaning the user is reordering + // the list of attachments. + if (event.altKey) { + return; + } + + switch (event.key) { + case "Escape": + let reorderAttachmentsPanel = document.getElementById( + "reorderAttachmentsPanel" + ); + + // Close the reorderAttachmentsPanel if open and interrupt. + if (reorderAttachmentsPanel.state == "open") { + reorderAttachmentsPanel.hidePopup(); + return; + } + + if (gAttachmentBucket.itemCount) { + // Deselect selected items in a full bucket if any. + if (gAttachmentBucket.selectedCount) { + gAttachmentBucket.clearSelection(); + return; + } + + // Move the focus to the message body. + focusMsgBody(); + return; + } + + // Close an empty bucket. + toggleAttachmentPane("hide"); + break; + + case "Enter": + // Enter on empty bucket to add file attachments, convenience + // keyboard equivalent of single-click on bucket whitespace. + if (!gAttachmentBucket.itemCount) { + goDoCommand("cmd_attachFile"); + } + break; + + case "ArrowLeft": + gAttachmentBucket.moveByOffset(-1, !event.ctrlKey, event.shiftKey); + event.preventDefault(); + break; + + case "ArrowRight": + gAttachmentBucket.moveByOffset(1, !event.ctrlKey, event.shiftKey); + event.preventDefault(); + break; + + case "ArrowDown": + gAttachmentBucket.moveByOffset( + gAttachmentBucket._itemsPerRow(), + !event.ctrlKey, + event.shiftKey + ); + event.preventDefault(); + break; + + case "ArrowUp": + gAttachmentBucket.moveByOffset( + -gAttachmentBucket._itemsPerRow(), + !event.ctrlKey, + event.shiftKey + ); + + event.preventDefault(); + break; + } +} + +function attachmentBucketOnClick(aEvent) { + // Handle click on attachment pane whitespace normally clear selection. + // If there are no attachments in the bucket, show 'Attach File(s)' dialog. + if ( + aEvent.button == 0 && + aEvent.target.getAttribute("is") == "attachment-list" && + !aEvent.target.firstElementChild + ) { + goDoCommand("cmd_attachFile"); + } +} + +function attachmentBucketOnSelect() { + attachmentBucketUpdateTooltips(); + updateAttachmentItems(); +} + +function attachmentBucketUpdateTooltips() { + // Attachment pane whitespace tooltip + if (gAttachmentBucket.selectedCount) { + gAttachmentBucket.tooltipText = getComposeBundle().getString( + "attachmentBucketClearSelectionTooltip" + ); + } else { + gAttachmentBucket.tooltipText = getComposeBundle().getString( + "attachmentBucketAttachFilesTooltip" + ); + } +} + +function OpenSelectedAttachment() { + if (gAttachmentBucket.selectedItems.length != 1) { + return; + } + let attachment = gAttachmentBucket.getSelectedItem(0).attachment; + let attachmentUrl = attachment.url; + + let messagePrefix = /^mailbox-message:|^imap-message:|^news-message:/i; + if (messagePrefix.test(attachmentUrl)) { + // we must be dealing with a forwarded attachment, treat this special + let msgHdr = + MailServices.messageServiceFromURI(attachmentUrl).messageURIToMsgHdr( + attachmentUrl + ); + if (msgHdr) { + MailUtils.openMessageInNewWindow(msgHdr); + } + return; + } + if ( + attachment.contentType == "application/pdf" || + /\.pdf$/i.test(attachment.name) + ) { + // @see msgHdrView.js which has simililar opening functionality + let handlerInfo = gMIMEService.getFromTypeAndExtension( + attachment.contentType, + attachment.name.split(".").pop() + ); + // Only open a new tab for pdfs if we are handling them internally. + if ( + !handlerInfo.alwaysAskBeforeHandling && + handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally + ) { + // Add the content type to avoid a "how do you want to open this?" + // dialog. The type may already be there, but that doesn't matter. + let url = attachment.url; + if (!url.includes("type=")) { + url += url.includes("?") ? "&" : "?"; + url += "type=application/pdf"; + } + let tabmail = Services.wm + .getMostRecentWindow("mail:3pane") + ?.document.getElementById("tabmail"); + if (tabmail) { + tabmail.openTab("contentTab", { + url, + background: false, + linkHandler: "single-page", + }); + tabmail.ownerGlobal.focus(); + return; + } + // If no tabmail, open PDF same as other attachments. + } + } + let uri = Services.io.newURI(attachmentUrl); + let channel = Services.io.newChannelFromURI( + uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + let uriLoader = Cc["@mozilla.org/uriloader;1"].getService(Ci.nsIURILoader); + uriLoader.openURI(channel, true, new nsAttachmentOpener()); +} + +function nsAttachmentOpener() {} + +nsAttachmentOpener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIURIContentListener", + "nsIInterfaceRequestor", + ]), + + doContent(contentType, isContentPreferred, request, contentHandler) { + // If we came here to display an attached message, make sure we provide a type. + if (/[?&]part=/i.test(request.URI.query)) { + let newQuery = request.URI.query + "&type=message/rfc822"; + request.URI = request.URI.mutate().setQuery(newQuery).finalize(); + } + let newHandler = Cc[ + "@mozilla.org/uriloader/content-handler;1?type=application/x-message-display" + ].createInstance(Ci.nsIContentHandler); + newHandler.handleContent("application/x-message-display", this, request); + return true; + }, + + isPreferred(contentType, desiredContentType) { + if (contentType == "message/rfc822") { + return true; + } + return false; + }, + + canHandleContent(contentType, isContentPreferred, desiredContentType) { + return false; + }, + + getInterface(iid) { + if (iid.equals(Ci.nsIDOMWindow)) { + return window; + } + if (iid.equals(Ci.nsIDocShell)) { + return window.docShell; + } + return this.QueryInterface(iid); + }, + + loadCookie: null, + parentContentListener: null, +}; + +/** + * Determine the sending format depending on the selected format, or the content + * of the message body. + * + * @returns {nsIMsgCompSendFormat} The determined send format: either PlainText, + * HTML or Both (never Auto or Unset). + */ +function determineSendFormat() { + if (!gMsgCompose.composeHTML) { + return Ci.nsIMsgCompSendFormat.PlainText; + } + + let sendFormat = gMsgCompose.compFields.deliveryFormat; + if (sendFormat != Ci.nsIMsgCompSendFormat.Auto) { + return sendFormat; + } + + // Auto downgrade if safe to do so. + let convertible; + try { + convertible = gMsgCompose.bodyConvertible(); + } catch (ex) { + return Ci.nsIMsgCompSendFormat.Both; + } + return convertible == Ci.nsIMsgCompConvertible.Plain + ? Ci.nsIMsgCompSendFormat.PlainText + : Ci.nsIMsgCompSendFormat.Both; +} + +/** + * Expands mailinglists found in the recipient fields. + */ +function expandRecipients() { + gMsgCompose.expandMailingLists(); +} + +/** + * Hides addressing options (To, CC, Bcc, Newsgroup, Followup-To, etc.) + * that are not relevant for the account type used for sending. + * + * @param {string} accountKey - Key of the account that is currently selected + * as the sending account. + * @param {string} prevKey - Key of the account that was previously selected + * as the sending account. + */ +function hideIrrelevantAddressingOptions(accountKey, prevKey) { + let showNews = false; + for (let account of MailServices.accounts.accounts) { + if (account.incomingServer.type == "nntp") { + showNews = true; + } + } + // If there is no News (NNTP) account existing then + // hide the Newsgroup and Followup-To recipient type menuitems. + for (let item of document.querySelectorAll(".news-show-row-menuitem")) { + showAddressRowMenuItemSetVisibility(item, showNews); + } + + let account = MailServices.accounts.getAccount(accountKey); + let accountType = account.incomingServer.type; + + // If the new account is a News (NNTP) account. + if (accountType == "nntp") { + updateUIforNNTPAccount(); + return; + } + + // If the new account is a Mail account and a previous account was selected. + if (accountType != "nntp" && prevKey != "") { + updateUIforMailAccount(); + } +} + +function LoadIdentity(startup) { + let identityElement = document.getElementById("msgIdentity"); + let prevIdentity = gCurrentIdentity; + + let idKey = null; + let accountKey = null; + let prevKey = getCurrentAccountKey(); + if (identityElement.selectedItem) { + // Set the identity key value on the menu list. + idKey = identityElement.selectedItem.getAttribute("identitykey"); + identityElement.setAttribute("identitykey", idKey); + gCurrentIdentity = MailServices.accounts.getIdentity(idKey); + + // Set the account key value on the menu list. + accountKey = identityElement.selectedItem.getAttribute("accountkey"); + identityElement.setAttribute("accountkey", accountKey); + + // Update the addressing options only if a new account was selected. + if (prevKey != getCurrentAccountKey()) { + hideIrrelevantAddressingOptions(accountKey, prevKey); + } + } + for (let input of document.querySelectorAll(".mail-input,.news-input")) { + let params = JSON.parse(input.searchParam); + params.idKey = idKey; + params.accountKey = accountKey; + input.searchParam = JSON.stringify(params); + } + + if (startup) { + // During compose startup, bail out here. + return; + } + + // Since switching the signature loses the caret position, we record it + // and restore it later. + let editor = GetCurrentEditor(); + let selection = editor.selection; + let range = selection.getRangeAt(0); + let start = range.startOffset; + let startNode = range.startContainer; + + editor.enableUndo(false); + + // Handle non-startup changing of identity. + if (prevIdentity && idKey != prevIdentity.key) { + let changedRecipients = false; + let prevReplyTo = prevIdentity.replyTo; + let prevCc = ""; + let prevBcc = ""; + let prevReceipt = prevIdentity.requestReturnReceipt; + let prevDSN = prevIdentity.DSN; + let prevAttachVCard = prevIdentity.attachVCard; + + if (prevIdentity.doCc && prevIdentity.doCcList) { + prevCc += prevIdentity.doCcList; + } + + if (prevIdentity.doBcc && prevIdentity.doBccList) { + prevBcc += prevIdentity.doBccList; + } + + let newReplyTo = gCurrentIdentity.replyTo; + let newCc = ""; + let newBcc = ""; + let newReceipt = gCurrentIdentity.requestReturnReceipt; + let newDSN = gCurrentIdentity.DSN; + let newAttachVCard = gCurrentIdentity.attachVCard; + + if (gCurrentIdentity.doCc && gCurrentIdentity.doCcList) { + newCc += gCurrentIdentity.doCcList; + } + + if (gCurrentIdentity.doBcc && gCurrentIdentity.doBccList) { + newBcc += gCurrentIdentity.doBccList; + } + + let msgCompFields = gMsgCompose.compFields; + // Update recipients in msgCompFields to match pills currently in the UI. + Recipients2CompFields(msgCompFields); + + if ( + !gReceiptOptionChanged && + prevReceipt == msgCompFields.returnReceipt && + prevReceipt != newReceipt + ) { + msgCompFields.returnReceipt = newReceipt; + ToggleReturnReceipt(msgCompFields.returnReceipt); + } + + if ( + !gDSNOptionChanged && + prevDSN == msgCompFields.DSN && + prevDSN != newDSN + ) { + msgCompFields.DSN = newDSN; + document + .getElementById("dsnMenu") + .setAttribute("checked", msgCompFields.DSN); + } + + if ( + !gAttachVCardOptionChanged && + prevAttachVCard == msgCompFields.attachVCard && + prevAttachVCard != newAttachVCard + ) { + msgCompFields.attachVCard = newAttachVCard; + document + .getElementById("cmd_attachVCard") + .setAttribute("checked", msgCompFields.attachVCard); + } + + if (newReplyTo != prevReplyTo) { + if (prevReplyTo != "") { + awRemoveRecipients(msgCompFields, "addr_reply", prevReplyTo); + } + if (newReplyTo != "") { + awAddRecipients(msgCompFields, "addr_reply", newReplyTo); + } + } + + let toCcAddrs = new Set([ + ...msgCompFields.splitRecipients(msgCompFields.to, true), + ...msgCompFields.splitRecipients(msgCompFields.cc, true), + ]); + + if (newCc != prevCc) { + if (prevCc) { + awRemoveRecipients(msgCompFields, "addr_cc", prevCc); + } + if (newCc) { + // Add only Auto-Cc recipients whose email is not already in To or CC. + newCc = msgCompFields + .splitRecipients(newCc, false) + .filter( + x => !toCcAddrs.has(...msgCompFields.splitRecipients(x, true)) + ) + .join(", "); + awAddRecipients(msgCompFields, "addr_cc", newCc); + } + changedRecipients = true; + } + + if (newBcc != prevBcc) { + let toCcBccAddrs = new Set([ + ...toCcAddrs, + ...msgCompFields.splitRecipients(newCc, true), + ...msgCompFields.splitRecipients(msgCompFields.bcc, true), + ]); + + if (prevBcc) { + awRemoveRecipients(msgCompFields, "addr_bcc", prevBcc); + } + if (newBcc) { + // Add only Auto-Bcc recipients whose email is not already in To, Cc, + // Bcc, or added as Auto-CC from newCc declared above. + newBcc = msgCompFields + .splitRecipients(newBcc, false) + .filter( + x => !toCcBccAddrs.has(...msgCompFields.splitRecipients(x, true)) + ) + .join(", "); + awAddRecipients(msgCompFields, "addr_bcc", newBcc); + } + changedRecipients = true; + } + + // Handle showing/hiding of empty CC/BCC row after changing identity. + // Whenever "Cc/Bcc these email addresses" aka mail.identity.id#.doCc/doBcc + // is checked in Account Settings, show the address row, even if empty. + // This is a feature especially for ux-efficiency of enterprise workflows. + let addressRowCc = document.getElementById("addressRowCc"); + if (gCurrentIdentity.doCc) { + // Per identity's doCc pref, show CC row, even if empty. + showAndFocusAddressRow("addressRowCc"); + } else if ( + prevIdentity.doCc && + !addressRowCc.querySelector("mail-address-pill") + ) { + // Current identity doesn't need CC row shown, but previous identity did. + // Hide CC row if it's empty. + addressRowSetVisibility(addressRowCc, false); + } + + let addressRowBcc = document.getElementById("addressRowBcc"); + if (gCurrentIdentity.doBcc) { + // Per identity's doBcc pref, show BCC row, even if empty. + showAndFocusAddressRow("addressRowBcc"); + } else if ( + prevIdentity.doBcc && + !addressRowBcc.querySelector("mail-address-pill") + ) { + // Current identity doesn't need BCC row shown, but previous identity did. + // Hide BCC row if it's empty. + addressRowSetVisibility(addressRowBcc, false); + } + + // Trigger async checking and updating of encryption UI. + adjustEncryptAfterIdentityChange(prevIdentity); + + try { + gMsgCompose.identity = gCurrentIdentity; + } catch (ex) { + dump("### Cannot change the identity: " + ex + "\n"); + } + + window.dispatchEvent(new CustomEvent("compose-from-changed")); + + gComposeNotificationBar.clearIdentityWarning(); + + // Trigger this method only if the Cc or Bcc recipients changed from the + // previous identity. + if (changedRecipients) { + onRecipientsChanged(true); + } + } + + // Only do this if we aren't starting up... + // It gets done as part of startup already. + addRecipientsToIgnoreList(gCurrentIdentity.fullAddress); + + // If the From field is editable, reset the address from the identity. + if (identityElement.editable) { + identityElement.value = identityElement.selectedItem.value; + identityElement.placeholder = getComposeBundle().getFormattedString( + "msgIdentityPlaceholder", + [identityElement.selectedItem.value] + ); + } + + editor.enableUndo(true); + editor.resetModificationCount(); + selection.collapse(startNode, start); + + // Try to focus the first available address row. If there are none, focus the + // Subject which is always available. + for (let row of document.querySelectorAll(".address-row")) { + if (focusAddressRowInput(row)) { + return; + } + } + focusSubjectInput(); +} + +function MakeFromFieldEditable(ignoreWarning) { + let bundle = getComposeBundle(); + if ( + !ignoreWarning && + !Services.prefs.getBoolPref("mail.compose.warned_about_customize_from") + ) { + var check = { value: false }; + if ( + Services.prompt.confirmEx( + window, + bundle.getString("customizeFromAddressTitle"), + bundle.getString("customizeFromAddressWarning"), + Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_OK + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL + + Services.prompt.BUTTON_POS_1_DEFAULT, + null, + null, + null, + bundle.getString("customizeFromAddressIgnore"), + check + ) != 0 + ) { + return; + } + Services.prefs.setBoolPref( + "mail.compose.warned_about_customize_from", + check.value + ); + } + + let customizeMenuitem = document.getElementById("cmd_customizeFromAddress"); + customizeMenuitem.setAttribute("disabled", "true"); + let identityElement = document.getElementById("msgIdentity"); + let identityElementWidth = `${ + identityElement.getBoundingClientRect().width + }px`; + identityElement.style.width = identityElementWidth; + identityElement.removeAttribute("type"); + identityElement.setAttribute("editable", "true"); + identityElement.focus(); + identityElement.value = identityElement.selectedItem.value; + identityElement.select(); + identityElement.placeholder = bundle.getFormattedString( + "msgIdentityPlaceholder", + [identityElement.selectedItem.value] + ); +} + +/** + * Set up autocomplete search parameters for address inputs of inbuilt headers. + * + * @param {Element} input - The address input of an inbuilt header field. + */ +function setupAutocompleteInput(input) { + let params = JSON.parse(input.getAttribute("autocompletesearchparam")); + params.type = input.closest(".address-row").dataset.recipienttype; + input.setAttribute("autocompletesearchparam", JSON.stringify(params)); + + // This method overrides the autocomplete binding's openPopup (essentially + // duplicating the logic from the autocomplete popup binding's + // openAutocompletePopup method), modifying it so that the popup is aligned + // and sized based on the parentNode of the input field. + input.openPopup = () => { + if (input.focused) { + input.popup.openAutocompletePopup( + input.nsIAutocompleteInput, + input.closest(".address-container") + ); + } + }; +} + +/** + * Handle the keypress event of the From field. + * + * @param {Event} event - A DOM keypress event on #msgIdentity. + */ +function fromKeyPress(event) { + if (event.key == "Enter") { + // Move the focus to the first available address input. + document + .querySelector( + "#recipientsContainer .address-row:not(.hidden) .address-row-input" + ) + .focus(); + } +} + +/** + * Handle the keypress event of the subject input. + * + * @param {Event} event - A DOM keypress event on #msgSubject. + */ +function subjectKeyPress(event) { + if (event.key == "Delete" && event.repeat && gPreventRowDeletionKeysRepeat) { + // Prevent repeated Delete keypress event if the flag is set. + event.preventDefault(); + return; + } + // Enable repeated deletion if any other key is pressed, or if the Delete + // keypress event is not repeated, or if the flag is already false. + gPreventRowDeletionKeysRepeat = false; + + // Move the focus to the body only if the Enter key is pressed without any + // modifier, as that would mean the user wants to send the message. + if (event.key == "Enter" && !event.ctrlKey && !event.metaKey) { + focusMsgBody(); + } +} + +/** + * Handle the input event of the subject input element. + * + * @param {Event} event - A DOM input event on #msgSubject. + */ +function msgSubjectOnInput(event) { + gSubjectChanged = true; + gContentChanged = true; + SetComposeWindowTitle(); +} + +// Content types supported in the envelopeDragObserver. +const DROP_FLAVORS = [ + "application/x-moz-file", + "text/x-moz-address", + "text/x-moz-message", + "text/x-moz-url", + "text/uri-list", +]; + +// We can drag and drop addresses, files, messages and urls into the compose +// envelope. +var envelopeDragObserver = { + /** + * Adjust the drop target when dragging from the attachment bucket onto itself + * by picking the nearest possible insertion point (generally, between two + * list items). + * + * @param {Event} event - The drag-and-drop event being performed. + * @returns {attachmentitem|string} - the adjusted drop target: + * - an attachmentitem node for inserting *before* + * - "none" if this isn't a valid insertion point + * - "afterLastItem" for appending at the bottom of the list. + */ + _adjustDropTarget(event) { + let target = event.target; + if (target == gAttachmentBucket) { + // Dragging or dropping at top/bottom border of the listbox + if ( + (event.screenY - target.screenY) / + target.getBoundingClientRect().height < + 0.5 + ) { + target = gAttachmentBucket.firstElementChild; + } else { + target = gAttachmentBucket.lastElementChild; + } + // We'll check below if this is a valid target. + } else if (target.id == "attachmentBucketCount") { + // Dragging or dropping at top border of the listbox. + // Allow bottom half of attachment list header as extended drop target + // for top of list, because otherwise it would be too small. + if ( + (event.screenY - target.screenY) / + target.getBoundingClientRect().height >= + 0.5 + ) { + target = gAttachmentBucket.firstElementChild; + // We'll check below if this is a valid target. + } else { + // Top half of attachment list header: sorry, can't drop here. + return "none"; + } + } + + // Target is an attachmentitem. + if (target.matches("richlistitem.attachmentItem")) { + // If we're dragging/dropping in bottom half of attachmentitem, + // adjust target to target.nextElementSibling (to show dropmarker above that). + if ( + (event.screenY - target.screenY) / + target.getBoundingClientRect().height >= + 0.5 + ) { + target = target.nextElementSibling; + + // If there's no target.nextElementSibling, we're dragging/dropping + // to the bottom of the list. + if (!target) { + // We can't move a bottom block selection to the bottom. + if (attachmentsSelectionIsBlock("bottom")) { + return "none"; + } + + // Not a bottom block selection: Target is *after* the last item. + return "afterLastItem"; + } + } + // Check if the adjusted target attachmentitem is a valid target. + let isBlock = attachmentsSelectionIsBlock(); + let prevItem = target.previousElementSibling; + // If target is first list item, there's no previous sibling; + // treat like unselected previous sibling. + let prevSelected = prevItem ? prevItem.selected : false; + if ( + (target.selected && (isBlock || prevSelected)) || + // target at end of block selection + (isBlock && prevSelected) + ) { + // We can't move a block selection before/after itself, + // or any selection onto itself, so trigger dropeffect "none". + return "none"; + } + return target; + } + + return "none"; + }, + + _showDropMarker(targetItem) { + // Hide old drop marker. + this._hideDropMarker(); + + if (targetItem == "afterLastItem") { + targetItem = gAttachmentBucket.lastElementChild; + targetItem.setAttribute("dropOn", "after"); + } else { + targetItem.setAttribute("dropOn", "before"); + } + }, + + _hideDropMarker() { + gAttachmentBucket + .querySelector(".attachmentItem[dropOn]") + ?.removeAttribute("dropOn"); + }, + + /** + * Loop through all the valid data type flavors and return a list of valid + * attachments to handle the various drag&drop actions. + * + * @param {Event} event - The drag-and-drop event being performed. + * @param {boolean} isDropping - If the action was performed from the onDrop + * method and it needs to handle pills creation. + * + * @returns {nsIMsgAttachment[]} - The array of valid attachments. + */ + getValidAttachments(event, isDropping) { + let attachments = []; + let dt = event.dataTransfer; + let dataList = []; + + // Extract all the flavors matching the data type of the dragged elements. + for (let i = 0; i < dt.mozItemCount; i++) { + let types = Array.from(dt.mozTypesAt(i)); + for (let flavor of DROP_FLAVORS) { + if (types.includes(flavor)) { + let data = dt.mozGetDataAt(flavor, i); + if (data) { + dataList.push({ data, flavor }); + break; + } + } + } + } + + // Check if we have any valid attachment in the dragged data. + for (let { data, flavor } of dataList) { + gIsValidInline = false; + let isValidAttachment = false; + let prettyName; + let size; + let contentType; + let msgUri; + let cloudFileInfo; + + // We could be dropping an attachment of various flavors OR an address; + // check and do the right thing. + switch (flavor) { + // Process attachments. + case "application/x-moz-file": + if (data instanceof Ci.nsIFile) { + size = data.fileSize; + } + try { + data = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler) + .getURLSpecFromActualFile(data); + isValidAttachment = true; + } catch (e) { + console.error( + "Couldn't process the dragged file " + data.leafName + ":" + e + ); + } + break; + + case "text/x-moz-message": + isValidAttachment = true; + let msgHdr = + MailServices.messageServiceFromURI(data).messageURIToMsgHdr(data); + prettyName = msgHdr.mime2DecodedSubject; + if (Services.prefs.getBoolPref("mail.forward_add_extension")) { + prettyName += ".eml"; + } + + size = msgHdr.messageSize; + contentType = "message/rfc822"; + break; + + // Data type representing: + // - URL strings dragged from a URL bar (Allow both attach and append). + // NOTE: This only works for macOS and Windows. + // - Attachments dragged from another message (Only attach). + // - Images dragged from the body of another message (Only append). + case "text/uri-list": + case "text/x-moz-url": + let pieces = data.split("\n"); + data = pieces[0]; + if (pieces.length > 1) { + prettyName = pieces[1]; + } + if (pieces.length > 2) { + size = parseInt(pieces[2]); + } + if (pieces.length > 3) { + contentType = pieces[3]; + } + if (pieces.length > 4) { + msgUri = pieces[4]; + } + if (pieces.length > 6) { + cloudFileInfo = { + cloudFileAccountKey: pieces[5], + cloudPartHeaderData: pieces[6], + }; + } + + // Show the attachment overlay only if the user is not dragging an + // image form another message, since we can't get the correct file + // name, nor we can properly handle the append inline outside the + // editor drop event. + isValidAttachment = !event.dataTransfer.types.includes( + "application/x-moz-nativeimage" + ); + // Show the append inline overlay only if this is not a file that was + // dragged from the attachment bucket of another message. + gIsValidInline = !event.dataTransfer.types.includes( + "application/x-moz-file-promise" + ); + break; + + // Process address: Drop it into recipient field. + case "text/x-moz-address": + // Process the drop only if the message body wasn't the target and we + // called this method from the onDrop() method. + if (event.target.baseURI != "about:blank?compose" && isDropping) { + DropRecipient(event.target, data); + // Prevent the default behaviour which drops the address text into + // the widget. + event.preventDefault(); + } + break; + } + + // Create the attachment and add it to attachments array. + if (isValidAttachment) { + let attachment = Cc[ + "@mozilla.org/messengercompose/attachment;1" + ].createInstance(Ci.nsIMsgAttachment); + attachment.url = data; + attachment.name = prettyName; + attachment.contentType = contentType; + attachment.msgUri = msgUri; + + if (size !== undefined) { + attachment.size = size; + } + + if (cloudFileInfo) { + attachment.cloudFileAccountKey = cloudFileInfo.cloudFileAccountKey; + attachment.cloudPartHeaderData = cloudFileInfo.cloudPartHeaderData; + } + + attachments.push(attachment); + } + } + + return attachments; + }, + + /** + * Reorder the attachments dragged within the attachment bucket. + * + * @param {Event} event - The drag event. + */ + _reorderDraggedAttachments(event) { + // Adjust the drop target according to mouse position on list (items). + let target = this._adjustDropTarget(event); + // Get a non-live, sorted list of selected attachment list items. + let selItems = attachmentsSelectionGetSortedArray(); + // Keep track of the item we had focused originally. Deselect it though, + // since listbox gets confused if you move its focused item around. + let focus = gAttachmentBucket.currentItem; + gAttachmentBucket.currentItem = null; + // Moving possibly non-coherent multiple selections around correctly + // is much more complex than one might think... + if ( + (target.matches && target.matches("richlistitem.attachmentItem")) || + target == "afterLastItem" + ) { + // Drop before targetItem in the list, or after last item. + let blockItems = []; + let targetItem; + for (let item of selItems) { + blockItems.push(item); + if (target == "afterLastItem") { + // Original target is the end of the list; append all items there. + gAttachmentBucket.appendChild(item); + } else if (target == selItems[0]) { + // Original target is first item of first selected block. + if (blockItems.includes(target)) { + // Item is in first block: do nothing, find the end of the block. + let nextItem = item.nextElementSibling; + if (!nextItem || !nextItem.selected) { + // We've reached the end of the first block. + blockItems.length = 0; + targetItem = nextItem; + } + } else { + // Item is NOT in first block: insert before targetItem, + // i.e. after end of first block. + gAttachmentBucket.insertBefore(item, targetItem); + } + } else if (target.selected) { + // Original target is not first item of first block, + // but first item of another block. + if ( + gAttachmentBucket.getIndexOfItem(item) < + gAttachmentBucket.getIndexOfItem(target) + ) { + // Insert all items from preceding blocks before original target. + gAttachmentBucket.insertBefore(item, target); + } else if (blockItems.includes(target)) { + // target is included in any selected block except first: + // do nothing for that block, find its end. + let nextItem = item.nextElementSibling; + if (!nextItem || !nextItem.selected) { + // end of block containing target + blockItems.length = 0; + targetItem = nextItem; + } + } else { + // Item from block after block containing target: insert before + // targetItem, i.e. after end of block containing target. + gAttachmentBucket.insertBefore(item, targetItem); + } + } else { + // target != selItems [0] + // Original target is NOT first item of any block, and NOT selected: + // Insert all items before the original target. + gAttachmentBucket.insertBefore(item, target); + } + } + } + gAttachmentBucket.currentItem = focus; + }, + + handleInlineDrop(event) { + // It would be nice here to be able to append images, but we can't really + // assume if users want to add the image URL as clickable link or embedded + // image, so we always default to clickable link. + // We can later explore adding some UI choice to allow controlling the + // outcome of this drop action, but users can still copy and paste the image + // in the editor to cirumvent this potential issue. + let editor = GetCurrentEditor(); + let attachments = this.getValidAttachments(event, true); + + for (let attachment of attachments) { + if (!attachment?.url) { + continue; + } + + let link = editor.createElementWithDefaults("a"); + link.setAttribute("href", attachment.url); + link.textContent = + attachment.name || + gMsgCompose.AttachmentPrettyName(attachment.url, null); + editor.insertElementAtSelection(link, true); + } + }, + + async onDrop(event) { + this._hideDropOverlay(); + + let dragSession = gDragService.getCurrentSession(); + if (dragSession.sourceNode?.parentNode == gAttachmentBucket) { + // We dragged from the attachment pane onto itself, so instead of + // attaching a new object, we're just reordering them. + this._reorderDraggedAttachments(event); + this._hideDropMarker(); + return; + } + + // Interrupt if we're dropping elements from within the message body. + if (dragSession.sourceNode?.ownerDocument.URL == "about:blank?compose") { + return; + } + + // Interrupt if we're not dropping a file from outside the compose window + // and we're not dragging a supported data type. + if ( + !event.dataTransfer.files.length && + !DROP_FLAVORS.some(f => event.dataTransfer.types.includes(f)) + ) { + return; + } + + // If the drop happened on the inline container, and the dragged data is + // valid for inline, bail out and handle it as inline text link. + if (event.target.id == "addInline" && gIsValidInline) { + this.handleInlineDrop(event); + return; + } + + // Handle the inline adding of images without triggering the creation of + // any attachment if the user dropped only images above the #addInline box. + if ( + event.target.id == "addInline" && + !this.isNotDraggingOnlyImages(event.dataTransfer) + ) { + this.appendImagesInline(event.dataTransfer); + return; + } + + let attachments = this.getValidAttachments(event, true); + + // Interrupt if we don't have anything to attach. + if (!attachments.length) { + return; + } + + let addedAttachmentItems = await AddAttachments(attachments); + // Convert attachments back to cloudFiles, if any. + for (let attachmentItem of addedAttachmentItems) { + if ( + !attachmentItem.attachment.cloudFileAccountKey || + !attachmentItem.attachment.cloudPartHeaderData + ) { + continue; + } + try { + let account = cloudFileAccounts.getAccount( + attachmentItem.attachment.cloudFileAccountKey + ); + let upload = JSON.parse( + atob(attachmentItem.attachment.cloudPartHeaderData) + ); + await UpdateAttachment(attachmentItem, { + cloudFileAccount: account, + relatedCloudFileUpload: upload, + }); + } catch (ex) { + showLocalizedCloudFileAlert(ex); + } + } + gAttachmentBucket.focus(); + + // Stop the propagation only if we actually attached something. + event.stopPropagation(); + }, + + onDragOver(event) { + let dragSession = gDragService.getCurrentSession(); + + // Check if we're dragging from the attachment bucket onto itself. + if (dragSession.sourceNode?.parentNode == gAttachmentBucket) { + event.stopPropagation(); + event.preventDefault(); + + // Show a drop marker. + let target = this._adjustDropTarget(event); + + if ( + (target.matches && target.matches("richlistitem.attachmentItem")) || + target == "afterLastItem" + ) { + // Adjusted target is an attachment list item; show dropmarker. + this._showDropMarker(target); + return; + } + + // target == "none", target is not a listItem, or no target: + // Indicate that we can't drop here. + this._hideDropMarker(); + event.dataTransfer.dropEffect = "none"; + return; + } + + // Interrupt if we're dragging elements from within the message body. + if (dragSession.sourceNode?.ownerDocument.URL == "about:blank?compose") { + return; + } + + // No need to check for the same dragged files if the previous dragging + // action didn't end. + if (gIsDraggingAttachments) { + // Prevent the default action of the event otherwise the onDrop event + // won't be triggered. + event.preventDefault(); + this.detectHoveredOverlay(event.target.id); + return; + } + + if (DROP_FLAVORS.some(f => event.dataTransfer.types.includes(f))) { + // Show the drop overlay only if we dragged files or supported types. + let attachments = this.getValidAttachments(event); + if (attachments.length) { + // We're dragging files that can potentially be attached or added + // inline, so update the variable. + gIsDraggingAttachments = true; + + event.stopPropagation(); + event.preventDefault(); + document + .getElementById("dropAttachmentOverlay") + .classList.add("showing"); + + document.l10n.setAttributes( + document.getElementById("addAsAttachmentLabel"), + "drop-file-label-attachment", + { + count: attachments.length || 1, + } + ); + + document.l10n.setAttributes( + document.getElementById("addInlineLabel"), + "drop-file-label-inline", + { + count: attachments.length || 1, + } + ); + + // Show the #addInline box only if the user is dragging text that we + // want to allow adding as text, as well as dragging only images, and + // if this is not a plain text message. + // NOTE: We're using event.dataTransfer.files.length instead of + // attachments.length because we only need to consider images coming + // from outside the application. The attachments array might contain + // files dragged from other compose windows or received message, which + // should not trigger the inline attachment overlay. + document + .getElementById("addInline") + .classList.toggle( + "hidden", + !gIsValidInline && + (!event.dataTransfer.files.length || + this.isNotDraggingOnlyImages(event.dataTransfer) || + !gMsgCompose.composeHTML) + ); + } else { + DragAddressOverTargetControl(event); + } + } + + this.detectHoveredOverlay(event.target.id); + }, + + onDragLeave(event) { + // Set the variable to false as a drag leave event was triggered. + gIsDraggingAttachments = false; + + // We use a timeout since a drag leave event might occur also when the drag + // motion passes above a child element and doesn't actually leave the + // compose window. + setTimeout(() => { + // If after the timeout, the dragging boolean is true, it means the user + // is still dragging something above the compose window, so let's bail out + // to prevent visual flickering of the drop overlay. + if (gIsDraggingAttachments) { + return; + } + + this._hideDropOverlay(); + }, 100); + + this._hideDropMarker(); + }, + + /** + * Hide the drag & drop overlay and update the global dragging variable to + * false. This operations are set in a dedicated method since they need to be + * called outside of the onDragleave() method. + */ + _hideDropOverlay() { + gIsDraggingAttachments = false; + + let overlay = document.getElementById("dropAttachmentOverlay"); + overlay.classList.remove("showing"); + overlay.classList.add("hiding"); + }, + + /** + * Loop through all the currently dragged or dropped files to see if there's + * at least 1 file which is not an image. + * + * @param {DataTransfer} dataTransfer - The dataTransfer object from the drag + * or drop event. + * @returns {boolean} True if at least one file is not an image. + */ + isNotDraggingOnlyImages(dataTransfer) { + for (let file of dataTransfer.files) { + if (!file.type.includes("image/")) { + return true; + } + } + return false; + }, + + /** + * Add or remove the hover effect to the droppable containers. We can't do it + * simply via CSS since the hover events don't work when dragging an item. + * + * @param {string} targetId - The ID of the hovered overlay element. + */ + detectHoveredOverlay(targetId) { + document + .getElementById("addInline") + .classList.toggle("hover", targetId == "addInline"); + document + .getElementById("addAsAttachment") + .classList.toggle("hover", targetId == "addAsAttachment"); + }, + + /** + * Loop through all the images that have been dropped above the #addInline + * box and create an image element to append to the message body. + * + * @param {DataTransfer} dataTransfer - The dataTransfer object from the drop + * event. + */ + appendImagesInline(dataTransfer) { + focusMsgBody(); + let editor = GetCurrentEditor(); + editor.beginTransaction(); + + for (let file of dataTransfer.files) { + if (!file.mozFullPath) { + continue; + } + + let realFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + realFile.initWithPath(file.mozFullPath); + + let imageElement; + try { + imageElement = editor.createElementWithDefaults("img"); + } catch (e) { + dump("Failed to create a new image element!\n"); + console.error(e); + continue; + } + + let src = Services.io.newFileURI(realFile).spec; + imageElement.setAttribute("src", src); + imageElement.setAttribute("moz-do-not-send", "false"); + + editor.insertElementAtSelection(imageElement, true); + + try { + loadBlockedImage(src); + } catch (e) { + dump("Failed to load the appended image!\n"); + console.error(e); + continue; + } + } + + editor.endTransaction(); + }, +}; + +// See attachmentListDNDObserver, which should have the same logic. +let attachmentBucketDNDObserver = { + onDragStart(event) { + // NOTE: Starting a drag on an attachment item will normally also select + // the attachment item before this method is called. But this is not + // necessarily the case. E.g. holding Shift when starting the drag + // operation. When it isn't selected, we just don't transfer. + if (event.target.matches(".attachmentItem[selected]")) { + // Also transfer other selected attachment items. + let attachments = Array.from( + gAttachmentBucket.querySelectorAll(".attachmentItem[selected]"), + item => item.attachment + ); + setupDataTransfer(event, attachments); + } + event.stopPropagation(); + }, +}; + +function DisplaySaveFolderDlg(folderURI) { + try { + var showDialog = gCurrentIdentity.showSaveMsgDlg; + } catch (e) { + return; + } + + if (showDialog) { + let msgfolder = MailUtils.getExistingFolder(folderURI); + if (!msgfolder) { + return; + } + let checkbox = { value: 0 }; + let bundle = getComposeBundle(); + let SaveDlgTitle = bundle.getString("SaveDialogTitle"); + let dlgMsg = bundle.getFormattedString("SaveDialogMsg", [ + msgfolder.name, + msgfolder.server.prettyName, + ]); + + Services.prompt.alertCheck( + window, + SaveDlgTitle, + dlgMsg, + bundle.getString("CheckMsg"), + checkbox + ); + try { + gCurrentIdentity.showSaveMsgDlg = !checkbox.value; + } catch (e) {} + } +} + +/** + * Focus the people search input in the contacts side panel. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {boolean} - Whether the peopleSearchInput was focused. + */ +function focusContactsSidebarSearchInput() { + if (document.getElementById("contactsSplitter").isCollapsed) { + return false; + } + let input = document + .getElementById("contactsBrowser") + .contentDocument.getElementById("peopleSearchInput"); + if (!input) { + return false; + } + input.focus(); + return true; +} + +/** + * Focus the "From" identity input/selector. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {true} - Always returns true. + */ +function focusMsgIdentity() { + document.getElementById("msgIdentity").focus(); + return true; +} + +/** + * Focus the address row input, provided the row is not hidden. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @param {Element} row - The address row to focus. + * + * @returns {boolean} - Whether the input was focused. + */ +function focusAddressRowInput(row) { + if (row.classList.contains("hidden")) { + return false; + } + row.querySelector(".address-row-input").focus(); + return true; +} + +/** + * Focus the "Subject" input. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {true} - Always returns true. + */ +function focusSubjectInput() { + document.getElementById("msgSubject").focus(); + return true; +} + +/** + * Focus the composed message body. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {true} - Always returns true. + */ +function focusMsgBody() { + // window.content.focus() fails to blur the currently focused element + document.commandDispatcher.advanceFocusIntoSubtree( + document.getElementById("messageArea") + ); + return true; +} + +/** + * Focus the attachment bucket, provided it is not hidden. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @param {Element} attachmentArea - The attachment container. + * + * @returns {boolean} - Whether the attachment bucket was focused. + */ +function focusAttachmentBucket(attachmentArea) { + if ( + document + .getElementById("composeContentBox") + .classList.contains("attachment-area-hidden") + ) { + return false; + } + if (!attachmentArea.open) { + // Focus the expander instead. + attachmentArea.querySelector("summary").focus(); + return true; + } + gAttachmentBucket.focus(); + return true; +} + +/** + * Focus the first notification button. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @returns {boolean} - Whether a notification received focused. + */ +function focusNotification() { + let notification = gComposeNotification.allNotifications[0]; + if (notification) { + let button = notification.buttonContainer.querySelector("button"); + if (button) { + button.focus(); + } else { + // Focus the close button instead. + notification.closeButton.focus(); + } + return true; + } + return false; +} + +/** + * Focus the first focusable descendant of the status bar. + * + * Note, this is used as a {@link moveFocusWithin} method. + * + * @param {Element} attachmentArea - The status bar. + * + * @returns {boolean} - Whether a status bar descendant received focused. + */ +function focusStatusBar(statusBar) { + let button = statusBar.querySelector("button:not([hidden])"); + if (!button) { + return false; + } + button.focus(); + return true; +} + +/** + * Fast-track focus ring: Switch focus between important (not all) elements + * in the message compose window in response to Ctrl+[Shift+]Tab or [Shift+]F6. + * + * @param {Event} event - A DOM keyboard event of a fast focus ring shortcut key + */ +function moveFocusToNeighbouringArea(event) { + event.preventDefault(); + let currentElement = document.activeElement; + + for (let i = 0; i < gFocusAreas.length; i++) { + // Go through each area and check if focus is within. + let area = gFocusAreas[i]; + if (!area.root.contains(currentElement)) { + continue; + } + // Focus is within, so we find the neighbouring area to move focus to. + let end = i; + while (true) { + // Get the next neighbour. + // NOTE: The focus will loop around. + if (event.shiftKey) { + // Move focus backward. If the index points to the start of the Array, + // we loop back to the end of the Array. + i = (i || gFocusAreas.length) - 1; + } else { + // Move focus forward. If the index points to the end of the Array, we + // loop back to the start of the Array. + i = (i + 1) % gFocusAreas.length; + } + if (i == end) { + // Full loop around without finding an area to focus. + // Unexpected, but we make sure to stop looping. + break; + } + area = gFocusAreas[i]; + if (area.focus(area.root)) { + // Successfully moved focus. + break; + } + // Else, try the next neighbour. + } + return; + } + // Focus is currently outside the gFocusAreas list, so do nothing. +} + +/** + * If the contacts sidebar is shown, hide it. Otherwise, show the contacts + * sidebar and focus it. + */ +function toggleContactsSidebar() { + setContactsSidebarVisibility( + document.getElementById("contactsSplitter").isCollapsed, + true + ); +} + +/** + * Show or hide contacts sidebar. + * + * @param {boolean} show - Whether to show the sidebar or hide the sidebar. + * @param {boolean} focus - Whether to focus peopleSearchInput if the sidebar is + * shown. + */ +function setContactsSidebarVisibility(show, focus) { + let contactsSplitter = document.getElementById("contactsSplitter"); + let sidebarAddrMenu = document.getElementById("menu_AddressSidebar"); + let contactsButton = document.getElementById("button-contacts"); + + if (show) { + contactsSplitter.expand(); + sidebarAddrMenu.setAttribute("checked", "true"); + if (contactsButton) { + contactsButton.setAttribute("checked", "true"); + } + + let contactsBrowser = document.getElementById("contactsBrowser"); + if (contactsBrowser.getAttribute("src") == "") { + // Url not yet set, load contacts side bar and focus the search + // input if applicable: We pass "?focus" as a URL querystring, then via + // onload event of <window id="abContactsPanel">, in AbPanelLoad() of + // abContactsPanel.js, we do the focusing first thing to avoid timing + // issues when trying to focus from here while contacts side bar is still + // loading. + let url = "chrome://messenger/content/addressbook/abContactsPanel.xhtml"; + if (focus) { + url += "?focus"; + } + contactsBrowser.setAttribute("src", url); + } else if (focus) { + // Url already set, so we can focus immediately if applicable. + focusContactsSidebarSearchInput(); + } + } else { + let contactsSidebar = document.getElementById("contactsSidebar"); + // Before closing, check if the focus was within the contacts sidebar. + let sidebarFocussed = contactsSidebar.contains(document.activeElement); + + contactsSplitter.collapse(); + sidebarAddrMenu.removeAttribute("checked"); + if (contactsButton) { + contactsButton.removeAttribute("checked"); + } + + // Don't change the focus unless it was within the contacts sidebar. + if (!sidebarFocussed) { + return; + } + // Else, we need to explicitly move the focus out of the contacts sidebar. + // We choose the subject input if it is empty, otherwise the message body. + if (!document.getElementById("msgSubject").value) { + focusSubjectInput(); + } else { + focusMsgBody(); + } + } +} + +function loadHTMLMsgPrefs() { + let fontFace = Services.prefs.getStringPref("msgcompose.font_face", ""); + if (fontFace) { + doStatefulCommand("cmd_fontFace", fontFace, true); + } + + let fontSize = Services.prefs.getCharPref("msgcompose.font_size", "3"); + EditorSetFontSize(fontSize); + + let bodyElement = GetBodyElement(); + + let useDefault = Services.prefs.getBoolPref("msgcompose.default_colors"); + + let textColor = useDefault + ? "" + : Services.prefs.getCharPref("msgcompose.text_color", ""); + if (!bodyElement.getAttribute("text") && textColor) { + bodyElement.setAttribute("text", textColor); + gDefaultTextColor = textColor; + document.getElementById("cmd_fontColor").setAttribute("state", textColor); + onFontColorChange(); + } + + let bgColor = useDefault + ? "" + : Services.prefs.getCharPref("msgcompose.background_color", ""); + if (!bodyElement.getAttribute("bgcolor") && bgColor) { + bodyElement.setAttribute("bgcolor", bgColor); + gDefaultBackgroundColor = bgColor; + document + .getElementById("cmd_backgroundColor") + .setAttribute("state", bgColor); + onBackgroundColorChange(); + } +} + +async function AutoSave() { + if ( + gMsgCompose.editor && + (gContentChanged || gMsgCompose.bodyModified) && + !gSendOperationInProgress && + !gSaveOperationInProgress + ) { + try { + await GenericSendMessage(Ci.nsIMsgCompDeliverMode.AutoSaveAsDraft); + } catch (ex) { + console.error(ex); + } + gAutoSaveKickedIn = true; + } + + gAutoSaveTimeout = setTimeout(AutoSave, gAutoSaveInterval); +} + +/** + * Periodically check for keywords in the message. + */ +var gAttachmentNotifier = { + _obs: null, + + enabled: false, + + init(aDocument) { + if (this._obs) { + this.shutdown(); + } + + this.enabled = Services.prefs.getBoolPref( + "mail.compose.attachment_reminder" + ); + if (!this.enabled) { + return; + } + + this._obs = new MutationObserver(function (aMutations) { + gAttachmentNotifier.timer.cancel(); + gAttachmentNotifier.timer.initWithCallback( + gAttachmentNotifier.event, + 500, + Ci.nsITimer.TYPE_ONE_SHOT + ); + }); + + this._obs.observe(aDocument, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }); + + // Add an input event listener for the subject field since there + // are ways of changing its value without key presses. + document + .getElementById("msgSubject") + .addEventListener("input", this.subjectInputObserver, true); + + // We could have been opened with a draft message already containing + // some keywords, so run the checker once to pick them up. + this.event.notify(); + }, + + // Timer based function triggered by the inputEventListener + // for the subject field. + subjectInputObserver() { + gAttachmentNotifier.timer.cancel(); + gAttachmentNotifier.timer.initWithCallback( + gAttachmentNotifier.event, + 500, + Ci.nsITimer.TYPE_ONE_SHOT + ); + }, + + /** + * Checks for new keywords synchronously and run the usual handler. + * + * @param aManage Determines whether to manage the notification according to keywords found. + */ + redetectKeywords(aManage) { + if (!this.enabled) { + return; + } + + attachmentWorker.onmessage( + { data: this._checkForAttachmentKeywords(false) }, + aManage + ); + }, + + /** + * Check if there are any keywords in the message. + * + * @param async Whether we should run the regex checker asynchronously or not. + * + * @returns If async is true, attachmentWorker.message is called with the array + * of found keywords and this function returns null. + * If it is false, the array is returned from this function immediately. + */ + _checkForAttachmentKeywords(async) { + if (!this.enabled) { + return async ? null : []; + } + + if (attachmentNotificationSupressed()) { + // If we know we don't need to show the notification, + // we can skip the expensive checking of keywords in the message. + // but mark it in the .lastMessage that the keywords are unknown. + attachmentWorker.lastMessage = null; + return async ? null : []; + } + + let keywordsInCsv = Services.prefs.getComplexValue( + "mail.compose.attachment_reminder_keywords", + Ci.nsIPrefLocalizedString + ).data; + let mailBody = getBrowser().contentDocument.querySelector("body"); + + // We use a new document and import the body into it. We do that to avoid + // loading images that were previously blocked. Content policy of the newly + // created data document will block the loads. Details: Bug 1409458 comment #22. + let newDoc = getBrowser().contentDocument.implementation.createDocument( + "", + "", + null + ); + let mailBodyNode = newDoc.importNode(mailBody, true); + + // Don't check quoted text from reply. + let blockquotes = mailBodyNode.getElementsByTagName("blockquote"); + for (let i = blockquotes.length - 1; i >= 0; i--) { + blockquotes[i].remove(); + } + + // For plaintext composition the quotes we need to find and exclude are + // <span _moz_quote="true">. + let spans = mailBodyNode.querySelectorAll("span[_moz_quote]"); + for (let i = spans.length - 1; i >= 0; i--) { + spans[i].remove(); + } + + // Ignore signature (html compose mode). + let sigs = mailBodyNode.getElementsByClassName("moz-signature"); + for (let i = sigs.length - 1; i >= 0; i--) { + sigs[i].remove(); + } + + // Replace brs with line breaks so node.textContent won't pull foo<br>bar + // together to foobar. + let brs = mailBodyNode.getElementsByTagName("br"); + for (let i = brs.length - 1; i >= 0; i--) { + brs[i].parentNode.replaceChild( + mailBodyNode.ownerDocument.createTextNode("\n"), + brs[i] + ); + } + + // Ignore signature (plain text compose mode). + let mailData = mailBodyNode.textContent; + let sigIndex = mailData.indexOf("-- \n"); + if (sigIndex > 0) { + mailData = mailData.substring(0, sigIndex); + } + + // Ignore replied messages (plain text and html compose mode). + let repText = getComposeBundle().getString( + "mailnews.reply_header_originalmessage" + ); + let repIndex = mailData.indexOf(repText); + if (repIndex > 0) { + mailData = mailData.substring(0, repIndex); + } + + // Ignore forwarded messages (plain text and html compose mode). + let fwdText = getComposeBundle().getString( + "mailnews.forward_header_originalmessage" + ); + let fwdIndex = mailData.indexOf(fwdText); + if (fwdIndex > 0) { + mailData = mailData.substring(0, fwdIndex); + } + + // Prepend the subject to see if the subject contains any attachment + // keywords too, after making sure that the subject has changed + // or after reopening a draft. For reply, redirect and forward, + // only check when the input was changed by the user. + let subject = document.getElementById("msgSubject").value; + if ( + subject && + (gSubjectChanged || + (gEditingDraft && + (gComposeType == Ci.nsIMsgCompType.New || + gComposeType == Ci.nsIMsgCompType.NewsPost || + gComposeType == Ci.nsIMsgCompType.Draft || + gComposeType == Ci.nsIMsgCompType.Template || + gComposeType == Ci.nsIMsgCompType.EditTemplate || + gComposeType == Ci.nsIMsgCompType.EditAsNew || + gComposeType == Ci.nsIMsgCompType.MailToUrl))) + ) { + mailData = subject + " " + mailData; + } + + if (!async) { + return AttachmentChecker.getAttachmentKeywords(mailData, keywordsInCsv); + } + + attachmentWorker.postMessage([mailData, keywordsInCsv]); + return null; + }, + + shutdown() { + if (this._obs) { + this._obs.disconnect(); + } + gAttachmentNotifier.timer.cancel(); + + this._obs = null; + }, + + event: { + notify(timer) { + // Only run the checker if the compose window is initialized + // and not shutting down. + if (gMsgCompose) { + // This runs the attachmentWorker asynchronously so if keywords are found + // manageAttachmentNotification is run from attachmentWorker.onmessage. + gAttachmentNotifier._checkForAttachmentKeywords(true); + } + }, + }, + + timer: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer), +}; + +/** + * Helper function to remove a query part from a URL, so for example: + * ...?remove=xx&other=yy becomes ...?other=yy. + * + * @param aURL the URL from which to remove the query part + * @param aQuery the query part to remove + * @returns the URL with the query part removed + */ +function removeQueryPart(aURL, aQuery) { + // Quick pre-check. + if (!aURL.includes(aQuery)) { + return aURL; + } + + let indexQM = aURL.indexOf("?"); + if (indexQM < 0) { + return aURL; + } + + let queryParts = aURL.substr(indexQM + 1).split("&"); + let indexPart = queryParts.indexOf(aQuery); + if (indexPart < 0) { + return aURL; + } + queryParts.splice(indexPart, 1); + return aURL.substr(0, indexQM + 1) + queryParts.join("&"); +} + +function InitEditor() { + var editor = GetCurrentEditor(); + + // Set eEditorMailMask flag to avoid using content prefs for spell checker, + // otherwise dictionary setting in preferences is ignored and dictionary is + // inconsistent in subject and message body. + let eEditorMailMask = Ci.nsIEditor.eEditorMailMask; + editor.flags |= eEditorMailMask; + document.getElementById("msgSubject").editor.flags |= eEditorMailMask; + + // Control insertion of line breaks. + editor.returnInParagraphCreatesNewParagraph = Services.prefs.getBoolPref( + "editor.CR_creates_new_p" + ); + editor.document.execCommand( + "defaultparagraphseparator", + false, + gMsgCompose.composeHTML && + Services.prefs.getBoolPref("mail.compose.default_to_paragraph") + ? "p" + : "br" + ); + if (gMsgCompose.composeHTML) { + // Re-enable table/image resizers. + editor.QueryInterface( + Ci.nsIHTMLAbsPosEditor + ).absolutePositioningEnabled = true; + editor.QueryInterface( + Ci.nsIHTMLInlineTableEditor + ).inlineTableEditingEnabled = true; + editor.QueryInterface(Ci.nsIHTMLObjectResizer).objectResizingEnabled = true; + } + + // We use loadSheetUsingURIString so that we get a synchronous load, rather + // than having a late-finishing async load mark our editor as modified when + // the user hasn't typed anything yet, but that means the sheet must not + // @import slow things, especially not over the network. + let domWindowUtils = GetCurrentEditorElement().contentWindow.windowUtils; + domWindowUtils.loadSheetUsingURIString( + "chrome://messenger/skin/messageQuotes.css", + domWindowUtils.AGENT_SHEET + ); + domWindowUtils.loadSheetUsingURIString( + "chrome://messenger/skin/shared/composerOverlay.css", + domWindowUtils.AGENT_SHEET + ); + + window.content.browsingContext.allowJavascript = false; + window.content.browsingContext.docShell.allowAuth = false; + window.content.browsingContext.docShell.allowMetaRedirects = false; + gMsgCompose.initEditor(editor, window.content); + + if (!editor.document.doctype) { + editor.document.insertBefore( + editor.document.implementation.createDocumentType("html", "", ""), + editor.document.firstChild + ); + } + + // Then, we enable related UI entries. + enableInlineSpellCheck(Services.prefs.getBoolPref("mail.spellcheck.inline")); + gAttachmentNotifier.init(editor.document); + + // Listen for spellchecker changes, set document language to + // dictionary picked by the user via the right-click menu in the editor. + document.addEventListener("spellcheck-changed", updateDocumentLanguage); + + // XXX: the error event fires twice for each load. Why?? + editor.document.body.addEventListener( + "error", + function (event) { + if (event.target.localName != "img") { + return; + } + + if (event.target.getAttribute("moz-do-not-send") == "true") { + return; + } + + let src = event.target.src; + if (!src) { + return; + } + if (!/^file:/i.test(src)) { + // Check if this is a protocol that can fetch parts. + let protocol = src.substr(0, src.indexOf(":")).toLowerCase(); + if ( + !( + Services.io.getProtocolHandler(protocol) instanceof + Ci.nsIMsgMessageFetchPartService + ) + ) { + // Can't fetch parts, don't try to load. + return; + } + } + + if (event.target.classList.contains("loading-internal")) { + // We're already loading this, or tried so unsuccessfully. + return; + } + if (gOriginalMsgURI) { + let msgSvc = MailServices.messageServiceFromURI(gOriginalMsgURI); + let originalMsgNeckoURI = msgSvc.getUrlForUri(gOriginalMsgURI); + if ( + src.startsWith( + removeQueryPart( + originalMsgNeckoURI.spec, + "type=application/x-message-display" + ) + ) || + // Special hack for saved messages. + (src.includes("?number=0&") && + originalMsgNeckoURI.spec.startsWith("file://") && + src.startsWith( + removeQueryPart( + originalMsgNeckoURI.spec, + "type=application/x-message-display" + ).replace("file://", "mailbox://") + "number=0" + )) + ) { + // Reply/Forward/Edit Draft/Edit as New can contain references to + // images in the original message. Load those and make them data: URLs + // now. + event.target.classList.add("loading-internal"); + try { + loadBlockedImage(src); + } catch (e) { + // Couldn't load the referenced image. + console.error(e); + } + } else { + // Appears to reference a random message. Notify and keep blocking. + gComposeNotificationBar.setBlockedContent(src); + } + } else { + // For file:, and references to parts of random messages, show the + // blocked content notification. + gComposeNotificationBar.setBlockedContent(src); + } + }, + true + ); + + // Convert mailnews URL back to data: URL. + let background = editor.document.body.background; + if (background && gOriginalMsgURI) { + // Check that background has the same URL as the message itself. + let msgSvc = MailServices.messageServiceFromURI(gOriginalMsgURI); + let originalMsgNeckoURI = msgSvc.getUrlForUri(gOriginalMsgURI); + if ( + background.startsWith( + removeQueryPart( + originalMsgNeckoURI.spec, + "type=application/x-message-display" + ) + ) + ) { + try { + editor.document.body.background = loadBlockedImage(background, true); + } catch (e) { + // Couldn't load the referenced image. + console.error(e); + } + } + } + + // Run menubar initialization first, to avoid TabsInTitlebar code picking + // up mutations from it and causing a reflow. + if (AppConstants.platform != "macosx") { + AutoHideMenubar.init(); + } + + // For plain text compose, set the styles for quoted text according to + // preferences. + if (!gMsgCompose.composeHTML) { + let style = editor.document.createElement("style"); + editor.document.head.appendChild(style); + let fontStyle = ""; + let fontSize = ""; + switch (Services.prefs.getIntPref("mail.quoted_style")) { + case 1: + fontStyle = "font-weight: bold;"; + break; + case 2: + fontStyle = "font-style: italic;"; + break; + case 3: + fontStyle = "font-weight: bold; font-style: italic;"; + break; + } + + switch (Services.prefs.getIntPref("mail.quoted_size")) { + case 1: + fontSize = "font-size: large;"; + break; + case 2: + fontSize = "font-size: small;"; + break; + } + + let citationColor = + "color: " + Services.prefs.getCharPref("mail.citation_color") + ";"; + + style.sheet.insertRule( + `span[_moz_quote="true"] { + ${fontStyle} + ${fontSize} + ${citationColor} + }` + ); + gMsgCompose.bodyModified = false; + } + + // Set document language to the draft language or the preference + // if this is a draft or template we prepared. + let draftLanguages = null; + if ( + gMsgCompose.compFields.creatorIdentityKey && + gMsgCompose.compFields.contentLanguage + ) { + draftLanguages = gMsgCompose.compFields.contentLanguage + .split(",") + .map(lang => lang.trim()); + } + + let dictionaries = getValidSpellcheckerDictionaries(draftLanguages); + ComposeChangeLanguage(dictionaries).catch(console.error); +} + +function setFontSize(event) { + // Increase Font Menuitem and Decrease Font Menuitem from the main menu + // will call this function because of oncommand attribute on the menupopup + // and fontSize will be null for such function calls. + let fontSize = event.target.value; + if (fontSize) { + EditorSetFontSize(fontSize); + } +} + +function setParagraphState(event) { + editorSetParagraphState(event.target.value); +} + +// This is used as event listener to spellcheck-changed event to update +// document language. +function updateDocumentLanguage(e) { + ComposeChangeLanguage(e.detail.dictionaries).catch(console.error); +} + +function toggleSpellCheckingEnabled() { + enableInlineSpellCheck(!gSpellCheckingEnabled); +} + +// This function is called either at startup (see InitEditor above), or when +// the user clicks on one of the two menu items that allow them to toggle the +// spellcheck feature (either context menu or Options menu). +function enableInlineSpellCheck(aEnableInlineSpellCheck) { + let checker = GetCurrentEditorSpellChecker(); + if (!checker) { + return; + } + if (gSpellCheckingEnabled != aEnableInlineSpellCheck) { + // If state of spellchecker is about to change, clear any pending observer. + spellCheckReadyObserver.removeObserver(); + } + + gSpellCheckingEnabled = checker.enableRealTimeSpell = aEnableInlineSpellCheck; + document + .getElementById("msgSubject") + .setAttribute("spellcheck", aEnableInlineSpellCheck); +} + +function getMailToolbox() { + return document.getElementById("compose-toolbox"); +} + +/** + * Helper function to dispatch a CustomEvent to the attachmentbucket. + * + * @param aEventType the name of the event to fire. + * @param aData any detail data to pass to the CustomEvent. + */ +function dispatchAttachmentBucketEvent(aEventType, aData) { + gAttachmentBucket.dispatchEvent( + new CustomEvent(aEventType, { + bubbles: true, + cancelable: true, + detail: aData, + }) + ); +} + +/** Update state of zoom type (text vs. full) menu item. */ +function UpdateFullZoomMenu() { + let menuItem = document.getElementById("menu_fullZoomToggle"); + menuItem.setAttribute("checked", !ZoomManager.useFullZoom); +} + +/** + * Return the <editor> element of the mail compose window. The name is somewhat + * unfortunate; we need to maintain it since the zoom manager, view source and + * other functions still rely on it. + */ +function getBrowser() { + return document.getElementById("messageEditor"); +} + +function goUpdateMailMenuItems(commandset) { + for (let i = 0; i < commandset.children.length; i++) { + let commandID = commandset.children[i].getAttribute("id"); + if (commandID) { + goUpdateCommand(commandID); + } + } +} + +/** + * Object to handle message related notifications that are showing in a + * notificationbox below the composed message content. + */ +var gComposeNotificationBar = { + get brandBundle() { + delete this.brandBundle; + return (this.brandBundle = document.getElementById("brandBundle")); + }, + + setBlockedContent(aBlockedURI) { + let brandName = this.brandBundle.getString("brandShortName"); + let buttonLabel = getComposeBundle().getString( + AppConstants.platform == "win" + ? "blockedContentPrefLabel" + : "blockedContentPrefLabelUnix" + ); + let buttonAccesskey = getComposeBundle().getString( + AppConstants.platform == "win" + ? "blockedContentPrefAccesskey" + : "blockedContentPrefAccesskeyUnix" + ); + + let buttons = [ + { + label: buttonLabel, + accessKey: buttonAccesskey, + popup: "blockedContentOptions", + callback(aNotification, aButton) { + return true; // keep notification open + }, + }, + ]; + + // The popup value is a space separated list of all the blocked urls. + let popup = document.getElementById("blockedContentOptions"); + let urls = popup.value ? popup.value.split(" ") : []; + if (!urls.includes(aBlockedURI)) { + urls.push(aBlockedURI); + } + popup.value = urls.join(" "); + + let msg = getComposeBundle().getFormattedString("blockedContentMessage", [ + brandName, + brandName, + ]); + msg = PluralForm.get(urls.length, msg); + + if (!this.isShowingBlockedContentNotification()) { + gComposeNotification.appendNotification( + "blockedContent", + { + label: msg, + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + buttons + ); + } else { + gComposeNotification + .getNotificationWithValue("blockedContent") + .setAttribute("label", msg); + } + }, + + isShowingBlockedContentNotification() { + return !!gComposeNotification.getNotificationWithValue("blockedContent"); + }, + + clearBlockedContentNotification() { + gComposeNotification.removeNotification( + gComposeNotification.getNotificationWithValue("blockedContent") + ); + }, + + clearNotifications(aValue) { + gComposeNotification.removeAllNotifications(true); + }, + + /** + * Show a warning notification when a newly typed identity in the Form field + * doesn't match any existing identity. + * + * @param {string} identity - The name of the identity to add to the + * notification. Most likely an email address. + */ + async setIdentityWarning(identity) { + // Bail out if we are already showing this type of notification. + if (gComposeNotification.getNotificationWithValue("identityWarning")) { + return; + } + + gComposeNotification.appendNotification( + "identityWarning", + { + label: await document.l10n.formatValue( + "compose-missing-identity-warning", + { + identity, + } + ), + priority: gComposeNotification.PRIORITY_WARNING_HIGH, + }, + null + ); + }, + + clearIdentityWarning() { + let idWarning = + gComposeNotification.getNotificationWithValue("identityWarning"); + if (idWarning) { + gComposeNotification.removeNotification(idWarning); + } + }, +}; + +/** + * Populate the menuitems of what blocked content to unblock. + */ +function onBlockedContentOptionsShowing(aEvent) { + let urls = aEvent.target.value ? aEvent.target.value.split(" ") : []; + + // Out with the old... + while (aEvent.target.lastChild) { + aEvent.target.lastChild.remove(); + } + + // ... and in with the new. + for (let url of urls) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute( + "label", + getComposeBundle().getFormattedString("blockedAllowResource", [url]) + ); + menuitem.setAttribute("crop", "center"); + menuitem.setAttribute("value", url); + menuitem.setAttribute( + "oncommand", + "onUnblockResource(this.value, this.parentNode);" + ); + aEvent.target.appendChild(menuitem); + } +} + +/** + * Handle clicking the "Load <url>" in the blocked content notification bar. + * + * @param {string} aURL - the URL that was unblocked + * @param {Node} aNode - the node holding as value the URLs of the blocked + * resources in the message (space separated). + */ +function onUnblockResource(aURL, aNode) { + try { + loadBlockedImage(aURL); + } catch (e) { + // Couldn't load the referenced image. + console.error(e); + } finally { + // Remove it from the list on success and failure. + let urls = aNode.value.split(" "); + for (let i = 0; i < urls.length; i++) { + if (urls[i] == aURL) { + urls.splice(i, 1); + aNode.value = urls.join(" "); + if (urls.length == 0) { + gComposeNotificationBar.clearBlockedContentNotification(); + } + break; + } + } + } +} + +/** + * Convert the blocked content to a data URL and swap the src to that for the + * elements that were using it. + * + * @param {string} aURL - (necko) URL to unblock + * @param {Bool} aReturnDataURL - return data: URL instead of processing image + * @returns {string} the image as data: URL. + * @throw Error() if reading the data failed + */ +function loadBlockedImage(aURL, aReturnDataURL = false) { + let filename; + if (/^(file|chrome|moz-extension):/i.test(aURL)) { + filename = aURL.substr(aURL.lastIndexOf("/") + 1); + } else { + let fnMatch = /[?&;]filename=([^?&]+)/.exec(aURL); + filename = (fnMatch && fnMatch[1]) || ""; + } + filename = decodeURIComponent(filename); + let uri = Services.io.newURI(aURL); + let contentType; + if (filename) { + try { + contentType = Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromURI(uri); + } catch (ex) { + contentType = "image/png"; + } + + if (!contentType.startsWith("image/")) { + // Unsafe to unblock this. It would just be garbage either way. + throw new Error( + "Won't unblock; URL=" + aURL + ", contentType=" + contentType + ); + } + } else { + // Assuming image/png is the best we can do. + contentType = "image/png"; + } + let channel = Services.io.newChannelFromURI( + uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + let inputStream = channel.open(); + let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + stream.setInputStream(inputStream); + let streamData = ""; + try { + while (stream.available() > 0) { + streamData += stream.readBytes(stream.available()); + } + } catch (e) { + stream.close(); + throw new Error("Couldn't read all data from URL=" + aURL + " (" + e + ")"); + } + stream.close(); + let encoded = btoa(streamData); + let dataURL = + "data:" + + contentType + + (filename ? ";filename=" + encodeURIComponent(filename) : "") + + ";base64," + + encoded; + + if (aReturnDataURL) { + return dataURL; + } + + let editor = GetCurrentEditor(); + for (let img of editor.document.images) { + if (img.src == aURL) { + img.src = dataURL; // Swap to data URL. + img.classList.remove("loading-internal"); + } + } + + return null; +} + +/** + * Update state of encrypted/signed toolbar buttons + */ +function showSendEncryptedAndSigned() { + let encToggle = document.getElementById("button-encryption"); + if (encToggle) { + if (gSendEncrypted) { + encToggle.setAttribute("checked", "true"); + } else { + encToggle.removeAttribute("checked"); + } + } + + let sigToggle = document.getElementById("button-signing"); + if (sigToggle) { + if (gSendSigned) { + sigToggle.setAttribute("checked", "true"); + } else { + sigToggle.removeAttribute("checked"); + } + } + + // Should button remain enabled? Identity might be unable to + // encrypt, but we might have kept button enabled after identity change. + let identityHasConfiguredSMIME = + isSmimeSigningConfigured() || isSmimeEncryptionConfigured(); + let identityHasConfiguredOpenPGP = isPgpConfigured(); + let e2eeNotConfigured = + !identityHasConfiguredOpenPGP && !identityHasConfiguredSMIME; + + if (encToggle) { + encToggle.disabled = e2eeNotConfigured && !gSendEncrypted; + } + if (sigToggle) { + sigToggle.disabled = e2eeNotConfigured; + } +} + +/** + * Look at the current encryption setting, and perform necessary + * automatic adjustments to related settings. + */ +function updateEncryptionDependencies() { + let canSign = gSelectedTechnologyIsPGP + ? isPgpConfigured() + : isSmimeSigningConfigured(); + + if (!canSign) { + gSendSigned = false; + gUserTouchedSendSigned = false; + } else if (!gSendEncrypted) { + if (!gUserTouchedSendSigned) { + gSendSigned = gCurrentIdentity.signMail; + } + } else if (!gUserTouchedSendSigned) { + gSendSigned = true; + } + + // if (!gSendEncrypted) we don't need to change gEncryptSubject, + // it will be ignored anyway. + if (gSendEncrypted) { + if (!gUserTouchedEncryptSubject) { + gEncryptSubject = gCurrentIdentity.protectSubject; + } + } + + if (!gSendSigned) { + if (!gUserTouchedAttachMyPubKey) { + gAttachMyPublicPGPKey = false; + } + } else if (!gUserTouchedAttachMyPubKey) { + gAttachMyPublicPGPKey = gCurrentIdentity.attachPgpKey; + } + + if (!gSendEncrypted) { + clearRecipPillKeyIssues(); + } + + if (gSMFields && !gSelectedTechnologyIsPGP) { + gSMFields.requireEncryptMessage = gSendEncrypted; + gSMFields.signMessage = gSendSigned; + } + + updateAttachMyPubKey(); + + updateEncryptedSubject(); + showSendEncryptedAndSigned(); + + updateEncryptOptionsMenuElements(); + checkEncryptedBccRecipients(); +} + +/** + * Listen to the click events on the compose window. + * + * @param {Event} event - The DOM Event + */ +function composeWindowOnClick(event) { + // Don't deselect pills if the click happened on another pill as the selection + // and focus change is handled by the pill itself. We also ignore clicks on + // toolbarbuttons, menus, and menu items. This will also prevent the unwanted + // deselection when opening the context menu on macOS. + if ( + event.target?.tagName == "mail-address-pill" || + event.target?.tagName == "toolbarbutton" || + event.target?.tagName == "menu" || + event.target?.tagName == "menuitem" + ) { + return; + } + + document.getElementById("recipientsContainer").deselectAllPills(); +} diff --git a/comm/mail/components/compose/content/addressingWidgetOverlay.js b/comm/mail/components/compose/content/addressingWidgetOverlay.js new file mode 100644 index 0000000000..cee4b6889e --- /dev/null +++ b/comm/mail/components/compose/content/addressingWidgetOverlay.js @@ -0,0 +1,1336 @@ +/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from MsgComposeCommands.js */ +/* import-globals-from ../../addrbook/content/abCommon.js */ +/* globals goDoCommand */ // From globalOverlay.js + +var { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm"); +var { DisplayNameUtils } = ChromeUtils.import( + "resource:///modules/DisplayNameUtils.jsm" +); + +// Temporarily prevent repeated deletion key events in address rows or subject. +// Prevent the keyboard shortcut for removing an empty address row (long +// Backspace or Delete keypress) from affecting another row. Also, when a long +// deletion keypress has just removed all text or all visible text from a row +// input, prevent the ongoing keypress from removing the row. +var gPreventRowDeletionKeysRepeat = false; + +/** + * Convert all the written recipients into string and store them into the + * msgCompFields array to be printed in the message header. + * + * @param {object} msgCompFields - An object to receive the recipients. + */ +function Recipients2CompFields(msgCompFields) { + if (!msgCompFields) { + throw new Error( + "Message Compose Error: msgCompFields is null (ExtractRecipients)" + ); + } + + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + for (let row of document.querySelectorAll(".address-row-raw")) { + let recipientType = row.dataset.recipienttype; + let headerValue = row.querySelector(".address-row-input").value.trim(); + if (headerValue) { + msgCompFields.setRawHeader(recipientType, headerValue); + } else if (otherHeaders.includes(recipientType)) { + msgCompFields.deleteHeader(recipientType); + } + } + + let getRecipientList = recipientType => + Array.from( + document.querySelectorAll( + `.address-row[data-recipienttype="${recipientType}"] mail-address-pill` + ), + pill => { + // Expect each pill to contain exactly one address. + let { name, email } = MailServices.headerParser.makeFromDisplayAddress( + pill.fullAddress + )[0]; + return MailServices.headerParser.makeMimeAddress(name, email); + } + ).join(","); + + msgCompFields.to = getRecipientList("addr_to"); + msgCompFields.cc = getRecipientList("addr_cc"); + msgCompFields.bcc = getRecipientList("addr_bcc"); + msgCompFields.replyTo = getRecipientList("addr_reply"); + msgCompFields.newsgroups = getRecipientList("addr_newsgroups"); + msgCompFields.followupTo = getRecipientList("addr_followup"); +} + +/** + * Replace the specified address row's pills with new ones generated by the + * given header value. The address row will be automatically shown if the header + * value is non-empty. + * + * @param {string} rowId - The id of the address row to set. + * @param {string} headerValue - The headerValue to create pills from. + * @param {boolean} multi - If the headerValue contains potentially multiple + * addresses and needs to be parsed to extract them. + * @param {boolean} [forceShow=false] - Whether to show the row, even if the + * given value is empty. + */ +function setAddressRowFromCompField( + rowId, + headerValue, + multi, + forceShow = false +) { + let row = document.getElementById(rowId); + addressRowClearPills(row); + + let value = multi + ? MailServices.headerParser.parseEncodedHeaderW(headerValue).join(", ") + : headerValue; + + if (value || forceShow) { + addressRowSetVisibility(row, true); + } + if (value) { + let input = row.querySelector(".address-row-input"); + input.value = value; + recipientAddPills(input, true); + } +} + +/** + * Convert all the recipients coming from a message header into pills. + * + * @param {object} msgCompFields - An object containing all the recipients. If + * any property is not a string, it is ignored. + */ +function CompFields2Recipients(msgCompFields) { + if (msgCompFields) { + // Populate all the recipients with the proper values. + if (typeof msgCompFields.replyTo == "string") { + setAddressRowFromCompField( + "addressRowReply", + msgCompFields.replyTo, + true + ); + } + + if (typeof msgCompFields.to == "string") { + setAddressRowFromCompField("addressRowTo", msgCompFields.to, true); + } + + if (typeof msgCompFields.cc == "string") { + setAddressRowFromCompField( + "addressRowCc", + msgCompFields.cc, + true, + gCurrentIdentity.doCc + ); + } + + if (typeof msgCompFields.bcc == "string") { + setAddressRowFromCompField( + "addressRowBcc", + msgCompFields.bcc, + true, + gCurrentIdentity.doBcc + ); + } + + if (typeof msgCompFields.newsgroups == "string") { + setAddressRowFromCompField( + "addressRowNewsgroups", + msgCompFields.newsgroups, + false + ); + } + + if (typeof msgCompFields.followupTo == "string") { + setAddressRowFromCompField( + "addressRowFollowup", + msgCompFields.followupTo, + true + ); + } + + // Add the sender to our spell check ignore list. + if (gCurrentIdentity) { + addRecipientsToIgnoreList(gCurrentIdentity.fullAddress); + } + + // Trigger this method only after all the pills have been created. + onRecipientsChanged(true); + } +} + +/** + * Update the recipients area UI to show News related fields and hide + * Mail related fields. + */ +function updateUIforNNTPAccount() { + // Hide the `mail-primary-input` field row if no pills have been created. + let mailContainer = document + .querySelector(".mail-primary-input") + .closest(".address-container"); + if (mailContainer.querySelectorAll("mail-address-pill").length == 0) { + mailContainer + .closest(".address-row") + .querySelector(".remove-field-button") + .click(); + } + + // Show the closing label. + mailContainer + .closest(".address-row") + .querySelector(".remove-field-button").hidden = false; + + // Show the `news-primary-input` field row if not already visible. + let newsContainer = document + .querySelector(".news-primary-input") + .closest(".address-row"); + showAndFocusAddressRow(newsContainer.id); + + // Hide the closing label. + newsContainer.querySelector(".remove-field-button").hidden = true; + + // Prefer showing the buttons for news-show-row-menuitem items. + for (let item of document.querySelectorAll(".news-show-row-menuitem")) { + showAddressRowMenuItemSetPreferButton(item, true); + } + + for (let item of document.querySelectorAll(".mail-show-row-menuitem")) { + showAddressRowMenuItemSetPreferButton(item, false); + } +} + +/** + * Update the recipients area UI to show Mail related fields and hide + * News related fields. This method is called only if the UI was previously + * updated to accommodate a News account type. + */ +function updateUIforMailAccount() { + // Show the `mail-primary-input` field row if not already visible. + let mailContainer = document + .querySelector(".mail-primary-input") + .closest(".address-row"); + showAndFocusAddressRow(mailContainer.id); + + // Hide the closing label. + mailContainer.querySelector(".remove-field-button").hidden = true; + + // Hide the `news-primary-input` field row if no pills have been created. + let newsContainer = document + .querySelector(".news-primary-input") + .closest(".address-row"); + if (newsContainer.querySelectorAll("mail-address-pill").length == 0) { + newsContainer.querySelector(".remove-field-button").click(); + } + + // Show the closing label. + newsContainer.querySelector(".remove-field-button").hidden = false; + + // Prefer showing the buttons for mail-show-row-menuitem items. + for (let item of document.querySelectorAll(".mail-show-row-menuitem")) { + showAddressRowMenuItemSetPreferButton(item, true); + } + + for (let item of document.querySelectorAll(".news-show-row-menuitem")) { + showAddressRowMenuItemSetPreferButton(item, false); + } +} + +/** + * Remove recipient pills from a specific addressing field based on full address + * matching. This is commonly used to clear previous Auto-CC/BCC recipients when + * loading a new identity. + * + * @param {object} msgCompFields - gMsgCompose.compFields, for helper functions. + * @param {string} recipientType - The type of recipients to remove, + * e.g. "addr_to" (recipient label id). + * @param {string} recipientsList - Comma-separated string containing recipients + * to be removed. May contain display names, and other commas therein. We only + * remove first exact match (full address). + */ +function awRemoveRecipients(msgCompFields, recipientType, recipientsList) { + if (!recipientType || !recipientsList) { + return; + } + + let container; + switch (recipientType) { + case "addr_cc": + container = document.getElementById("ccAddrContainer"); + break; + case "addr_bcc": + container = document.getElementById("bccAddrContainer"); + break; + case "addr_reply": + container = document.getElementById("replyAddrContainer"); + break; + case "addr_to": + container = document.getElementById("toAddrContainer"); + break; + } + + // Convert csv string of recipients to be deleted into full addresses array. + let recipientsArray = msgCompFields.splitRecipients(recipientsList, false); + + // Remove first instance of specified recipients from specified container. + for (let recipientFullAddress of recipientsArray) { + let pill = container.querySelector( + `mail-address-pill[fullAddress="${recipientFullAddress}"]` + ); + if (pill) { + pill.remove(); + } + } + + let addressRow = container.closest(`.address-row`); + + // Remove entire address row if empty, no user input, and not type "addr_to". + if ( + recipientType != "addr_to" && + !container.querySelector(`mail-address-pill`) && + !container.querySelector(`input[is="autocomplete-input"]`).value + ) { + addressRowSetVisibility(addressRow, false); + } + + updateAriaLabelsOfAddressRow(addressRow); +} + +/** + * Adds a batch of new rows matching recipientType and drops in the list of addresses. + * + * @param msgCompFields A nsIMsgCompFields object that is only used as a helper, + * it will not get the addresses appended. + * @param recipientType Type of recipient, e.g. "addr_to". + * @param recipientList A string of addresses to add. + */ +function awAddRecipients(msgCompFields, recipientType, recipientsList) { + if (!msgCompFields || !recipientsList) { + return; + } + + addressRowAddRecipientsArray( + document.querySelector( + `.address-row[data-recipienttype="${recipientType}"]` + ), + msgCompFields.splitRecipients(recipientsList, false) + ); +} + +/** + * Adds a batch of new recipient pill matching recipientType and drops in the + * array of addresses. + * + * @param {Element} row - The row to add the addresses to. + * @param {string[]} addressArray - Recipient addresses (strings) to add. + * @param {boolean=false} select - If the newly generated pills should be + * selected. + */ +function addressRowAddRecipientsArray(row, addressArray, select = false) { + let addresses = []; + for (let addr of addressArray) { + addresses.push(...MailServices.headerParser.makeFromDisplayAddress(addr)); + } + + if (row.classList.contains("hidden")) { + showAndFocusAddressRow(row.id, true); + } + + let recipientArea = document.getElementById("recipientsContainer"); + let input = row.querySelector(".address-row-input"); + for (let address of addresses) { + let pill = recipientArea.createRecipientPill(input, address); + if (select) { + pill.setAttribute("selected", "selected"); + } + } + + row + .querySelector(".address-container") + .classList.add("addressing-field-edited"); + + // Add the recipients to our spell check ignore list. + addRecipientsToIgnoreList(addressArray.join(", ")); + updateAriaLabelsOfAddressRow(row); + + if (row.id != "addressRowReply") { + onRecipientsChanged(); + } +} + +/** + * Find the autocomplete input when an address is dropped in the compose header. + * + * @param {XULElement} target - The element where an address was dropped. + * @param {string} recipient - The email address dragged by the user. + */ +function DropRecipient(target, recipient) { + let row; + if (target.classList.contains("address-row")) { + row = target; + } else if (target.dataset.addressRow) { + row = document.getElementById(target.dataset.addressRow); + } else { + row = target.closest(".address-row"); + } + if (!row || row.classList.contains("address-row-raw")) { + return; + } + + addressRowAddRecipientsArray(row, [recipient]); +} + +// Returns the load context for the current window +function getLoadContext() { + return window.docShell.QueryInterface(Ci.nsILoadContext); +} + +/** + * Focus the next available address row's input. Otherwise, focus the "Subject" + * input. + * + * @param {Element} currentInput - The current input to search from. + */ +function focusNextAddressRow(currentInput) { + let addressRow = currentInput.closest(".address-row").nextElementSibling; + while (addressRow) { + if (focusAddressRowInput(addressRow)) { + return; + } + addressRow = addressRow.nextElementSibling; + } + focusSubjectInput(); +} + +/** + * Handle keydown events for other header input fields in the compose window. + * Only applies to rows created from mail.compose.other.header pref; no pills. + * Keep behaviour in sync with addressInputOnBeforeHandleKeyDown(). + * + * @param {Event} event - The DOM keydown event. + */ +function otherHeaderInputOnKeyDown(event) { + let input = event.target; + + switch (event.key) { + case " ": + // If the existing input value is empty string or whitespace only, + // prevent entering space and clear whitespace-only input text. + if (!input.value.trim()) { + event.preventDefault(); + input.value = ""; + } + break; + + case "Enter": + // Break if modifier keys were used, to prevent hijacking unrelated + // keyboard shortcuts like Ctrl/Cmd+[Shift]+Enter for sending. + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + break; + } + + // Enter was pressed: Focus the next available address row or subject. + // Prevent Enter from firing again on the element we move the focus to. + event.preventDefault(); + focusNextAddressRow(input); + break; + + case "Backspace": + case "Delete": + if (event.repeat && gPreventRowDeletionKeysRepeat) { + // Prevent repeated deletion keydown event if the flag is set. + event.preventDefault(); + break; + } + // Enable repeated deletion in case of a non-repeated deletion keydown + // event, or if the flag is already false. + gPreventRowDeletionKeysRepeat = false; + + if ( + !event.repeat || + input.value.trim() || + input.selectionStart + input.selectionEnd || + input + .closest(".address-row") + .querySelector(".remove-field-button[hidden]") || + event.altKey + ) { + // Break if it is not a long deletion keypress, input still has text, + // or cursor selection is not at position 0 while deleting whitespace, + // to allow regular text deletion before we remove the row. + // Also break for non-removable rows with hidden [x] button, and if Alt + // key is pressed, to avoid interfering with undo shortcut Alt+Backspace. + break; + } + // Prevent event and set flag to prevent further unwarranted deletion in + // the adjacent row, which will receive focus while the key is still down. + event.preventDefault(); + gPreventRowDeletionKeysRepeat = true; + + // Hide the address row if it is empty except whitespace, repeated + // deletion keydown event occurred, and it has an [x] button for removal. + hideAddressRowFromWithin( + input, + event.key == "Backspace" ? "previous" : "next" + ); + break; + } +} + +/** + * Handle keydown events for autocomplete address inputs in the compose window. + * Does not apply to rows created from mail.compose.other.header pref, which are + * handled with a subset of this function in otherHeaderInputOnKeyDown(). + * + * @param {Event} event - The DOM keydown event. + */ +function addressInputOnBeforeHandleKeyDown(event) { + let input = event.target; + + switch (event.key) { + case "a": + // Break if there's text in the input, if not Ctrl/Cmd+A, or for other + // modifiers, to not hijack our own (Ctrl/Cmd+Shift+A) or OS shortcuts. + if ( + input.value || + !(AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey) || + event.shiftKey || + event.altKey + ) { + break; + } + + // Ctrl/Cmd+A on empty input: Select all pills of the current row. + // Prevent a pill keypress event when the focus moves on it. + event.preventDefault(); + + let lastPill = input + .closest(".address-container") + .querySelector("mail-address-pill:last-of-type"); + let mailRecipientsArea = input.closest("mail-recipients-area"); + if (lastPill) { + // Select all pills of current address row. + mailRecipientsArea.selectSiblingPills(lastPill); + lastPill.focus(); + break; + } + // No pills in the current address row, select all pills in all rows. + let lastPillGlobal = mailRecipientsArea.querySelector( + "mail-address-pill:last-of-type" + ); + if (lastPillGlobal) { + mailRecipientsArea.selectAllPills(); + lastPillGlobal.focus(); + } + break; + + case " ": + case ",": + let selection = input.value.substring( + input.selectionStart, + input.selectionEnd + ); + + // If keydown would normally replace all of the current trimmed input, + // including if the current input is empty, then suppress the key and + // clear the input instead. + if (selection.includes(input.value.trim())) { + event.preventDefault(); + input.value = ""; + break; + } + + // Otherwise, comma may trigger pill creation. + if (event.key !== ",") { + break; + } + + let beforeComma; + let afterComma; + if (input.selectionEnd == input.selectionStart) { + // If there is no selected text, we will try to create a pill for the + // text prior to the typed comma. + // NOTE: This also captures auto complete suggestions that are not + // inline. E.g. suggestion popup is shown and the user selects one with + // the arrow keys. + beforeComma = input.value.substring(0, input.selectionEnd); + afterComma = input.value.substring(input.selectionEnd); + // Only create a pill for valid addresses. + if (!isValidAddress(beforeComma)) { + break; + } + } else if ( + // There is an auto complete suggestion ... + input.controller.searchStatus == + Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH && + input.controller.matchCount && + // that is also shown inline (the end of the input is selected). + input.selectionEnd == input.value.length + // NOTE: This should exclude cases where no suggestion is selected (user + // presses "DownArrow" then "UpArrow" when the suggestion pops up), or + // if the suggestions were cancelled with "Esc", or the inline + // suggestion was cleared with "Backspace". + ) { + if (input.value[input.selectionStart] == ",") { + // Don't create the pill in the special case where the auto-complete + // suggestion starts with a comma. + break; + } + // Complete the suggestion as a pill. + beforeComma = input.value; + afterComma = ""; + } else { + // If any other part of the text is selected, we treat it as normal. + break; + } + + event.preventDefault(); + input.value = beforeComma; + input.handleEnter(event); + // Keep any left over text in the input. + input.value = afterComma; + // Keep the cursor at the same position. + input.selectionStart = 0; + input.selectionEnd = 0; + break; + + case "Home": + case "ArrowLeft": + case "Backspace": + if ( + event.key == "Backspace" && + event.repeat && + gPreventRowDeletionKeysRepeat + ) { + // Prevent repeated backspace keydown event if the flag is set. + event.preventDefault(); + break; + } + // Enable repeated deletion if Home or ArrowLeft were pressed, or if it is + // a non-repeated Backspace keydown event, or if the flag is already false. + gPreventRowDeletionKeysRepeat = false; + + if ( + input.value.trim() || + input.selectionStart + input.selectionEnd || + event.altKey + ) { + // Break and allow the key's default behavior if the row has content, + // or the cursor is not at position 0, or the Alt modifier is pressed. + break; + } + // Navigate into pills if there are any, and if the input is empty or + // whitespace-only, and the cursor is at position 0, and the Alt key was + // not used (prevent undo via Alt+Backspace from deleting pills). + // We'll sanitize whitespace on blur. + + // Prevent a pill keypress event when the focus moves on it, or prevent + // deletion in previous row after removing current row via long keydown. + event.preventDefault(); + + let targetPill = input + .closest(".address-container") + .querySelector( + "mail-address-pill" + (event.key == "Home" ? "" : ":last-of-type") + ); + if (targetPill) { + if (event.repeat) { + // Prevent navigating into pills for repeated keydown from the middle + // of whitespace. + break; + } + input + .closest("mail-recipients-area") + .checkKeyboardSelected(event, targetPill); + // Prevent removing the current row after deleting the last pill with + // repeated deletion keydown. + gPreventRowDeletionKeysRepeat = true; + break; + } + + // No pill found, so the address row is empty except whitespace. + // Check for long Backspace keyboard shortcut to remove the row. + if ( + event.key != "Backspace" || + !event.repeat || + input + .closest(".address-row") + .querySelector(".remove-field-button[hidden]") + ) { + break; + } + // Set flag to prevent further unwarranted deletion in the previous row, + // which will receive focus while the key is still down. We have already + // prevented the event above. + gPreventRowDeletionKeysRepeat = true; + + // Hide the address row if it is empty except whitespace, repeated + // Backspace keydown event occurred, and it has an [x] button for removal. + hideAddressRowFromWithin(input, "previous"); + break; + + case "Delete": + if (event.repeat && gPreventRowDeletionKeysRepeat) { + // Prevent repeated Delete keydown event if the flag is set. + event.preventDefault(); + break; + } + // Enable repeated deletion in case of a non-repeated Delete keydown event, + // or if the flag is already false. + gPreventRowDeletionKeysRepeat = false; + + if ( + !event.repeat || + input.value.trim() || + input.selectionStart + input.selectionEnd || + input + .closest(".address-container") + .querySelector("mail-address-pill") || + input + .closest(".address-row") + .querySelector(".remove-field-button[hidden]") + ) { + // Break and allow the key's default behaviour if the address row has + // content, or the cursor is not at position 0, or the row is not + // removable. + break; + } + // Prevent the event and set flag to prevent further unwarranted deletion + // in the next row, which will receive focus while the key is still down. + event.preventDefault(); + gPreventRowDeletionKeysRepeat = true; + + // Hide the address row if it is empty except whitespace, repeated Delete + // keydown event occurred, cursor is at position 0, and it has an + // [x] button for removal. + hideAddressRowFromWithin(input, "next"); + break; + + case "Enter": + // Break if unrelated modifier keys are used. The toolkit hack for Mac + // will consume metaKey, and we'll exclude shiftKey after that. + if (event.ctrlKey || event.altKey) { + break; + } + + // MacOS-only variation necessary to send messages via Cmd+[Shift]+Enter + // since autocomplete input fields prevent that by default (bug 1682147). + if (event.metaKey) { + // Cmd+[Shift]+Enter: Send message [later]. + let sendCmd = event.shiftKey ? "cmd_sendLater" : "cmd_sendWithCheck"; + goDoCommand(sendCmd); + break; + } + + // Break if there's text in the address input, or if Shift modifier is + // used, to prevent hijacking shortcuts like Ctrl+Shift+Enter. + if (input.value.trim() || event.shiftKey) { + break; + } + + // Enter on empty input: Focus the next available address row or subject. + // Prevent Enter from firing again on the element we move the focus to. + event.preventDefault(); + focusNextAddressRow(input); + break; + + case "Tab": + // Return if the Alt or Cmd modifiers were pressed, meaning the user is + // switching between windows and not tabbing out of the address input. + if (event.altKey || event.metaKey) { + break; + } + // Trigger the autocomplete controller only if we have a value, + // to prevent interfering with the natural change of focus on Tab. + if (input.value.trim()) { + // Prevent Tab from firing again on address input after pill creation. + event.preventDefault(); + + // Use the setTimeout only if the input field implements a forced + // autocomplete and we don't have any match as we might need to wait for + // the autocomplete suggestions to show up. + if (input.forceComplete && input.mController.matchCount == 0) { + // Prevent fast user input to become an error pill before + // autocompletion kicks in with its default timeout. + setTimeout(() => { + input.handleEnter(event); + }, input.timeout); + } else { + input.handleEnter(event); + } + } + + // Handle Shift+Tab, but not Ctrl+Shift+Tab, which is handled by + // moveFocusToNeighbouringAreas. + if (event.shiftKey && !event.ctrlKey) { + event.preventDefault(); + input.closest("mail-recipients-area").moveFocusToPreviousElement(input); + } + break; + } +} + +/** + * Handle input events for all types of address inputs in the compose window. + * + * @param {Event} event - A DOM input event. + * @param {boolean} rawInput - A flag for plain text inputs created via + * mail.compose.other.header, which do not have autocompletion and pills. + */ +function addressInputOnInput(event, rawInput) { + let input = event.target; + + if ( + !input.value || + (!input.value.trim() && + input.selectionStart + input.selectionEnd == 0 && + event.inputType == "deleteContentBackward") + ) { + // Temporarily disable repeated deletion to prevent premature + // removal of the current row if input text has just become empty or + // whitespace-only with cursor at position 0 from backwards deletion. + gPreventRowDeletionKeysRepeat = true; + } + + if (rawInput) { + // For raw inputs, we are done. + return; + } + // Now handling only autocomplete inputs. + + // Trigger onRecipientsChanged() for every input text change in order + // to properly update the "Send" button and trigger the save as draft + // prompt even before the creation of any pill. + onRecipientsChanged(); + + // Change the min size of the input field on input change only if the + // current width is smaller than 80% of its container's width + // to prevent overflow. + if ( + input.clientWidth < + input.closest(".address-container").clientWidth * 0.8 + ) { + document + .getElementById("recipientsContainer") + .resizeInputField(input, input.value.trim().length); + } +} + +/** + * Add one or more <mail-address-pill> elements to the containing address row. + * + * @param {Element} input - Address input where "autocomplete-did-enter-text" + * was observed, and/or to whose containing address row pill(s) will be added. + * @param {boolean} [automatic=false] - Set to true if the change of recipients + * was invoked programmatically and should not be considered a change of + * message content. + */ +function recipientAddPills(input, automatic = false) { + if (!input.value.trim()) { + return; + } + + let addresses = MailServices.headerParser.makeFromDisplayAddress(input.value); + let recipientArea = document.getElementById("recipientsContainer"); + + for (let address of addresses) { + recipientArea.createRecipientPill(input, address); + } + + // Add the just added recipient address(es) to the spellcheck ignore list. + addRecipientsToIgnoreList(input.value.trim()); + + // Reset the input element. + input.removeAttribute("nomatch"); + input.setAttribute("size", 1); + input.value = ""; + + // We need to detach the autocomplete Controller to prevent the input + // to be filled with the previously selected address when the "blur" event + // gets triggered. + input.detachController(); + // If it was detached, attach it again to enable autocomplete. + if (!input.controller.input) { + input.attachController(); + } + + // Prevent triggering some methods if the pill creation was done automatically + // for example during the move of an existing pill between addressing fields. + if (!automatic) { + input + .closest(".address-container") + .classList.add("addressing-field-edited"); + onRecipientsChanged(); + } + + updateAriaLabelsOfAddressRow(input.closest(".address-row")); +} + +/** + * Remove all <mail-address-pill> elements from the containing address row. + * + * @param {Element} row - The address row to clear. + */ +function addressRowClearPills(row) { + for (let pill of row.querySelectorAll( + ".address-container mail-address-pill" + )) { + pill.remove(); + } + updateAriaLabelsOfAddressRow(row); +} + +/** + * Handle focus event of address inputs: Force a focused styling on the closest + * address container of the currently focused input element. + * + * @param {Element} input - The address input element receiving focus. + */ +function addressInputOnFocus(input) { + input.closest(".address-container").setAttribute("focused", "true"); +} + +/** + * Handle blur event of address inputs: Remove focused styling from the closest + * address container and create address pills if valid recipients were written. + * + * @param {Element} input - The input element losing focus. + */ +function addressInputOnBlur(input) { + input.closest(".address-container").removeAttribute("focused"); + + // If the input is still the active element after blur (when switching to + // another window), return to prevent autocompletion and pillification + // and let the user continue editing the address later where he left. + if (document.activeElement == input) { + return; + } + + // For other headers aka raw input, trim and we are done. + if (input.getAttribute("is") != "autocomplete-input") { + input.value = input.value.trim(); + return; + } + + let address = input.value.trim(); + if (!address) { + // If input is empty or whitespace only, clear input to remove any leftover + // whitespace, reset the input size, and return. + input.value = ""; + input.setAttribute("size", 1); + return; + } + + if (input.forceComplete && input.mController.matchCount >= 1) { + // If input.forceComplete is true and there are autocomplete matches, + // we need to call the inbuilt Enter handler to force the input text + // to the best autocomplete match because we've set input._dontBlur. + input.mController.handleEnter(true); + return; + } + + // Otherwise, try to parse the input text as comma-separated recipients and + // convert them into recipient pills. + let listNames = MimeParser.parseHeaderField( + address, + MimeParser.HEADER_ADDRESS + ); + let isMailingList = + listNames.length > 0 && + MailServices.ab.mailListNameExists(listNames[0].name); + + if ( + address && + (isValidAddress(address) || + isMailingList || + input.classList.contains("news-input")) + ) { + recipientAddPills(input); + } + + // Trim any remaining input for which we didn't create a pill. + if (input.value.trim()) { + input.value = input.value.trim(); + } +} + +/** + * Trigger the startEditing() method of the mail-address-pill element. + * + * @param {XULlement} element - The element from which the context menu was + * opened. + * @param {Event} event - The DOM event. + */ +function editAddressPill(element, event) { + document + .getElementById("recipientsContainer") + .startEditing(element.closest("mail-address-pill"), event); +} + +/** + * Expands all the selected mailing list pills into their composite addresses. + * + * @param {XULlement} element - The element from which the context menu was + * opened. + */ +function expandList(element) { + let pill = element.closest("mail-address-pill"); + if (pill.isMailList) { + let addresses = []; + for (let currentPill of pill.parentNode.querySelectorAll( + "mail-address-pill" + )) { + if (currentPill == pill) { + let dir = MailServices.ab.getDirectory(pill.listURI); + if (dir) { + for (let card of dir.childCards) { + addresses.push(makeMailboxObjectFromCard(card)); + } + } + } else { + addresses.push(currentPill.fullAddress); + } + } + let row = pill.closest(".address-row"); + addressRowClearPills(row); + addressRowAddRecipientsArray(row, addresses, false); + } +} + +/** + * Handle the disabling of context menu items according to the types and count + * of selected pills. + * + * @param {Event} event - The DOM Event. + */ +function onPillPopupShowing(event) { + let menu = event.target; + // Reset previously hidden menuitems. + for (let menuitem of menu.querySelectorAll( + ".pill-action-move, .pill-action-edit" + )) { + menuitem.hidden = false; + } + + let recipientsContainer = document.getElementById("recipientsContainer"); + + // Check if the pill where the context menu was originated is not selected. + let pill = event.explicitOriginalTarget.closest("mail-address-pill"); + if (!pill.hasAttribute("selected")) { + recipientsContainer.deselectAllPills(); + pill.setAttribute("selected", "selected"); + } + + let allSelectedPills = recipientsContainer.getAllSelectedPills(); + // If more than one pill is selected, hide the editing item. + if (recipientsContainer.getAllSelectedPills().length > 1) { + menu.querySelector("#editAddressPill").hidden = true; + } + + // Update the recipient type in the menu label of #menu_selectAllSiblingPills. + let type = pill + .closest(".address-row") + .querySelector(".address-label-container > label").value; + document.l10n.setAttributes( + menu.querySelector("#menu_selectAllSiblingPills"), + "pill-action-select-all-sibling-pills", + { type } + ); + + // Hide the `Expand List` menuitem and the preceding menuseparator if not all + // selected pills are mailing lists. + let isNotMailingList = [...allSelectedPills].some(pill => !pill.isMailList); + menu.querySelector("#expandList").hidden = isNotMailingList; + menu.querySelector("#pillContextBeforeExpandListSeparator").hidden = + isNotMailingList; + + // If any Newsgroup or Followup pill is selected, hide all move actions. + if ( + recipientsContainer.querySelector( + ":is(#addressRowNewsgroups, #addressRowFollowup) " + + "mail-address-pill[selected]" + ) + ) { + for (let menuitem of menu.querySelectorAll(".pill-action-move")) { + menuitem.hidden = true; + } + // Hide the menuseparator before the move items, as there's nothing below. + menu.querySelector("#pillContextBeforeMoveItemsSeparator").hidden = true; + return; + } + // Show the menuseparator before the move items as no Newsgroup or Followup + // pill is selected. + menu.querySelector("#pillContextBeforeMoveItemsSeparator").hidden = false; + + let selectedType = ""; + // Check if all selected pills are in the same address row. + for (let row of recipientsContainer.querySelectorAll( + ".address-row:not(.hidden)" + )) { + // Check if there's at least one selected pill in the address row. + let selectedPill = row.querySelector("mail-address-pill[selected]"); + if (!selectedPill) { + continue; + } + // Return if we already have a selectedType: More than one type selected. + if (selectedType) { + return; + } + selectedType = row.dataset.recipienttype; + } + + // All selected pills are of the same type, hide the type's move action. + switch (selectedType) { + case "addr_to": + menu.querySelector("#moveAddressPillTo").hidden = true; + break; + + case "addr_cc": + menu.querySelector("#moveAddressPillCc").hidden = true; + break; + + case "addr_bcc": + menu.querySelector("#moveAddressPillBcc").hidden = true; + break; + } +} + +/** + * Show the specified address row and focus its input. If showing the address + * row is disabled, the focus is not changed. + * + * @param {string} rowId - The id of the row to show. + */ +function showAndFocusAddressRow(rowId) { + let row = document.getElementById(rowId); + if (addressRowSetVisibility(row, true)) { + row.querySelector(".address-row-input").focus(); + } +} + +/** + * Set the visibility of an address row (Cc, Bcc, etc.). + * + * @param {Element} row - The address row. + * @param {boolean} [show=true] - Whether to show the row or hide it. + * + * @returns {boolean} - Whether the visibility was set. + */ +function addressRowSetVisibility(row, show) { + let menuItem = document.getElementById(row.dataset.showSelfMenuitem); + if (show && menuItem.hasAttribute("disabled")) { + return false; + } + + // Show/hide the row and hide/show the menuitem or button + row.classList.toggle("hidden", !show); + showAddressRowMenuItemSetVisibility(menuItem, !show); + return true; +} + +/** + * Set the visibility of a menu item that shows an address row. + * + * @param {Element} menuItem - The menu item. + * @param {boolean} [show=true] - Whether to show the item or hide it. + */ +function showAddressRowMenuItemSetVisibility(menuItem, show) { + let buttonId = menuItem.dataset.buttonId; + let button = buttonId && document.getElementById(buttonId); + if (button && menuItem.dataset.preferButton == "true") { + button.hidden = !show; + // Make sure the menuItem is never shown. + menuItem.hidden = true; + } else { + menuItem.hidden = !show; + if (button) { + button.hidden = true; + } + } + + updateRecipientsVisibility(); +} + +/** + * Set whether a menu item that shows an address row should prefer being + * displayed as the button specified by its "data-button-id" attribute, if it + * has one. + * + * @param {Element} menuItem - The menu item. + * @param {boolean} preferButton - Whether to prefer showing the button rather + * than the menu item. + */ +function showAddressRowMenuItemSetPreferButton(menuItem, preferButton) { + let buttonId = menuItem.dataset.buttonId; + if (!buttonId || menuItem.dataset.preferButton == String(preferButton)) { + return; + } + let button = document.getElementById(buttonId); + + menuItem.dataset.preferButton = preferButton; + if (preferButton) { + button.hidden = menuItem.hidden; + menuItem.hidden = true; + } else { + menuItem.hidden = button.hidden; + button.hidden = true; + } + + updateRecipientsVisibility(); +} + +/** + * Hide or show the menu button for the extra recipients based on the current + * hidden status of menuitems and buttons. + */ +function updateRecipientsVisibility() { + document.getElementById("extraAddressRowsMenuButton").hidden = + !document.querySelector("#extraAddressRowsMenu > :not([hidden])"); + + let buttonbox = document.getElementById("extraAddressRowsArea"); + // Toggle the class to show/hide the pseudo element separator + // of the msgIdentity field. + buttonbox.classList.toggle( + "addressingWidget-separator", + !!buttonbox.querySelector("button:not([hidden])") + ); +} + +/** + * Hide the container row of a recipient (Cc, Bcc, etc.). + * The container can't be hidden if previously typed addresses are listed. + * + * @param {Element} element - A descendant element of the row to be hidden (or + * the row itself), usually the [x] label when triggered, or an empty address + * input upon Backspace or Del keydown. + * @param {("next"|"previous")} [focusType="next"] - How to move focus after + * hiding the address row: try to focus the input of an available next sibling + * row (for [x] or DEL) or previous sibling row (for BACKSPACE). + */ +function hideAddressRowFromWithin(element, focusType = "next") { + let addressRow = element.closest(".address-row"); + + // Prevent address row removal when sending (disable-on-send). + if ( + addressRow + .querySelector(".address-container") + .classList.contains("disable-container") + ) { + return; + } + + let pills = addressRow.querySelectorAll("mail-address-pill"); + let isEdited = addressRow + .querySelector(".address-container") + .classList.contains("addressing-field-edited"); + + // Ask the user to confirm the removal of all the typed addresses if the field + // holds addressing pills and has been previously edited. + if (isEdited && pills.length) { + let fieldName = addressRow.querySelector( + ".address-label-container > label" + ); + let confirmTitle = getComposeBundle().getFormattedString( + "confirmRemoveRecipientRowTitle2", + [fieldName.value] + ); + let confirmBody = getComposeBundle().getFormattedString( + "confirmRemoveRecipientRowBody2", + [fieldName.value] + ); + let confirmButton = getComposeBundle().getString( + "confirmRemoveRecipientRowButton" + ); + + let result = Services.prompt.confirmEx( + window, + confirmTitle, + confirmBody, + Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL, + confirmButton, + null, + null, + null, + {} + ); + if (result == 1) { + return; + } + } + + for (let pill of pills) { + pill.remove(); + } + + // Reset the original input. + let input = addressRow.querySelector(".address-row-input"); + input.value = ""; + + addressRowSetVisibility(addressRow, false); + + // Update the Send button only if the content was previously changed. + if (isEdited) { + onRecipientsChanged(true); + } + updateAriaLabelsOfAddressRow(addressRow); + + // Move focus to the next focusable address input field. + let addressRowSibling = + focusType == "next" + ? getNextSibling(addressRow, ".address-row:not(.hidden)") + : getPreviousSibling(addressRow, ".address-row:not(.hidden)"); + + if (addressRowSibling) { + addressRowSibling.querySelector(".address-row-input").focus(); + return; + } + // Otherwise move focus to the subject field or to the first available input. + let fallbackFocusElement = + focusType == "next" + ? document.getElementById("msgSubject") + : getNextSibling(addressRow, ".address-row:not(.hidden)").querySelector( + ".address-row-input" + ); + fallbackFocusElement.focus(); +} + +/** + * Handle the click event on the close label of an address row. + * + * @param {Event} event - The DOM click event. + */ +function closeLabelOnClick(event) { + hideAddressRowFromWithin(event.target); +} + +function extraAddressRowsMenuOpened() { + document + .getElementById("extraAddressRowsMenuButton") + .setAttribute("aria-expanded", "true"); +} + +function extraAddressRowsMenuClosed() { + document + .getElementById("extraAddressRowsMenuButton") + .setAttribute("aria-expanded", "false"); +} + +/** + * Show the menu for extra address rows (extraAddressRowsMenu). + */ +function openExtraAddressRowsMenu() { + let button = document.getElementById("extraAddressRowsMenuButton"); + let menu = document.getElementById("extraAddressRowsMenu"); + // NOTE: menu handlers handle the aria-expanded state of the button. + menu.openPopup(button, "after_end", 8, 0); +} diff --git a/comm/mail/components/compose/content/bigFileObserver.js b/comm/mail/components/compose/content/bigFileObserver.js new file mode 100644 index 0000000000..f741af7afa --- /dev/null +++ b/comm/mail/components/compose/content/bigFileObserver.js @@ -0,0 +1,368 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozElements */ + +/* import-globals-from MsgComposeCommands.js */ + +var { cloudFileAccounts } = ChromeUtils.import( + "resource:///modules/cloudFileAccounts.jsm" +); + +var kUploadNotificationValue = "bigAttachmentUploading"; +var kPrivacyWarningNotificationValue = "bigAttachmentPrivacyWarning"; + +var gBigFileObserver = { + bigFiles: [], + sessionHidden: false, + privacyWarned: false, + + get hidden() { + return ( + this.sessionHidden || + !Services.prefs.getBoolPref("mail.cloud_files.enabled") || + !Services.prefs.getBoolPref("mail.compose.big_attachments.notify") || + Services.io.offline + ); + }, + + hide(aPermanent) { + if (aPermanent) { + Services.prefs.setBoolPref("mail.compose.big_attachments.notify", false); + } else { + this.sessionHidden = true; + } + }, + + init() { + let bucket = document.getElementById("attachmentBucket"); + bucket.addEventListener("attachments-added", this); + bucket.addEventListener("attachments-removed", this); + bucket.addEventListener("attachment-converted-to-regular", this); + bucket.addEventListener("attachment-uploading", this); + bucket.addEventListener("attachment-uploaded", this); + bucket.addEventListener("attachment-upload-failed", this); + + this.sessionHidden = false; + this.privacyWarned = false; + this.bigFiles = []; + }, + + handleEvent(event) { + if (this.hidden) { + return; + } + + switch (event.type) { + case "attachments-added": + this.bigFileTrackerAdd(event.detail); + break; + case "attachments-removed": + this.bigFileTrackerRemove(event.detail); + this.checkAndHidePrivacyNotification(); + break; + case "attachment-converted-to-regular": + this.checkAndHidePrivacyNotification(); + break; + case "attachment-uploading": + // Remove the currently uploading item from bigFiles, to remove the big + // file notification already during upload. + this.bigFileTrackerRemove([event.detail]); + this.updateUploadingNotification(); + break; + case "attachment-upload-failed": + this.updateUploadingNotification(); + break; + case "attachment-uploaded": + this.updateUploadingNotification(); + if (this.uploadsInProgress == 0) { + this.showPrivacyNotification(); + } + break; + default: + // Do not update the notification for other events. + return; + } + + this.updateBigFileNotification(); + }, + + bigFileTrackerAdd(aAttachments) { + let threshold = + Services.prefs.getIntPref("mail.compose.big_attachments.threshold_kb") * + 1024; + + for (let attachment of aAttachments) { + if (attachment.size >= threshold && !attachment.sendViaCloud) { + this.bigFiles.push(attachment); + } + } + }, + + bigFileTrackerRemove(aAttachments) { + for (let attachment of aAttachments) { + let index = this.bigFiles.findIndex(e => e.url == attachment.url); + if (index != -1) { + this.bigFiles.splice(index, 1); + } + } + }, + + formatString(key, replacements, plural) { + let str = getComposeBundle().getString(key); + if (plural !== undefined) { + str = PluralForm.get(plural, str); + } + if (replacements !== undefined) { + for (let i = 0; i < replacements.length; i++) { + str = str.replace("#" + (i + 1), replacements[i]); + } + } + return str; + }, + + updateBigFileNotification() { + let bigFileNotification = + gComposeNotification.getNotificationWithValue("bigAttachment"); + if (this.bigFiles.length) { + if (bigFileNotification) { + bigFileNotification.label = this.formatString( + "bigFileDescription", + [this.bigFiles.length], + this.bigFiles.length + ); + return; + } + + let buttons = [ + { + label: getComposeBundle().getString("learnMore.label"), + accessKey: getComposeBundle().getString("learnMore.accesskey"), + callback: this.openLearnMore.bind(this), + }, + { + label: this.formatString("bigFileShare.label", []), + accessKey: this.formatString("bigFileShare.accesskey"), + callback: this.convertAttachments.bind(this), + }, + { + label: this.formatString("bigFileAttach.label", []), + accessKey: this.formatString("bigFileAttach.accesskey"), + callback: this.hideBigFileNotification.bind(this), + }, + ]; + + let msg = this.formatString( + "bigFileDescription", + [this.bigFiles.length], + this.bigFiles.length + ); + + bigFileNotification = gComposeNotification.appendNotification( + "bigAttachment", + { + label: msg, + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + buttons + ); + } else if (bigFileNotification) { + gComposeNotification.removeNotification(bigFileNotification); + } + }, + + openLearnMore() { + let url = Services.prefs.getCharPref("mail.cloud_files.learn_more_url"); + openContentTab(url); + return true; + }, + + convertAttachments() { + let account; + let accounts = cloudFileAccounts.configuredAccounts; + + if (accounts.length == 1) { + account = accounts[0]; + } else if (accounts.length > 1) { + // We once used Services.prompt.select for this UI, but it doesn't support displaying an + // icon for each item. The following code does the same thing with a replacement dialog. + let { PromptUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromptUtils.sys.mjs" + ); + + let names = accounts.map(i => cloudFileAccounts.getDisplayName(i)); + let icons = accounts.map(i => i.iconURL); + let args = { + promptType: "select", + title: this.formatString("bigFileChooseAccount.title"), + text: this.formatString("bigFileChooseAccount.text"), + list: names, + icons, + selected: -1, + ok: false, + }; + + let propBag = PromptUtils.objectToPropBag(args); + openDialog( + "chrome://messenger/content/cloudfile/selectDialog.xhtml", + "_blank", + "centerscreen,chrome,modal,titlebar", + propBag + ); + PromptUtils.propBagToObject(propBag, args); + + if (args.ok) { + account = accounts[args.selected]; + } + } else { + openPreferencesTab("paneCompose", "compositionAttachmentsCategory"); + return true; + } + + if (account) { + convertToCloudAttachment(this.bigFiles, account); + } + + return false; + }, + + hideBigFileNotification() { + let never = {}; + if ( + Services.prompt.confirmCheck( + window, + this.formatString("bigFileHideNotification.title"), + this.formatString("bigFileHideNotification.text"), + this.formatString("bigFileHideNotification.check"), + never + ) + ) { + this.hide(never.value); + return false; + } + return true; + }, + + updateUploadingNotification() { + // We will show the uploading notification for a minimum of 2.5 seconds + // seconds. + const kThreshold = 2500; // milliseconds + + if ( + !Services.prefs.getBoolPref( + "mail.compose.big_attachments.insert_notification" + ) + ) { + return; + } + + let activeUploads = this.uploadsInProgress; + let notification = gComposeNotification.getNotificationWithValue( + kUploadNotificationValue + ); + + if (activeUploads == 0) { + if (notification) { + // Check the timestamp that we stashed in the timeout field of the + // notification... + let now = Date.now(); + if (now >= notification.timeout) { + gComposeNotification.removeNotification(notification); + } else { + setTimeout(function () { + gComposeNotification.removeNotification(notification); + }, notification.timeout - now); + } + } + return; + } + + let message = this.formatString("cloudFileUploadingNotification"); + message = PluralForm.get(activeUploads, message); + + if (notification) { + notification.label = message; + return; + } + + let showUploadButton = { + accessKey: this.formatString( + "stopShowingUploadingNotification.accesskey" + ), + label: this.formatString("stopShowingUploadingNotification.label"), + callback(aNotificationBar, aButton) { + Services.prefs.setBoolPref( + "mail.compose.big_attachments.insert_notification", + false + ); + }, + }; + notification = gComposeNotification.appendNotification( + kUploadNotificationValue, + { + label: message, + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + [showUploadButton] + ); + notification.timeout = Date.now() + kThreshold; + }, + + hidePrivacyNotification() { + this.privacyWarned = false; + let notification = gComposeNotification.getNotificationWithValue( + kPrivacyWarningNotificationValue + ); + + if (notification) { + gComposeNotification.removeNotification(notification); + } + }, + + checkAndHidePrivacyNotification() { + if ( + !gAttachmentBucket.itemChildren.find( + item => item.attachment && item.attachment.sendViaCloud + ) + ) { + this.hidePrivacyNotification(); + } + }, + + showPrivacyNotification() { + if (this.privacyWarned) { + return; + } + this.privacyWarned = true; + + let notification = gComposeNotification.getNotificationWithValue( + kPrivacyWarningNotificationValue + ); + + if (notification) { + return; + } + + let message = this.formatString("cloudFilePrivacyNotification"); + gComposeNotification.appendNotification( + kPrivacyWarningNotificationValue, + { + label: message, + priority: gComposeNotification.PRIORITY_WARNING_MEDIUM, + }, + null + ); + }, + + get uploadsInProgress() { + let items = [...document.getElementById("attachmentBucket").itemChildren]; + return items.filter(e => e.uploading).length; + }, +}; + +window.addEventListener( + "compose-window-init", + gBigFileObserver.init.bind(gBigFileObserver), + true +); diff --git a/comm/mail/components/compose/content/cloudAttachmentLinkManager.js b/comm/mail/components/compose/content/cloudAttachmentLinkManager.js new file mode 100644 index 0000000000..9693f1aa8d --- /dev/null +++ b/comm/mail/components/compose/content/cloudAttachmentLinkManager.js @@ -0,0 +1,758 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from MsgComposeCommands.js */ + +let { MsgUtils } = ChromeUtils.import( + "resource:///modules/MimeMessageUtils.jsm" +); + +var gCloudAttachmentLinkManager = { + init() { + this.cloudAttachments = []; + + let bucket = document.getElementById("attachmentBucket"); + bucket.addEventListener("attachments-removed", this); + bucket.addEventListener("attachment-converted-to-regular", this); + bucket.addEventListener("attachment-uploaded", this); + bucket.addEventListener("attachment-moved", this); + bucket.addEventListener("attachment-renamed", this); + + // If we're restoring a draft that has some attachments, + // check to see if any of them are marked to be sent via + // cloud, and if so, add them to our list. + for (let i = 0; i < bucket.getRowCount(); ++i) { + let attachment = bucket.getItemAtIndex(i).attachment; + if (attachment && attachment.sendViaCloud) { + this.cloudAttachments.push(attachment); + } + } + + gMsgCompose.RegisterStateListener(this); + }, + + NotifyComposeFieldsReady() {}, + NotifyComposeBodyReady() {}, + ComposeProcessDone() {}, + SaveInFolderDone() {}, + + async handleEvent(event) { + let mailDoc = document.getElementById("messageEditor").contentDocument; + + if ( + event.type == "attachment-renamed" || + event.type == "attachment-moved" + ) { + let cloudFileUpload = event.target.cloudFileUpload; + let items = []; + + let list = mailDoc.getElementById("cloudAttachmentList"); + if (list) { + items = list.getElementsByClassName("cloudAttachmentItem"); + } + + for (let item of items) { + // The original attachment is stored in the events detail property. + if (item.dataset.contentLocation == event.detail.contentLocation) { + item.replaceWith(await this._createNode(mailDoc, cloudFileUpload)); + } + } + if (event.type == "attachment-moved") { + await this._updateServiceProviderLinks(mailDoc); + } + } else if (event.type == "attachment-uploaded") { + if (this.cloudAttachments.length == 0) { + this._insertHeader(mailDoc); + } + + let cloudFileUpload = event.target.cloudFileUpload; + let attachment = event.target.attachment; + this.cloudAttachments.push(attachment); + await this._insertItem(mailDoc, cloudFileUpload); + } else if ( + event.type == "attachments-removed" || + event.type == "attachment-converted-to-regular" + ) { + let items = []; + let list = mailDoc.getElementById("cloudAttachmentList"); + if (list) { + items = list.getElementsByClassName("cloudAttachmentItem"); + } + + let attachments = Array.isArray(event.detail) + ? event.detail + : [event.detail]; + for (let attachment of attachments) { + // Remove the attachment from the message body. + if (list) { + for (let item of items) { + if (item.dataset.contentLocation == attachment.contentLocation) { + item.remove(); + } + } + } + + // Now, remove the attachment from our internal list. + let index = this.cloudAttachments.indexOf(attachment); + if (index != -1) { + this.cloudAttachments.splice(index, 1); + } + } + + await this._updateAttachmentCount(mailDoc); + await this._updateServiceProviderLinks(mailDoc); + + if (items.length == 0) { + if (list) { + list.remove(); + } + this._removeRoot(mailDoc); + } + } + }, + + /** + * Removes the root node for an attachment list in an HTML email. + * + * @param {Document} aDocument - the document to remove the root node from + */ + _removeRoot(aDocument) { + let header = aDocument.getElementById("cloudAttachmentListRoot"); + if (header) { + header.remove(); + } + }, + + /** + * Given some node, returns the textual HTML representation for the node + * and its children. + * + * @param {Document} aDocument - the document that the node is embedded in + * @param {DOMNode} aNode - the node to get the textual representation from + */ + _getHTMLRepresentation(aDocument, aNode) { + let tmp = aDocument.createElement("p"); + tmp.appendChild(aNode); + return tmp.innerHTML; + }, + + /** + * Returns the plain text equivalent of the given HTML markup, ready to be + * inserted into a compose editor. + * + * @param {string} aMarkup - the HTML markup that should be converted + */ + _getTextRepresentation(aMarkup) { + return MsgUtils.convertToPlainText(aMarkup, true).replaceAll("\r\n", "\n"); + }, + + /** + * Generates an appropriately styled link. + * + * @param {Document} aDocument - the document to append the link to - doesn't + * actually get appended, but is used to generate the anchor node + * @param {string} aContent - the textual content of the link + * @param {string} aHref - the HREF attribute for the generated link + * @param {string} aColor - the CSS color string for the link + */ + _generateLink(aDocument, aContent, aHref, aColor) { + let link = aDocument.createElement("a"); + link.href = aHref; + link.textContent = aContent; + link.style.cssText = `color: ${aColor} !important`; + return link; + }, + + _findInsertionPoint(aDocument) { + let mailBody = aDocument.querySelector("body"); + let editor = GetCurrentEditor(); + let selection = editor.selection; + + let childNodes = mailBody.childNodes; + let childToInsertAfter, childIndex; + + // First, search for any text nodes that are immediate children of + // the body. If we find any, we'll insert after those. + for (childIndex = childNodes.length - 1; childIndex >= 0; childIndex--) { + if (childNodes[childIndex].nodeType == Node.TEXT_NODE) { + childToInsertAfter = childNodes[childIndex]; + break; + } + } + + if (childIndex != -1) { + selection.collapse( + childToInsertAfter, + childToInsertAfter.nodeValue ? childToInsertAfter.nodeValue.length : 0 + ); + if ( + childToInsertAfter.nodeValue && + childToInsertAfter.nodeValue.length > 0 + ) { + editor.insertLineBreak(); + } + editor.insertLineBreak(); + return; + } + + // If there's a signature, let's get a hold of it now. + let signature = mailBody.querySelector(".moz-signature"); + + // Are we replying? + let replyCitation = mailBody.querySelector(".moz-cite-prefix"); + if (replyCitation) { + if (gCurrentIdentity && gCurrentIdentity.replyOnTop == 0) { + // Replying below quote - we'll select the point right before + // the signature. If there's no signature, we'll just use the + // last node. + if (signature && signature.previousSibling) { + selection.collapse( + mailBody, + Array.from(childNodes).indexOf(signature.previousSibling) + ); + } else { + selection.collapse(mailBody, childNodes.length - 1); + editor.insertLineBreak(); + + if (!gMsgCompose.composeHTML) { + editor.insertLineBreak(); + } + + selection.collapse(mailBody, childNodes.length - 2); + } + } else if (replyCitation.previousSibling) { + // Replying above quote + let nodeIndex = Array.from(childNodes).indexOf( + replyCitation.previousSibling + ); + if (nodeIndex <= 0) { + editor.insertLineBreak(); + nodeIndex = 1; + } + selection.collapse(mailBody, nodeIndex); + } else { + editor.beginningOfDocument(); + editor.insertLineBreak(); + } + return; + } + + // Are we forwarding? + let forwardBody = mailBody.querySelector(".moz-forward-container"); + if (forwardBody) { + if (forwardBody.previousSibling) { + let nodeIndex = Array.from(childNodes).indexOf( + forwardBody.previousSibling + ); + if (nodeIndex <= 0) { + editor.insertLineBreak(); + nodeIndex = 1; + } + // If we're forwarding, insert just before the forward body. + selection.collapse(mailBody, nodeIndex); + } else { + // Just insert after a linebreak at the top. + editor.beginningOfDocument(); + editor.insertLineBreak(); + selection.collapse(mailBody, 1); + } + return; + } + + // If we haven't figured it out at this point, let's see if there's a + // signature, and just insert before it. + if (signature && signature.previousSibling) { + let nodeIndex = Array.from(childNodes).indexOf(signature.previousSibling); + if (nodeIndex <= 0) { + editor.insertLineBreak(); + nodeIndex = 1; + } + selection.collapse(mailBody, nodeIndex); + return; + } + + // If we haven't figured it out at this point, let's just put it + // at the bottom of the message body. If the "bottom" is also the top, + // then we'll insert a linebreak just above it. + let nodeIndex = childNodes.length - 1; + if (nodeIndex <= 0) { + editor.insertLineBreak(); + nodeIndex = 1; + } + selection.collapse(mailBody, nodeIndex); + }, + + /** + * Attempts to find any elements with an id in aIDs, and sets those elements + * id attribute to the empty string, freeing up the ids for later use. + * + * @param {Document} aDocument - the document to search for the elements + * @param {string[]} aIDs - an array of id strings + */ + _resetNodeIDs(aDocument, aIDs) { + for (let id of aIDs) { + let node = aDocument.getElementById(id); + if (node) { + node.id = ""; + } + } + }, + + /** + * Insert the header for the cloud attachment list, which we'll use to + * as an insertion point for the individual cloud attachments. + * + * @param {Document} aDocument - the document to insert the header into + */ + _insertHeader(aDocument) { + // If there already exists a cloudAttachmentListRoot, + // cloudAttachmentListHeader, cloudAttachmentListFooter or + // cloudAttachmentList in the document, strip them of their IDs so that we + // don't conflict with them. + this._resetNodeIDs(aDocument, [ + "cloudAttachmentListRoot", + "cloudAttachmentListHeader", + "cloudAttachmentList", + "cloudAttachmentListFooter", + ]); + + let editor = GetCurrentEditor(); + let selection = editor.selection; + let originalAnchor = selection.anchorNode; + let originalOffset = selection.anchorOffset; + + // Save off the selection ranges so we can restore them later. + let ranges = []; + for (let i = 0; i < selection.rangeCount; i++) { + ranges.push(selection.getRangeAt(i)); + } + + this._findInsertionPoint(aDocument); + + let root = editor.createElementWithDefaults("div"); + let header = editor.createElementWithDefaults("div"); + let list = editor.createElementWithDefaults("div"); + let footer = editor.createElementWithDefaults("div"); + + if (gMsgCompose.composeHTML) { + root.style.padding = "15px"; + root.style.backgroundColor = "#D9EDFF"; + + header.style.marginBottom = "15px"; + + list = editor.createElementWithDefaults("ul"); + list.style.backgroundColor = "#FFFFFF"; + list.style.padding = "15px"; + list.style.listStyleType = "none"; + list.display = "inline-block"; + } + + root.id = "cloudAttachmentListRoot"; + header.id = "cloudAttachmentListHeader"; + list.id = "cloudAttachmentList"; + footer.id = "cloudAttachmentListFooter"; + + // It's really quite strange, but if we don't set + // the innerHTML of each element to be non-empty, then + // the nodes fail to be added to the compose window. + root.innerHTML = " "; + header.innerHTML = " "; + list.innerHTML = " "; + footer.innerHTML = " "; + + root.appendChild(header); + root.appendChild(list); + root.appendChild(footer); + editor.insertElementAtSelection(root, false); + if (!root.previousSibling || root.previousSibling.localName == "span") { + root.parentNode.insertBefore(editor.document.createElement("br"), root); + } + + // Remove the space, which would end up in the plain text converted + // version. + list.innerHTML = ""; + selection.collapse(originalAnchor, originalOffset); + + // Restore the selection ranges. + for (let range of ranges) { + selection.addRange(range); + } + }, + + /** + * Updates the count of how many attachments have been added + * in HTML emails. + * + * @param {Document} aDocument - the document that contains the header node + */ + async _updateAttachmentCount(aDocument) { + let header = aDocument.getElementById("cloudAttachmentListHeader"); + if (!header) { + return; + } + + let entries = aDocument.querySelectorAll( + "#cloudAttachmentList > .cloudAttachmentItem" + ); + + header.textContent = await l10nCompose.formatValue( + "cloud-file-count-header", + { + count: entries.length, + } + ); + }, + + /** + * Updates the service provider links in the footer. + * + * @param {Document} aDocument - the document that contains the footer node + */ + async _updateServiceProviderLinks(aDocument) { + let footer = aDocument.getElementById("cloudAttachmentListFooter"); + if (!footer) { + return; + } + + let providers = []; + let entries = aDocument.querySelectorAll( + "#cloudAttachmentList > .cloudAttachmentItem" + ); + for (let entry of entries) { + if (!entry.dataset.serviceUrl) { + continue; + } + + let link_markup = this._generateLink( + aDocument, + entry.dataset.serviceName, + entry.dataset.serviceUrl, + "dark-grey" + ).outerHTML; + + if (!providers.includes(link_markup)) { + providers.push(link_markup); + } + } + + let content = ""; + if (providers.length == 1) { + content = await l10nCompose.formatValue( + "cloud-file-service-provider-footer-single", + { + link: providers[0], + } + ); + } else if (providers.length > 1) { + let lastLink = providers.pop(); + let firstLinks = providers.join(", "); + content = await l10nCompose.formatValue( + "cloud-file-service-provider-footer-multiple", + { + firstLinks, + lastLink, + } + ); + } + + if (gMsgCompose.composeHTML) { + // eslint-disable-next-line no-unsanitized/property + footer.innerHTML = content; + } else { + footer.textContent = this._getTextRepresentation(content); + } + }, + + /** + * Insert the information for a cloud attachment. + * + * @param {Document} aDocument - the document to insert the item into + * @param {CloudFileTemplate} aCloudFileUpload - object with information about + * the uploaded file + */ + async _insertItem(aDocument, aCloudFileUpload) { + let list = aDocument.getElementById("cloudAttachmentList"); + + if (!list) { + this._insertHeader(aDocument); + list = aDocument.getElementById("cloudAttachmentList"); + } + list.appendChild(await this._createNode(aDocument, aCloudFileUpload)); + await this._updateAttachmentCount(aDocument); + await this._updateServiceProviderLinks(aDocument); + }, + + /** + * @typedef CloudFileDate + * @property {integer} timestamp - milliseconds since epoch + * @property {DateTimeFormat} format - format object of Intl.DateTimeFormat + */ + + /** + * @typedef CloudFileTemplate + * @property {string} serviceName - name of the upload service provider + * @property {string} serviceIcon - icon of the upload service provider + * @property {string} serviceUrl - web interface of the upload service provider + * @property {boolean} downloadPasswordProtected - link is password protected + * @property {integer} downloadLimit - download limit of the link + * @property {CloudFileDate} downloadExpiryDate - expiry date of the link + */ + + /** + * Create the link node for a cloud attachment. + * + * @param {Document} aDocument - the document to insert the item into + * @param {CloudFileTemplate} aCloudFileUpload - object with information about + * the uploaded file + * @param {boolean} composeHTML - override gMsgCompose.composeHTML + */ + async _createNode( + aDocument, + aCloudFileUpload, + composeHTML = gMsgCompose.composeHTML + ) { + const iconSize = 32; + const locales = { + service: 0, + size: 1, + link: 2, + "password-protected-link": 3, + "expiry-date": 4, + "download-limit": 5, + "tooltip-password-protected-link": 6, + }; + + let l10n_values = await l10nCompose.formatValues([ + { id: "cloud-file-template-service-name" }, + { id: "cloud-file-template-size" }, + { id: "cloud-file-template-link" }, + { id: "cloud-file-template-password-protected-link" }, + { id: "cloud-file-template-expiry-date" }, + { id: "cloud-file-template-download-limit" }, + { id: "cloud-file-tooltip-password-protected-link" }, + ]); + + let node = aDocument.createElement("li"); + node.style.border = "1px solid #CDCDCD"; + node.style.borderRadius = "5px"; + node.style.marginTop = "10px"; + node.style.marginBottom = "10px"; + node.style.padding = "15px"; + node.style.display = "grid"; + node.style.gridTemplateColumns = "0fr 1fr 0fr 0fr"; + node.style.alignItems = "center"; + + const statsRow = (name, content, contentLink) => { + let entry = aDocument.createElement("span"); + entry.style.gridColumn = `2 / span 3`; + entry.style.fontSize = "small"; + + let description = aDocument.createElement("span"); + description.style.color = "dark-grey"; + description.textContent = `${l10n_values[locales[name]]} `; + entry.appendChild(description); + + let value; + if (composeHTML && contentLink) { + value = this._generateLink(aDocument, content, contentLink, "#595959"); + } else { + value = aDocument.createElement("span"); + value.style.color = "#595959"; + value.textContent = content; + } + value.classList.add(`cloudfile-${name}`); + entry.appendChild(value); + + entry.appendChild(aDocument.createElement("br")); + return entry; + }; + + const serviceRow = () => { + let service = aDocument.createDocumentFragment(); + + let description = aDocument.createElement("span"); + description.style.display = "none"; + description.textContent = `${l10n_values[locales.service]} `; + service.appendChild(description); + + let providerName = aDocument.createElement("span"); + providerName.style.gridArea = "1 / 4"; + providerName.style.color = "#595959"; + providerName.style.fontSize = "small"; + providerName.textContent = aCloudFileUpload.serviceName; + providerName.classList.add("cloudfile-service-name"); + service.appendChild(providerName); + + service.appendChild(aDocument.createElement("br")); + return service; + }; + + // If this message is send in plain text only, do not add a link to the file + // name. + let name = aDocument.createElement("span"); + name.textContent = aCloudFileUpload.name; + if (composeHTML) { + name = this._generateLink( + aDocument, + aCloudFileUpload.name, + aCloudFileUpload.url, + "#0F7EDB" + ); + name.setAttribute("moz-do-not-send", "true"); + name.style.gridArea = "1 / 2"; + } + name.classList.add("cloudfile-name"); + node.appendChild(name); + + let paperclip = aDocument.createElement("img"); + paperclip.classList.add("paperClipIcon"); + paperclip.style.gridArea = "1 / 1"; + paperclip.alt = ""; + paperclip.style.marginRight = "5px"; + paperclip.width = `${iconSize}`; + paperclip.height = `${iconSize}`; + if (aCloudFileUpload.downloadPasswordProtected) { + paperclip.title = l10n_values[locales["tooltip-password-protected-link"]]; + paperclip.src = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIfSURBVFhH7ZfLK0RRHMfvNd6PMV4Lj5UkO5bslJIdf4ClRw2TlY2yt2EhsZO9DYoFoiSvJBZkI6SsNMyIiLnH93vmXDF5HNe9pHzqM797fufMPb+Zc4Z7jC+QBnvgJryD93AddkH2eUop3IPiHXdgCfSEdLgLOdE+bIFFSl4zZxeRAl2HXzsn2IIZTCTAHPs4hsvhOlxz3rxRtt6GfRyzJlsucw1582zZehv2cUxEtlyGN6afkThuFa7EL7+H0wK03pek4q/xJwtYVv4YumurO+4V/3vgvwAvC5iHTfHL9zFV/Ah7J9tjE9s2r/K3YwWlD8IaREP+ExPCWBDJVl+gM3LEto0nBURHCiuNpBiflvLjqWcufDFfdVbo4ly1PVoC0xrAaz4qnLdiVjk1hVhArvDRFxuSYxQeFSAaGHzCbAuEIsf0URjtsithX3i1Cf18yewKn8kWyOu+OlWXuSpKnBRwpWKxioTXi7BCtr6Ak004BZvhJAwyAUZhb3Q0bwKxXmY+xVzyB8MNOgXwE/NrC0A+clXBDZV7iYkC7GK18AcvTZ0lOFGRE5NDWAtn4A28hdPQEToFcG1Jq4qERXAZ+DCaBXk+cIROAePQgh2whgk30SngAA7CVDgLq6Fr6P4M++Ec5PmPp6BhWAdzIA+m3BOO0C2AJ2GuMyfme0KQp6Ao5EmZf/fLDGFuI2oi+EEcUQm5JDywhpWc2MFGNIwn/WmcKhqF50UAAAAASUVORK5CYII="; + } else { + paperclip.src = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAVFJREFUWIXtl8FKw0AQhj8EbQ/p0Ut8AVEPgYLUB+i5L6J9E0Wtr1HPgl48WU8K1Tfw4LktxUAhHvZfiMXUbdhVhB0Yms78M/NldwkJuFsD6AMjYCYfASfKBbUd4BkoKvxJmiDWKA1/AXrAtrynmIUIshJ9DXgEmt/km8oVwHEIANu8u0LTleYhBMBUzZMVmkSaSQgAe9DW1d3L/wzAqW6jJpQ3+5cA3vbW1Vz3Np6BCBABIkAE+DWAmX7TUixdynm15Wf6jf5fa3Cq60K5qrraNuHrK1kbmJcGWJ8rB9DC4yvaq5odlmK7wBB4lw8Vs9ZRzdgHwLmaXa5RM1DNmQ+AA2ABfACZgz4DctXs+QAAuMLc0dsPEJk0BXDhazjAFnCnxjlmiTuYg5kAR4rl0twCmz4BLMQAs7RVH6kLzJ17H162fczhGmO+mqa6PqXGnn8CxMN0PcC9DrQAAAAASUVORK5CYII="; + } + node.appendChild(paperclip); + + let serviceIcon = aDocument.createElement("img"); + serviceIcon.classList.add("cloudfile-service-icon"); + serviceIcon.style.gridArea = "1 / 3"; + serviceIcon.alt = ""; + serviceIcon.style.margin = "0 5px"; + serviceIcon.width = `${iconSize}`; + serviceIcon.height = `${iconSize}`; + node.appendChild(serviceIcon); + + if (aCloudFileUpload.serviceIcon) { + if (!/^(chrome|moz-extension):\/\//i.test(aCloudFileUpload.serviceIcon)) { + serviceIcon.src = aCloudFileUpload.serviceIcon; + } else { + try { + // Let's use the goodness from MsgComposeCommands.js since we're + // sitting right in a compose window. + serviceIcon.src = window.loadBlockedImage( + aCloudFileUpload.serviceIcon, + true + ); + } catch (e) { + // Couldn't load the referenced image. + console.error(e); + } + } + } + node.appendChild(aDocument.createElement("br")); + + node.appendChild( + statsRow("size", gMessenger.formatFileSize(aCloudFileUpload.size)) + ); + + if (aCloudFileUpload.downloadExpiryDate) { + node.appendChild( + statsRow( + "expiry-date", + new Date( + aCloudFileUpload.downloadExpiryDate.timestamp + ).toLocaleString( + undefined, + aCloudFileUpload.downloadExpiryDate.format || { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + } + ) + ) + ); + } + + if (aCloudFileUpload.downloadLimit) { + node.appendChild( + statsRow("download-limit", aCloudFileUpload.downloadLimit) + ); + } + + if (composeHTML || aCloudFileUpload.serviceUrl) { + node.appendChild(serviceRow()); + } + + let linkElementLocaleId = aCloudFileUpload.downloadPasswordProtected + ? "password-protected-link" + : "link"; + node.appendChild( + statsRow(linkElementLocaleId, aCloudFileUpload.url, aCloudFileUpload.url) + ); + + // An extra line break is needed for the converted plain text version, if it + // should have a gap between its <li> elements. + if (composeHTML) { + node.appendChild(aDocument.createElement("br")); + } + + // Generate the plain text version from the HTML. The used method needs a <ul> + // element wrapped around the <li> element to produce the correct content. + if (!composeHTML) { + let ul = aDocument.createElement("ul"); + ul.appendChild(node); + node = aDocument.createElement("p"); + node.textContent = this._getTextRepresentation(ul.outerHTML); + } + + node.className = "cloudAttachmentItem"; + node.dataset.contentLocation = aCloudFileUpload.url; + node.dataset.serviceName = aCloudFileUpload.serviceName; + node.dataset.serviceUrl = aCloudFileUpload.serviceUrl; + return node; + }, + + /** + * Event handler for when mail is sent. For mail that is being sent + * (and not saved!), find any cloudAttachmentList* nodes that we've created, + * and strip their IDs out. That way, if the receiving user replies by + * sending some BigFiles, we don't run into ID conflicts. + */ + send(aEvent) { + let msgType = parseInt(aEvent.target.getAttribute("msgtype")); + + if ( + msgType == Ci.nsIMsgCompDeliverMode.Now || + msgType == Ci.nsIMsgCompDeliverMode.Later || + msgType == Ci.nsIMsgCompDeliverMode.Background + ) { + const kIDs = [ + "cloudAttachmentListRoot", + "cloudAttachmentListHeader", + "cloudAttachmentList", + "cloudAttachmentListFooter", + ]; + let mailDoc = document.getElementById("messageEditor").contentDocument; + + for (let id of kIDs) { + let element = mailDoc.getElementById(id); + if (element) { + element.removeAttribute("id"); + } + } + } + }, +}; + +window.addEventListener( + "compose-window-init", + gCloudAttachmentLinkManager.init.bind(gCloudAttachmentLinkManager), + true +); +window.addEventListener( + "compose-send-message", + gCloudAttachmentLinkManager.send.bind(gCloudAttachmentLinkManager), + true +); diff --git a/comm/mail/components/compose/content/dialogs/EdAEAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEAttributes.js new file mode 100644 index 0000000000..52b7e30fac --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdAEAttributes.js @@ -0,0 +1,973 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// HTML Attributes object for "Name" menulist +var gHTMLAttr = {}; + +// JS Events Attributes object for "Name" menulist +var gJSAttr = {}; + +// Core HTML attribute values // +// This is appended to Name menulist when "_core" is attribute name +var gCoreHTMLAttr = ["^id", "class", "title"]; + +// Core event attribute values // +// This is appended to all JS menulists +// except those elements having "noJSEvents" +// as a value in their gJSAttr array. +var gCoreJSEvents = [ + "onclick", + "ondblclick", + "onmousedown", + "onmouseup", + "onmouseover", + "onmousemove", + "onmouseout", + "-", + "onkeypress", + "onkeydown", + "onkeyup", +]; + +// Following are commonly-used strings + +// Also accept: sRGB: #RRGGBB // +var gHTMLColors = [ + "Aqua", + "Black", + "Blue", + "Fuchsia", + "Gray", + "Green", + "Lime", + "Maroon", + "Navy", + "Olive", + "Purple", + "Red", + "Silver", + "Teal", + "White", + "Yellow", +]; + +var gHAlign = ["left", "center", "right"]; + +var gHAlignJustify = ["left", "center", "right", "justify"]; + +var gHAlignTableContent = ["left", "center", "right", "justify", "char"]; + +var gVAlignTable = ["top", "middle", "bottom", "baseline"]; + +var gTarget = ["_blank", "_self", "_parent", "_top"]; + +// ================ HTML Attributes ================ // +/* For each element, there is an array of attributes, + whose name is the element name, + used to fill the "Attribute Name" menulist. + For each of those attributes, if they have a specific + set of values, those are listed in an array named: + "elementName_attName". + + In each values string, the following characters + are signal to do input filtering: + "#" Allow only integer values + "%" Allow integer values or a number ending in "%" + "+" Allow integer values and allow "+" or "-" as first character + "!" Allow only one character + "^" The first character can be only be A-Z, a-z, hyphen, underscore, colon or period + "$" is an attribute required by HTML DTD +*/ + +/* + Most elements have the "dir" attribute, + so we use this value array + for all elements instead of specifying + separately for each element +*/ +gHTMLAttr.all_dir = ["ltr", "rtl"]; + +gHTMLAttr.a = [ + "charset", + "type", + "name", + "href", + "^hreflang", + "target", + "rel", + "rev", + "!accesskey", + "shape", // with imagemap // + "coords", // with imagemap // + "#tabindex", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.a_target = gTarget; + +gHTMLAttr.a_rel = [ + "alternate", + "stylesheet", + "start", + "next", + "prev", + "contents", + "index", + "glossary", + "copyright", + "chapter", + "section", + "subsection", + "appendix", + "help", + "bookmark", +]; + +gHTMLAttr.a_rev = [ + "alternate", + "stylesheet", + "start", + "next", + "prev", + "contents", + "index", + "glossary", + "copyright", + "chapter", + "section", + "subsection", + "appendix", + "help", + "bookmark", +]; + +gHTMLAttr.a_shape = ["rect", "circle", "poly", "default"]; + +gHTMLAttr.abbr = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.acronym = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.address = ["_core", "-", "^lang", "dir"]; + +// this is deprecated // +gHTMLAttr.applet = [ + "codebase", + "archive", + "code", + "object", + "alt", + "name", + "%$width", + "%$height", + "align", + "#hspace", + "#vspace", + "-", + "_core", +]; + +gHTMLAttr.applet_align = ["top", "middle", "bottom", "left", "right"]; + +gHTMLAttr.area = [ + "shape", + "coords", + "href", + "nohref", + "target", + "$alt", + "#tabindex", + "!accesskey", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.area_target = gTarget; + +gHTMLAttr.area_shape = ["rect", "circle", "poly", "default"]; + +gHTMLAttr.area_nohref = ["nohref"]; + +gHTMLAttr.b = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.base = ["href", "target"]; + +gHTMLAttr.base_target = gTarget; + +// this is deprecated // +gHTMLAttr.basefont = ["^id", "$size", "color", "face"]; + +gHTMLAttr.basefont_color = gHTMLColors; + +gHTMLAttr.bdo = ["_core", "-", "^lang", "$dir"]; + +gHTMLAttr.bdo_dir = ["ltr", "rtl"]; + +gHTMLAttr.big = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.blockquote = ["cite", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.body = [ + "background", + "bgcolor", + "text", + "link", + "vlink", + "alink", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.body_bgcolor = gHTMLColors; + +gHTMLAttr.body_text = gHTMLColors; + +gHTMLAttr.body_link = gHTMLColors; + +gHTMLAttr.body_vlink = gHTMLColors; + +gHTMLAttr.body_alink = gHTMLColors; + +gHTMLAttr.br = ["clear", "-", "_core"]; + +gHTMLAttr.br_clear = ["none", "left", "all", "right"]; + +gHTMLAttr.button = [ + "name", + "value", + "$type", + "disabled", + "#tabindex", + "!accesskey", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.button_type = ["submit", "button", "reset"]; + +gHTMLAttr.button_disabled = ["disabled"]; + +gHTMLAttr.caption = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.caption_align = ["top", "bottom", "left", "right"]; + +// this is deprecated // +gHTMLAttr.center = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.cite = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.code = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.col = [ + "#$span", + "%width", + "align", + "!char", + "#charoff", + "valign", + "char", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.col_span = [ + "1", // default +]; + +gHTMLAttr.col_align = gHAlignTableContent; + +gHTMLAttr.col_valign = ["top", "middle", "bottom", "baseline"]; + +gHTMLAttr.colgroup = [ + "#$span", + "%width", + "align", + "!char", + "#charoff", + "valign", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.colgroup_span = [ + "1", // default +]; + +gHTMLAttr.colgroup_align = gHAlignTableContent; + +gHTMLAttr.colgroup_valign = ["top", "middle", "bottom", "baseline"]; + +gHTMLAttr.dd = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.del = ["cite", "datetime", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.dfn = ["_core", "-", "^lang", "dir"]; + +// this is deprecated // +gHTMLAttr.dir = ["compact", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.dir_compact = ["compact"]; + +gHTMLAttr.div = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.div_align = gHAlignJustify; + +gHTMLAttr.dl = ["compact", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.dl_compact = ["compact"]; + +gHTMLAttr.dt = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.em = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.fieldset = ["_core", "-", "^lang", "dir"]; + +// this is deprecated // +gHTMLAttr.font = ["+size", "color", "face", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.font_color = gHTMLColors; + +gHTMLAttr.form = [ + "$action", + "$method", + "enctype", + "accept", + "name", + "accept-charset", + "target", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.form_method = ["get", "post"]; + +gHTMLAttr.form_enctype = ["application/x-www-form-urlencoded"]; + +gHTMLAttr.form_target = gTarget; + +gHTMLAttr.frame = [ + "longdesc", + "name", + "src", + "#frameborder", + "#marginwidth", + "#marginheight", + "noresize", + "$scrolling", +]; + +gHTMLAttr.frame_frameborder = ["1", "0"]; + +gHTMLAttr.frame_noresize = ["noresize"]; + +gHTMLAttr.frame_scrolling = ["auto", "yes", "no"]; + +gHTMLAttr.frameset = ["rows", "cols", "-", "_core"]; + +gHTMLAttr.h1 = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.h1_align = gHAlignJustify; + +gHTMLAttr.h2 = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.h2_align = gHAlignJustify; + +gHTMLAttr.h3 = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.h3_align = gHAlignJustify; + +gHTMLAttr.h4 = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.h4_align = gHAlignJustify; + +gHTMLAttr.h5 = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.h5_align = gHAlignJustify; + +gHTMLAttr.h6 = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.h6_align = gHAlignJustify; + +gHTMLAttr.head = ["profile", "-", "^lang", "dir"]; + +gHTMLAttr.hr = [ + "align", + "noshade", + "#size", + "%width", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.hr_align = gHAlign; + +gHTMLAttr.hr_noshade = ["noshade"]; + +gHTMLAttr.html = ["version", "-", "^lang", "dir"]; + +gHTMLAttr.i = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.iframe = [ + "longdesc", + "name", + "src", + "$frameborder", + "marginwidth", + "marginheight", + "$scrolling", + "align", + "%height", + "%width", + "-", + "_core", +]; + +gHTMLAttr.iframe_frameborder = ["1", "0"]; + +gHTMLAttr.iframe_scrolling = ["auto", "yes", "no"]; + +gHTMLAttr.iframe_align = ["top", "middle", "bottom", "left", "right"]; + +gHTMLAttr.img = [ + "$src", + "$alt", + "longdesc", + "name", + "%height", + "%width", + "usemap", + "ismap", + "align", + "#border", + "#hspace", + "#vspace", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.img_ismap = ["ismap"]; + +gHTMLAttr.img_align = ["top", "middle", "bottom", "left", "right"]; + +gHTMLAttr.input = [ + "$type", + "name", + "value", + "checked", + "disabled", + "readonly", + "#size", + "#maxlength", + "src", + "alt", + "usemap", + "ismap", + "#tabindex", + "!accesskey", + "accept", + "align", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.input_type = [ + "text", + "password", + "checkbox", + "radio", + "submit", + "reset", + "file", + "hidden", + "image", + "button", +]; + +gHTMLAttr.input_checked = ["checked"]; + +gHTMLAttr.input_disabled = ["disabled"]; + +gHTMLAttr.input_readonly = ["readonly"]; + +gHTMLAttr.input_ismap = ["ismap"]; + +gHTMLAttr.input_align = ["top", "middle", "bottom", "left", "right"]; + +gHTMLAttr.ins = ["cite", "datetime", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.isindex = ["prompt", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.kbd = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.label = ["for", "!accesskey", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.legend = ["!accesskey", "align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.legend_align = ["top", "bottom", "left", "right"]; + +gHTMLAttr.li = ["type", "#value", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.li_type = ["disc", "square", "circle", "-", "1", "a", "A", "i", "I"]; + +gHTMLAttr.link = [ + "charset", + "href", + "^hreflang", + "type", + "rel", + "rev", + "media", + "target", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.link_target = gTarget; + +gHTMLAttr.link_rel = [ + "alternate", + "stylesheet", + "start", + "next", + "prev", + "contents", + "index", + "glossary", + "copyright", + "chapter", + "section", + "subsection", + "appendix", + "help", + "bookmark", +]; + +gHTMLAttr.link_rev = [ + "alternate", + "stylesheet", + "start", + "next", + "prev", + "contents", + "index", + "glossary", + "copyright", + "chapter", + "section", + "subsection", + "appendix", + "help", + "bookmark", +]; + +gHTMLAttr.map = ["$name", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.menu = ["compact", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.menu_compact = ["compact"]; + +gHTMLAttr.meta = [ + "http-equiv", + "name", + "$content", + "scheme", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.noframes = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.noscript = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.object = [ + "declare", + "classid", + "codebase", + "data", + "type", + "codetype", + "archive", + "standby", + "%height", + "%width", + "usemap", + "name", + "#tabindex", + "align", + "#border", + "#hspace", + "#vspace", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.object_declare = ["declare"]; + +gHTMLAttr.object_align = ["top", "middle", "bottom", "left", "right"]; + +gHTMLAttr.ol = ["type", "compact", "#start", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.ol_type = ["1", "a", "A", "i", "I"]; + +gHTMLAttr.ol_compact = ["compact"]; + +gHTMLAttr.optgroup = ["disabled", "$label", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.optgroup_disabled = ["disabled"]; + +gHTMLAttr.option = [ + "selected", + "disabled", + "label", + "value", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.option_selected = ["selected"]; + +gHTMLAttr.option_disabled = ["disabled"]; + +gHTMLAttr.p = ["align", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.p_align = gHAlignJustify; + +gHTMLAttr.param = ["^id", "$name", "value", "$valuetype", "type"]; + +gHTMLAttr.param_valuetype = ["data", "ref", "object"]; + +gHTMLAttr.pre = ["%width", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.q = ["cite", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.s = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.samp = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.script = ["charset", "$type", "language", "src", "defer"]; + +gHTMLAttr.script_defer = ["defer"]; + +gHTMLAttr.select = [ + "name", + "#size", + "multiple", + "disabled", + "#tabindex", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.select_multiple = ["multiple"]; + +gHTMLAttr.select_disabled = ["disabled"]; + +gHTMLAttr.small = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.span = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.strike = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.strong = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.style = ["$type", "media", "title", "-", "^lang", "dir"]; + +gHTMLAttr.sub = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.sup = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.table = [ + "summary", + "%width", + "#border", + "frame", + "rules", + "#cellspacing", + "#cellpadding", + "align", + "bgcolor", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.table_frame = [ + "void", + "above", + "below", + "hsides", + "lhs", + "rhs", + "vsides", + "box", + "border", +]; + +gHTMLAttr.table_rules = ["none", "groups", "rows", "cols", "all"]; + +// Note; This is alignment of the table, +// not table contents, like all other table child elements +gHTMLAttr.table_align = gHAlign; + +gHTMLAttr.table_bgcolor = gHTMLColors; + +gHTMLAttr.tbody = [ + "align", + "!char", + "#charoff", + "valign", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.tbody_align = gHAlignTableContent; + +gHTMLAttr.tbody_valign = gVAlignTable; + +gHTMLAttr.td = [ + "abbr", + "axis", + "headers", + "scope", + "$#rowspan", + "$#colspan", + "align", + "!char", + "#charoff", + "valign", + "nowrap", + "bgcolor", + "%width", + "%height", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.td_scope = ["row", "col", "rowgroup", "colgroup"]; + +gHTMLAttr.td_rowspan = [ + "1", // default +]; + +gHTMLAttr.td_colspan = [ + "1", // default +]; + +gHTMLAttr.td_align = gHAlignTableContent; + +gHTMLAttr.td_valign = gVAlignTable; + +gHTMLAttr.td_nowrap = ["nowrap"]; + +gHTMLAttr.td_bgcolor = gHTMLColors; + +gHTMLAttr.textarea = [ + "name", + "$#rows", + "$#cols", + "disabled", + "readonly", + "#tabindex", + "!accesskey", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.textarea_disabled = ["disabled"]; + +gHTMLAttr.textarea_readonly = ["readonly"]; + +gHTMLAttr.tfoot = [ + "align", + "!char", + "#charoff", + "valign", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.tfoot_align = gHAlignTableContent; + +gHTMLAttr.tfoot_valign = gVAlignTable; + +gHTMLAttr.th = [ + "abbr", + "axis", + "headers", + "scope", + "$#rowspan", + "$#colspan", + "align", + "!char", + "#charoff", + "valign", + "nowrap", + "bgcolor", + "%width", + "%height", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.th_scope = ["row", "col", "rowgroup", "colgroup"]; + +gHTMLAttr.th_rowspan = [ + "1", // default +]; + +gHTMLAttr.th_colspan = [ + "1", // default +]; + +gHTMLAttr.th_align = gHAlignTableContent; + +gHTMLAttr.th_valign = gVAlignTable; + +gHTMLAttr.th_nowrap = ["nowrap"]; + +gHTMLAttr.th_bgcolor = gHTMLColors; + +gHTMLAttr.thead = [ + "align", + "!char", + "#charoff", + "valign", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.thead_align = gHAlignTableContent; + +gHTMLAttr.thead_valign = gVAlignTable; + +gHTMLAttr.title = ["^lang", "dir"]; + +gHTMLAttr.tr = [ + "align", + "!char", + "#charoff", + "valign", + "bgcolor", + "-", + "_core", + "-", + "^lang", + "dir", +]; + +gHTMLAttr.tr_align = gHAlignTableContent; + +gHTMLAttr.tr_valign = gVAlignTable; + +gHTMLAttr.tr_bgcolor = gHTMLColors; + +gHTMLAttr.tt = ["_core", "-", "^lang", "dir"]; + +gHTMLAttr.u = ["_core", "-", "^lang", "dir"]; +gHTMLAttr.ul = ["type", "compact", "-", "_core", "-", "^lang", "dir"]; + +gHTMLAttr.ul_type = ["disc", "square", "circle"]; + +gHTMLAttr.ul_compact = ["compact"]; + +// Prefix with "_" since this is reserved (it's stripped out) +gHTMLAttr._var = ["_core", "-", "^lang", "dir"]; + +// ================ JS Attributes ================ // +// These are element specific even handlers. +/* Most all elements use gCoreJSEvents, so those + are assumed except for those listed here with "noEvents" +*/ + +gJSAttr.a = ["onfocus", "onblur"]; + +gJSAttr.area = ["onfocus", "onblur"]; + +gJSAttr.body = ["onload", "onupload"]; + +gJSAttr.button = ["onfocus", "onblur"]; + +gJSAttr.form = ["onsubmit", "onreset"]; + +gJSAttr.frameset = ["onload", "onunload"]; + +gJSAttr.input = ["onfocus", "onblur", "onselect", "onchange"]; + +gJSAttr.label = ["onfocus", "onblur"]; + +gJSAttr.select = ["onfocus", "onblur", "onchange"]; + +gJSAttr.textarea = ["onfocus", "onblur", "onselect", "onchange"]; + +// Elements that don't have JSEvents: +gJSAttr.font = ["noJSEvents"]; + +gJSAttr.applet = ["noJSEvents"]; + +gJSAttr.isindex = ["noJSEvents"]; + +gJSAttr.iframe = ["noJSEvents"]; diff --git a/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js b/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js new file mode 100644 index 0000000000..ca54fa16da --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdAECSSAttributes.js @@ -0,0 +1,146 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdAdvancedEdit.js */ +/* import-globals-from EdDialogCommon.js */ + +// build attribute list in tree form from element attributes +function BuildCSSAttributeTable() { + var style = gElement.style; + if (style == undefined) { + dump("Inline styles undefined\n"); + return; + } + + var declLength = style.length; + + if (declLength == undefined || declLength == 0) { + if (declLength == undefined) { + dump("Failed to query the number of inline style declarations\n"); + } + + return; + } + + if (declLength > 0) { + for (var i = 0; i < declLength; ++i) { + var name = style.item(i); + var value = style.getPropertyValue(name); + AddTreeItem(name, value, "CSSAList", CSSAttrs); + } + } + + ClearCSSInputWidgets(); +} + +function onChangeCSSAttribute() { + var name = TrimString(gDialog.AddCSSAttributeNameInput.value); + if (!name) { + return; + } + + var value = TrimString(gDialog.AddCSSAttributeValueInput.value); + + // First try to update existing attribute + // If not found, add new attribute + if (!UpdateExistingAttribute(name, value, "CSSAList") && value) { + AddTreeItem(name, value, "CSSAList", CSSAttrs); + } +} + +function ClearCSSInputWidgets() { + gDialog.AddCSSAttributeTree.view.selection.clearSelection(); + gDialog.AddCSSAttributeNameInput.value = ""; + gDialog.AddCSSAttributeValueInput.value = ""; + SetTextboxFocus(gDialog.AddCSSAttributeNameInput); +} + +function onSelectCSSTreeItem() { + if (!gDoOnSelectTree) { + return; + } + + var tree = gDialog.AddCSSAttributeTree; + if (tree && tree.view.selection.count) { + gDialog.AddCSSAttributeNameInput.value = GetTreeItemAttributeStr( + getSelectedItem(tree) + ); + gDialog.AddCSSAttributeValueInput.value = GetTreeItemValueStr( + getSelectedItem(tree) + ); + } +} + +function onInputCSSAttributeName() { + var attName = TrimString( + gDialog.AddCSSAttributeNameInput.value + ).toLowerCase(); + var newValue = ""; + + var existingValue = GetAndSelectExistingAttributeValue(attName, "CSSAList"); + if (existingValue) { + newValue = existingValue; + } + + gDialog.AddCSSAttributeValueInput.value = newValue; +} + +function editCSSAttributeValue(targetCell) { + if (IsNotTreeHeader(targetCell)) { + gDialog.AddCSSAttributeValueInput.select(); + } +} + +function UpdateCSSAttributes() { + var CSSAList = document.getElementById("CSSAList"); + var styleString = ""; + for (var i = 0; i < CSSAList.children.length; i++) { + var item = CSSAList.children[i]; + var name = GetTreeItemAttributeStr(item); + var value = GetTreeItemValueStr(item); + // this code allows users to be sloppy in typing in values, and enter + // things like "foo: " and "bar;". This will trim off everything after the + // respective character. + if (name.includes(":")) { + name = name.substring(0, name.lastIndexOf(":")); + } + if (value.includes(";")) { + value = value.substring(0, value.lastIndexOf(";")); + } + if (i == CSSAList.children.length - 1) { + // Last property. + styleString += name + ": " + value + ";"; + } else { + styleString += name + ": " + value + "; "; + } + } + if (styleString) { + // Use editor transactions if modifying the element directly in the document + doRemoveAttribute("style"); + doSetAttribute("style", styleString); // NOTE BUG 18894!!! + } else if (gElement.getAttribute("style")) { + doRemoveAttribute("style"); + } +} + +function RemoveCSSAttribute() { + // We only allow 1 selected item + if (gDialog.AddCSSAttributeTree.view.selection.count) { + // Remove the item from the tree + // We always rebuild complete "style" string, + // so no list of "removed" items + getSelectedItem(gDialog.AddCSSAttributeTree).remove(); + + ClearCSSInputWidgets(); + } +} + +function SelectCSSTree(index) { + gDoOnSelectTree = false; + try { + gDialog.AddCSSAttributeTree.selectedIndex = index; + } catch (e) {} + gDoOnSelectTree = true; +} diff --git a/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js new file mode 100644 index 0000000000..127bfb858b --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdAEHTMLAttributes.js @@ -0,0 +1,362 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdAdvancedEdit.js */ +/* import-globals-from EdDialogCommon.js */ + +function BuildHTMLAttributeNameList() { + gDialog.AddHTMLAttributeNameInput.removeAllItems(); + + var elementName = gElement.localName; + var attNames = gHTMLAttr[elementName]; + + if (attNames && attNames.length) { + var menuitem; + + for (var i = 0; i < attNames.length; i++) { + var name = attNames[i]; + + if (name == "_core") { + // Signal to append the common 'core' attributes. + for (var j = 0; j < gCoreHTMLAttr.length; j++) { + name = gCoreHTMLAttr[j]; + + // only filtering rule used for core attributes as of 8-20-01 + // Add more rules if necessary. + if (name.includes("^")) { + name = name.replace(/\^/g, ""); + menuitem = gDialog.AddHTMLAttributeNameInput.appendItem(name, name); + menuitem.setAttribute("limitFirstChar", "true"); + } else { + gDialog.AddHTMLAttributeNameInput.appendItem(name, name); + } + } + } else if (name == "-") { + // Signal for separator + var popup = gDialog.AddHTMLAttributeNameInput.menupopup; + if (popup) { + var sep = document.createXULElement("menuseparator"); + if (sep) { + popup.appendChild(sep); + } + } + } else { + // Get information about value filtering + let forceOneChar = name.includes("!"); + let forceInteger = name.includes("#"); + let forceSignedInteger = name.includes("+"); + let forceIntOrPercent = name.includes("%"); + let limitFirstChar = name.includes("^"); + // let required = name.includes("$"); + + // Strip flag characters + name = name.replace(/[!^#%$+]/g, ""); + + menuitem = gDialog.AddHTMLAttributeNameInput.appendItem(name, name); + if (menuitem) { + // Signify "required" attributes by special style + // TODO: Don't do this until next version, when we add + // explanatory text and an 'Autofill Required Attributes' button + // if (required) + // menuitem.setAttribute("class", "menuitem-highlight-1"); + + // Set flags to filter value input + if (forceOneChar) { + menuitem.setAttribute("forceOneChar", "true"); + } + if (limitFirstChar) { + menuitem.setAttribute("limitFirstChar", "true"); + } + if (forceInteger) { + menuitem.setAttribute("forceInteger", "true"); + } + if (forceSignedInteger) { + menuitem.setAttribute("forceSignedInteger", "true"); + } + if (forceIntOrPercent) { + menuitem.setAttribute("forceIntOrPercent", "true"); + } + } + } + } + } +} + +// build attribute list in tree form from element attributes +function BuildHTMLAttributeTable() { + var nodeMap = gElement.attributes; + var i; + if (nodeMap.length > 0) { + var added = false; + for (i = 0; i < nodeMap.length; i++) { + let name = nodeMap[i].name.trim().toLowerCase(); + if ( + CheckAttributeNameSimilarity(nodeMap[i].nodeName, HTMLAttrs) || + name.startsWith("on") || + name == "style" + ) { + continue; // repeated or non-HTML attribute, ignore this one and go to next + } + if ( + !name.startsWith("_moz") && + AddTreeItem(name, nodeMap[i].value, "HTMLAList", HTMLAttrs) + ) { + added = true; + } + } + + if (added) { + SelectHTMLTree(0); + } + } +} + +function ClearHTMLInputWidgets() { + gDialog.AddHTMLAttributeTree.view.selection.clearSelection(); + gDialog.AddHTMLAttributeNameInput.value = ""; + gDialog.AddHTMLAttributeValueInput.value = ""; + SetTextboxFocus(gDialog.AddHTMLAttributeNameInput); +} + +function onSelectHTMLTreeItem() { + if (!gDoOnSelectTree) { + return; + } + + var tree = gDialog.AddHTMLAttributeTree; + if (tree && tree.view.selection.count) { + var inputName = TrimString( + gDialog.AddHTMLAttributeNameInput.value + ).toLowerCase(); + var selectedItem = getSelectedItem(tree); + var selectedName = + selectedItem.firstElementChild.firstElementChild.getAttribute("label"); + + if (inputName == selectedName) { + // Already editing selected name - just update the value input + gDialog.AddHTMLAttributeValueInput.value = + GetTreeItemValueStr(selectedItem); + } else { + gDialog.AddHTMLAttributeNameInput.value = selectedName; + + // Change value input based on new selected name + onInputHTMLAttributeName(); + } + } +} + +function onInputHTMLAttributeName() { + let attName = gDialog.AddHTMLAttributeNameInput.value.toLowerCase().trim(); + + // Clear value widget, but prevent triggering update in tree + gUpdateTreeValue = false; + gDialog.AddHTMLAttributeValueInput.value = ""; + gUpdateTreeValue = true; + + if (attName) { + // Get value list for current attribute name + var valueListName; + + // Most elements have the "dir" attribute, + // so we have just one array for the allowed values instead + // requiring duplicate entries for each element in EdAEAttributes.js + if (attName == "dir") { + valueListName = "all_dir"; + } else { + valueListName = gElement.localName + "_" + attName; + } + + // Strip off leading "_" we sometimes use (when element name is reserved word) + if (valueListName.startsWith("_")) { + valueListName = valueListName.slice(1); + } + + let useMenulist = false; // Editable menulist vs. input for the value. + var newValue = ""; + if (valueListName in gHTMLAttr) { + var valueList = gHTMLAttr[valueListName]; + + let listLen = valueList.length; + useMenulist = listLen > 1; + if (listLen == 1) { + newValue = valueList[0]; + } + + // Note: For case where "value list" is actually just + // one (default) item, don't use menulist for that + if (useMenulist) { + gDialog.AddHTMLAttributeValueMenulist.removeAllItems(); + + // Rebuild the list + for (var i = 0; i < listLen; i++) { + if (valueList[i] == "-") { + // Signal for separator + var popup = gDialog.AddHTMLAttributeValueInput.menupopup; + if (popup) { + var sep = document.createXULElement("menuseparator"); + if (sep) { + popup.appendChild(sep); + } + } + } else { + gDialog.AddHTMLAttributeValueMenulist.appendItem( + valueList[i], + valueList[i] + ); + } + } + } + } + if (useMenulist) { + // Switch to using editable menulist instead of the input. + gDialog.AddHTMLAttributeValueMenulist.parentElement.collapsed = false; + gDialog.AddHTMLAttributeValueTextbox.parentElement.collapsed = true; + gDialog.AddHTMLAttributeValueInput = + gDialog.AddHTMLAttributeValueMenulist; + } else { + // No list: Use input instead of editable menulist. + gDialog.AddHTMLAttributeValueMenulist.parentElement.collapsed = true; + gDialog.AddHTMLAttributeValueTextbox.parentElement.collapsed = false; + gDialog.AddHTMLAttributeValueInput = gDialog.AddHTMLAttributeValueTextbox; + } + + // If attribute already exists in tree, use associated value, + // else use default found above + var existingValue = GetAndSelectExistingAttributeValue( + attName, + "HTMLAList" + ); + if (existingValue) { + newValue = existingValue; + } + + gDialog.AddHTMLAttributeValueInput.value = newValue; + + if (!existingValue) { + onInputHTMLAttributeValue(); + } + } +} + +function onInputHTMLAttributeValue() { + if (!gUpdateTreeValue) { + return; + } + + var name = TrimString(gDialog.AddHTMLAttributeNameInput.value); + if (!name) { + return; + } + + // Trim spaces only from left since we must allow spaces within the string + // (we always reset the input field's value below) + var value = TrimStringLeft(gDialog.AddHTMLAttributeValueInput.value); + if (value) { + // Do value filtering based on type of attribute + // (Do not use "forceInteger()" to avoid multiple + // resetting of input's value and flickering) + var selectedItem = gDialog.AddHTMLAttributeNameInput.selectedItem; + + if (selectedItem) { + if ( + selectedItem.getAttribute("forceOneChar") == "true" && + value.length > 1 + ) { + value = value.slice(0, 1); + } + + if (selectedItem.getAttribute("forceIntOrPercent") == "true") { + // Allow integer with optional "%" as last character + var percent = TrimStringRight(value).slice(-1); + value = value.replace(/\D+/g, ""); + if (percent == "%") { + value += percent; + } + } else if (selectedItem.getAttribute("forceInteger") == "true") { + value = value.replace(/\D+/g, ""); + } else if (selectedItem.getAttribute("forceSignedInteger") == "true") { + // Allow integer with optional "+" or "-" as first character + var sign = value[0]; + value = value.replace(/\D+/g, ""); + if (sign == "+" || sign == "-") { + value = sign + value; + } + } + + // Special case attributes + if (selectedItem.getAttribute("limitFirstChar") == "true") { + // Limit first character to letter, and all others to + // letters, numbers, and a few others + value = value + .replace(/^[^a-zA-Z\u0080-\uFFFF]/, "") + .replace(/[^a-zA-Z0-9_\.\-\:\u0080-\uFFFF]+/g, ""); + } + + // Update once only if it changed + if (value != gDialog.AddHTMLAttributeValueInput.value) { + gDialog.AddHTMLAttributeValueInput.value = value; + } + } + } + + // Update value in the tree list + // If not found, add new attribute + if (!UpdateExistingAttribute(name, value, "HTMLAList") && value) { + AddTreeItem(name, value, "HTMLAList", HTMLAttrs); + } +} + +function editHTMLAttributeValue(targetCell) { + if (IsNotTreeHeader(targetCell)) { + gDialog.AddHTMLAttributeValueInput.select(); + } +} + +// update the object with added and removed attributes +function UpdateHTMLAttributes() { + var HTMLAList = document.getElementById("HTMLAList"); + var i; + + // remove removed attributes + for (i = 0; i < HTMLRAttrs.length; i++) { + var name = HTMLRAttrs[i]; + + if (gElement.hasAttribute(name)) { + doRemoveAttribute(name); + } + } + + // Set added or changed attributes + for (i = 0; i < HTMLAList.children.length; i++) { + var item = HTMLAList.children[i]; + doSetAttribute(GetTreeItemAttributeStr(item), GetTreeItemValueStr(item)); + } +} + +function RemoveHTMLAttribute() { + // We only allow 1 selected item + if (gDialog.AddHTMLAttributeTree.view.selection.count) { + var item = getSelectedItem(gDialog.AddHTMLAttributeTree); + var attr = GetTreeItemAttributeStr(item); + + // remove the item from the attribute array + HTMLRAttrs[HTMLRAttrs.length] = attr; + RemoveNameFromAttArray(attr, HTMLAttrs); + + // Remove the item from the tree + item.remove(); + + // Clear inputs and selected item in tree + ClearHTMLInputWidgets(); + } +} + +function SelectHTMLTree(index) { + gDoOnSelectTree = false; + try { + gDialog.AddHTMLAttributeTree.selectedIndex = index; + } catch (e) {} + gDoOnSelectTree = true; +} diff --git a/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js b/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js new file mode 100644 index 0000000000..8f902b74cd --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdAEJSEAttributes.js @@ -0,0 +1,200 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdAdvancedEdit.js */ +/* import-globals-from EdDialogCommon.js */ + +function BuildJSEAttributeNameList() { + gDialog.AddJSEAttributeNameList.removeAllItems(); + + // Get events specific to current element + var elementName = gElement.localName; + if (elementName in gJSAttr) { + var attNames = gJSAttr[elementName]; + var i; + var popup; + var sep; + + if (attNames && attNames.length) { + // Since we don't allow user-editable JS events yet (but we will soon) + // simply remove the JS tab to not allow adding JS events + if (attNames[0] == "noJSEvents") { + var tab = document.getElementById("tabJSE"); + if (tab) { + tab.remove(); + } + + return; + } + + for (i = 0; i < attNames.length; i++) { + gDialog.AddJSEAttributeNameList.appendItem(attNames[i], attNames[i]); + } + + popup = gDialog.AddJSEAttributeNameList.firstElementChild; + if (popup) { + sep = document.createXULElement("menuseparator"); + if (sep) { + popup.appendChild(sep); + } + } + } + } + + // Always add core JS events unless we aborted above + for (i = 0; i < gCoreJSEvents.length; i++) { + if (gCoreJSEvents[i] == "-") { + if (!popup) { + popup = gDialog.AddJSEAttributeNameList.firstElementChild; + } + + sep = document.createXULElement("menuseparator"); + + if (popup && sep) { + popup.appendChild(sep); + } + } else { + gDialog.AddJSEAttributeNameList.appendItem( + gCoreJSEvents[i], + gCoreJSEvents[i] + ); + } + } + + gDialog.AddJSEAttributeNameList.selectedIndex = 0; + + // Use current name and value of first tree item if it exists + onSelectJSETreeItem(); +} + +// build attribute list in tree form from element attributes +function BuildJSEAttributeTable() { + var nodeMap = gElement.attributes; + if (nodeMap.length > 0) { + var added = false; + for (var i = 0; i < nodeMap.length; i++) { + let name = nodeMap[i].nodeName.toLowerCase(); + if (CheckAttributeNameSimilarity(nodeMap[i].nodeName, JSEAttrs)) { + // Repeated or non-JS handler, ignore this one and go to next. + continue; + } + if (!name.startsWith("on")) { + // Attribute isn't an event handler. + continue; + } + var value = gElement.getAttribute(nodeMap[i].nodeName); + if (AddTreeItem(name, value, "JSEAList", JSEAttrs)) { + // add item to tree + added = true; + } + } + + // Select first item + if (added) { + gDialog.AddJSEAttributeTree.selectedIndex = 0; + } + } +} + +function onSelectJSEAttribute() { + if (!gDoOnSelectTree) { + return; + } + + gDialog.AddJSEAttributeValueInput.value = GetAndSelectExistingAttributeValue( + gDialog.AddJSEAttributeNameList.label, + "JSEAList" + ); +} + +function onSelectJSETreeItem() { + var tree = gDialog.AddJSEAttributeTree; + if (tree && tree.view.selection.count) { + // Select attribute name in list + gDialog.AddJSEAttributeNameList.value = GetTreeItemAttributeStr( + getSelectedItem(tree) + ); + + // Set value input to that in tree (no need to update this in the tree) + gUpdateTreeValue = false; + gDialog.AddJSEAttributeValueInput.value = GetTreeItemValueStr( + getSelectedItem(tree) + ); + gUpdateTreeValue = true; + } +} + +function onInputJSEAttributeValue() { + if (gUpdateTreeValue) { + var name = TrimString(gDialog.AddJSEAttributeNameList.label); + var value = TrimString(gDialog.AddJSEAttributeValueInput.value); + + // Update value in the tree list + // Since we have a non-editable menulist, + // we MUST automatically add the event attribute if it doesn't exist + if (!UpdateExistingAttribute(name, value, "JSEAList") && value) { + AddTreeItem(name, value, "JSEAList", JSEAttrs); + } + } +} + +function editJSEAttributeValue(targetCell) { + if (IsNotTreeHeader(targetCell)) { + gDialog.AddJSEAttributeValueInput.select(); + } +} + +function UpdateJSEAttributes() { + var JSEAList = document.getElementById("JSEAList"); + var i; + + // remove removed attributes + for (i = 0; i < JSERAttrs.length; i++) { + var name = JSERAttrs[i]; + + if (gElement.hasAttribute(name)) { + doRemoveAttribute(name); + } + } + + // Add events + for (i = 0; i < JSEAList.children.length; i++) { + var item = JSEAList.children[i]; + + // set the event handler + doSetAttribute(GetTreeItemAttributeStr(item), GetTreeItemValueStr(item)); + } +} + +function RemoveJSEAttribute() { + // This differs from HTML and CSS panels: + // We reselect after removing, because there is not + // editable attribute name input, so we can't clear that + // like we do in other panels + var newIndex = gDialog.AddJSEAttributeTree.selectedIndex; + + // We only allow 1 selected item + if (gDialog.AddJSEAttributeTree.view.selection.count) { + var item = getSelectedItem(gDialog.AddJSEAttributeTree); + + // Name is the text of the treecell + var attr = GetTreeItemAttributeStr(item); + + // remove the item from the attribute array + if (newIndex >= JSEAttrs.length - 1) { + newIndex--; + } + + // remove the item from the attribute array + JSERAttrs[JSERAttrs.length] = attr; + RemoveNameFromAttArray(attr, JSEAttrs); + + // Remove the item from the tree + item.remove(); + + // Reselect an item + gDialog.AddJSEAttributeTree.selectedIndex = newIndex; + } +} diff --git a/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js new file mode 100644 index 0000000000..5f2515c2f6 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.js @@ -0,0 +1,342 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdAEAttributes.js */ +/* import-globals-from EdAECSSAttributes.js */ +/* import-globals-from EdAEHTMLAttributes.js */ +/* import-globals-from EdAEJSEAttributes.js */ +/* import-globals-from EdDialogCommon.js */ + +/** ************ GLOBALS */ +var gElement = null; // handle to actual element edited + +var HTMLAttrs = []; // html attributes +var CSSAttrs = []; // css attributes +var JSEAttrs = []; // js events + +var HTMLRAttrs = []; // removed html attributes +var JSERAttrs = []; // removed js events + +/* Set false to allow changing selection in tree + without doing "onselect" handler actions +*/ +var gDoOnSelectTree = true; +var gUpdateTreeValue = true; + +/** ************ INITIALISATION && SETUP */ + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +/** + * function : void Startup(); + * parameters : none + * returns : none + * desc. : startup and initialisation, prepares dialog. + */ +function Startup() { + var editor = GetCurrentEditor(); + + // Element to edit is passed in + if (!editor || !window.arguments[1]) { + dump("Advanced Edit: No editor or element to edit not supplied\n"); + window.close(); + return; + } + // This is the return value for the parent, + // who only needs to know if OK was clicked + window.opener.AdvancedEditOK = false; + + // The actual element edited (not a copy!) + gElement = window.arguments[1]; + + // place the tag name in the header + var tagLabel = document.getElementById("tagLabel"); + tagLabel.setAttribute("value", "<" + gElement.localName + ">"); + + // Create dialog object to store controls for easy access + gDialog.AddHTMLAttributeNameInput = document.getElementById( + "AddHTMLAttributeNameInput" + ); + + gDialog.AddHTMLAttributeValueMenulist = document.getElementById( + "AddHTMLAttributeValueMenulist" + ); + gDialog.AddHTMLAttributeValueTextbox = document.getElementById( + "AddHTMLAttributeValueTextbox" + ); + gDialog.AddHTMLAttributeValueInput = gDialog.AddHTMLAttributeValueTextbox; + + gDialog.AddHTMLAttributeTree = document.getElementById("HTMLATree"); + gDialog.AddCSSAttributeNameInput = document.getElementById( + "AddCSSAttributeNameInput" + ); + gDialog.AddCSSAttributeValueInput = document.getElementById( + "AddCSSAttributeValueInput" + ); + gDialog.AddCSSAttributeTree = document.getElementById("CSSATree"); + gDialog.AddJSEAttributeNameList = document.getElementById( + "AddJSEAttributeNameList" + ); + gDialog.AddJSEAttributeValueInput = document.getElementById( + "AddJSEAttributeValueInput" + ); + gDialog.AddJSEAttributeTree = document.getElementById("JSEATree"); + gDialog.okButton = document.querySelector("dialog").getButton("accept"); + + // build the attribute trees + BuildHTMLAttributeTable(); + BuildCSSAttributeTable(); + BuildJSEAttributeTable(); + + // Build attribute name arrays for menulists + BuildJSEAttributeNameList(); + BuildHTMLAttributeNameList(); + // No menulists for CSS panel (yet) + + // Set focus to Name editable menulist in HTML panel + SetTextboxFocus(gDialog.AddHTMLAttributeNameInput); + + // size the dialog properly + window.sizeToContent(); + + SetWindowLocation(); +} + +/** + * function : bool onAccept ( void ); + * parameters : none + * returns : boolean true to close the window + * desc. : event handler for ok button + */ +function onAccept() { + var editor = GetCurrentEditor(); + editor.beginTransaction(); + try { + // Update our gElement attributes + UpdateHTMLAttributes(); + UpdateCSSAttributes(); + UpdateJSEAttributes(); + } catch (ex) { + dump(ex); + } + editor.endTransaction(); + + window.opener.AdvancedEditOK = true; + SaveWindowLocation(); +} + +// Helpers for removing and setting attributes +// Use editor transactions if modifying the element already in the document +// (Temporary element from a property dialog won't have a parent node) +function doRemoveAttribute(attrib) { + try { + var editor = GetCurrentEditor(); + if (gElement.parentNode) { + editor.removeAttribute(gElement, attrib); + } else { + gElement.removeAttribute(attrib); + } + } catch (ex) {} +} + +function doSetAttribute(attrib, value) { + try { + var editor = GetCurrentEditor(); + if (gElement.parentNode) { + editor.setAttribute(gElement, attrib, value); + } else { + gElement.setAttribute(attrib, value); + } + } catch (ex) {} +} + +/** + * function : bool CheckAttributeNameSimilarity ( string attName, array attArray ); + * parameters : attribute to look for, array of current attributes + * returns : true if attribute already exists, false if it does not + * desc. : checks to see if any other attributes by the same name as the arg supplied + * already exist. + */ +function CheckAttributeNameSimilarity(attName, attArray) { + for (var i = 0; i < attArray.length; i++) { + if (attName.toLowerCase() == attArray[i].toLowerCase()) { + return true; + } + } + return false; +} + +/** + * function : bool UpdateExistingAttribute ( string attName, string attValue, string treeChildrenId ); + * parameters : attribute to look for, new value, ID of <treeChildren> node in XUL tree + * returns : true if attribute already exists in tree, false if it does not + * desc. : checks to see if any other attributes by the same name as the arg supplied + * already exist while setting the associated value if different from current value + */ +function UpdateExistingAttribute(attName, attValue, treeChildrenId) { + var treeChildren = document.getElementById(treeChildrenId); + if (!treeChildren) { + return false; + } + + var name; + var i; + attName = TrimString(attName).toLowerCase(); + attValue = TrimString(attValue); + + for (i = 0; i < treeChildren.children.length; i++) { + var item = treeChildren.children[i]; + name = GetTreeItemAttributeStr(item); + if (name.toLowerCase() == attName) { + // Set the text in the "value' column treecell + SetTreeItemValueStr(item, attValue); + + // Select item just changed, + // but don't trigger the tree's onSelect handler + gDoOnSelectTree = false; + try { + selectTreeItem(treeChildren, item); + } catch (e) {} + gDoOnSelectTree = true; + + return true; + } + } + return false; +} + +/** + * function : string GetAndSelectExistingAttributeValue ( string attName, string treeChildrenId ); + * parameters : attribute to look for, ID of <treeChildren> node in XUL tree + * returns : value in from the tree or empty string if name not found + */ +function GetAndSelectExistingAttributeValue(attName, treeChildrenId) { + if (!attName) { + return ""; + } + + var treeChildren = document.getElementById(treeChildrenId); + var name; + var i; + + for (i = 0; i < treeChildren.children.length; i++) { + var item = treeChildren.children[i]; + name = GetTreeItemAttributeStr(item); + if (name.toLowerCase() == attName.toLowerCase()) { + // Select item in the tree + // but don't trigger the tree's onSelect handler + gDoOnSelectTree = false; + try { + selectTreeItem(treeChildren, item); + } catch (e) {} + gDoOnSelectTree = true; + + // Get the text in the "value' column treecell + return GetTreeItemValueStr(item); + } + } + + // Attribute doesn't exist in tree, so remove selection + gDoOnSelectTree = false; + try { + treeChildren.parentNode.view.selection.clearSelection(); + } catch (e) {} + gDoOnSelectTree = true; + + return ""; +} + +/* Tree structure: + <treeItem> + <treeRow> + <treeCell> // Name Cell + <treeCell // Value Cell +*/ +function GetTreeItemAttributeStr(treeItem) { + if (treeItem) { + return TrimString( + treeItem.firstElementChild.firstElementChild.getAttribute("label") + ); + } + + return ""; +} + +function GetTreeItemValueStr(treeItem) { + if (treeItem) { + return TrimString( + treeItem.firstElementChild.lastElementChild.getAttribute("label") + ); + } + + return ""; +} + +function SetTreeItemValueStr(treeItem, value) { + if (treeItem && GetTreeItemValueStr(treeItem) != value) { + treeItem.firstElementChild.lastElementChild.setAttribute("label", value); + } +} + +function IsNotTreeHeader(treeCell) { + if (treeCell) { + return treeCell.parentNode.parentNode.nodeName != "treehead"; + } + + return false; +} + +function RemoveNameFromAttArray(attName, attArray) { + for (var i = 0; i < attArray.length; i++) { + if (attName.toLowerCase() == attArray[i].toLowerCase()) { + // Remove 1 array item + attArray.splice(i, 1); + break; + } + } +} + +// adds a generalised treeitem. +function AddTreeItem(name, value, treeChildrenId, attArray) { + attArray[attArray.length] = name; + var treeChildren = document.getElementById(treeChildrenId); + var treeitem = document.createXULElement("treeitem"); + var treerow = document.createXULElement("treerow"); + + var attrCell = document.createXULElement("treecell"); + attrCell.setAttribute("class", "propertylist"); + attrCell.setAttribute("label", name); + + var valueCell = document.createXULElement("treecell"); + valueCell.setAttribute("class", "propertylist"); + valueCell.setAttribute("label", value); + + treerow.appendChild(attrCell); + treerow.appendChild(valueCell); + treeitem.appendChild(treerow); + treeChildren.appendChild(treeitem); + + // Select item just added, but suppress calling the onSelect handler. + gDoOnSelectTree = false; + try { + selectTreeItem(treeChildren, treeitem); + } catch (e) {} + gDoOnSelectTree = true; + + return treeitem; +} + +function selectTreeItem(treeChildren, item) { + var index = treeChildren.parentNode.view.getIndexOfItem(item); + treeChildren.parentNode.view.selection.select(index); +} + +function getSelectedItem(tree) { + if (tree.view.selection.count == 1) { + return tree.view.getItemAtIndex(tree.currentIndex); + } + return null; +} diff --git a/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml new file mode 100644 index 0000000000..cfeff95b42 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdAdvancedEdit.xhtml @@ -0,0 +1,243 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!-- first checkin of the year 2000! --> +<!-- Ben Goodger, 12:50AM, 01/00/00 NZST --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/menulist.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdAdvancedEdit.dtd"> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + style="min-width: 40em" + title="&WindowTitle.label;" + lightweightthemes="true" + onload="Startup()" +> + <dialog id="advancedEditDlg"> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <!-- element page functions --> + <script src="chrome://messenger/content/messengercompose/EdAEHTMLAttributes.js" /> + <script src="chrome://messenger/content/messengercompose/EdAECSSAttributes.js" /> + <script src="chrome://messenger/content/messengercompose/EdAEJSEAttributes.js" /> + <script src="chrome://messenger/content/messengercompose/EdAEAttributes.js" /> + + <!-- global dialog functions --> + <script src="chrome://messenger/content/messengercompose/EdAdvancedEdit.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <hbox> + <label value="¤tattributesfor.label;" /> + <label class="header" id="tagLabel" /> + </hbox> + + <separator class="thin" /> + + <tabbox flex="1"> + <tabs> + <tab label="&tabHTML.label;" /> + <tab label="&tabCSS.label;" /> + <tab label="&tabJSE.label;" id="tabJSE" /> + </tabs> + <tabpanels flex="1"> + <!-- ============================================================== --> + <!-- HTML Attributes --> + <!-- ============================================================== --> + <vbox> + <tree + id="HTMLATree" + class="AttributesTree" + flex="1" + hidecolumnpicker="true" + seltype="single" + onselect="onSelectHTMLTreeItem();" + onclick="onSelectHTMLTreeItem();" + ondblclick="editHTMLAttributeValue(event.target);" + > + <treecols> + <treecol id="HTMLAttrCol" label="&tree.attributeHeader.label;" /> + <splitter class="tree-splitter" /> + <treecol id="HTMLValCol" label="&tree.valueHeader.label;" /> + </treecols> + <treechildren id="HTMLAList" flex="1" /> + </tree> + <hbox align="center"> + <label value="&editAttribute.label;" /> + <spacer flex="1" /> + <button + label="&removeAttribute.label;" + oncommand="RemoveHTMLAttribute();" + /> + </hbox> + <hbox> + <vbox flex="1"> + <label + control="AddHTMLAttributeNameInput" + value="&AttName.label;" + /> + <menulist + is="menulist-editable" + id="AddHTMLAttributeNameInput" + class="editorAdvancedEditableMenulist" + editable="true" + flex="1" + oninput="onInputHTMLAttributeName();" + oncommand="onInputHTMLAttributeName();" + /> + </vbox> + <vbox flex="1"> + <label + id="AddHTMLAttributeValueLabel" + control="AddHTMLAttributeValueInput" + value="&AttValue.label;" + /> + <vbox flex="1"> + <hbox flex="1" class="input-container"> + <html:input + id="AddHTMLAttributeValueTextbox" + type="text" + class="input-inline" + onchange="onInputHTMLAttributeValue();" + aria-labelledby="AddHTMLAttributeValueLabel" + /> + </hbox> + <hbox flex="1" collapsed="true"> + <menulist + is="menulist-editable" + id="AddHTMLAttributeValueMenulist" + editable="true" + flex="1" + oninput="onInputHTMLAttributeValue();" + oncommand="onInputHTMLAttributeValue();" + /> + </hbox> + </vbox> + </vbox> + </hbox> + </vbox> + <!-- ============================================================== --> + <!-- CSS Attributes --> + <!-- ============================================================== --> + <vbox> + <tree + id="CSSATree" + class="AttributesTree" + flex="1" + hidecolumnpicker="true" + seltype="single" + onselect="onSelectCSSTreeItem();" + onclick="onSelectCSSTreeItem();" + ondblclick="editCSSAttributeValue(event.target);" + > + <treecols> + <treecol id="CSSPropCol" label="&tree.propertyHeader.label;" /> + <splitter class="tree-splitter" /> + <treecol id="CSSValCol" label="&tree.valueHeader.label;" /> + </treecols> + <treechildren id="CSSAList" flex="1" /> + </tree> + <hbox align="center"> + <label value="&editAttribute.label;" /> + <spacer flex="1" /> + <button + label="&removeAttribute.label;" + oncommand="RemoveCSSAttribute();" + /> + </hbox> + <hbox> + <vbox flex="1"> + <label + id="AddCSSAttributeNameLabel" + value="&PropertyName.label;" + /> + <html:input + id="AddCSSAttributeNameInput" + type="text" + class="input-inline" + onchange="onInputCSSAttributeName();" + aria-labelledby="AddCSSAttributeNameLabel" + /> + </vbox> + <vbox flex="1"> + <label id="AddCSSAttributeValueLabel" value="&AttValue.label;" /> + <html:input + id="AddCSSAttributeValueInput" + type="text" + class="input-inline" + onchange="onChangeCSSAttribute();" + aria-labelledby="AddCSSAttributeValueLabel" + /> + </vbox> + </hbox> + </vbox> + <!-- ============================================================== --> + <!-- JavaScript Event Handlers --> + <!-- ============================================================== --> + <vbox> + <tree + id="JSEATree" + class="AttributesTree" + flex="1" + hidecolumnpicker="true" + seltype="single" + onselect="onSelectJSETreeItem();" + onclick="onSelectJSETreeItem();" + ondblclick="editJSEAttributeValue(event.target);" + > + <treecols> + <treecol id="AttrCol" label="&tree.attributeHeader.label;" /> + <splitter class="tree-splitter" /> + <treecol id="HeaderCol" label="&tree.valueHeader.label;" /> + </treecols> + <treechildren id="JSEAList" flex="1" /> + </tree> + <hbox align="center"> + <label value="&editAttribute.label;" /> + <spacer flex="1" /> + <button + label="&removeAttribute.label;" + oncommand="RemoveJSEAttribute()" + /> + </hbox> + <hbox> + <vbox flex="1"> + <label value="&AttName.label;" /> + <menulist + id="AddJSEAttributeNameList" + oncommand="onSelectJSEAttribute();" + /> + </vbox> + <vbox flex="1"> + <label id="AddJSEAttributeValueLabel" value="&AttValue.label;" /> + <hbox flex="1" class="input-container"> + <html:input + id="AddJSEAttributeValueInput" + type="text" + class="input-inline" + onchange="onInputJSEAttributeValue();" + aria-labelledby="AddJSEAttributeValueLabel" + /> + </hbox> + </vbox> + </hbox> + </vbox> + </tabpanels> + </tabbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdColorPicker.js b/comm/mail/components/compose/content/dialogs/EdColorPicker.js new file mode 100644 index 0000000000..ef03a1d10b --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdColorPicker.js @@ -0,0 +1,290 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +// Cancel() is in EdDialogCommon.js + +var insertNew = true; +var tagname = "TAG NAME"; +var gColor = ""; +var LastPickedColor = ""; +var ColorType = "Text"; +var TextType = false; +var HighlightType = false; +var TableOrCell = false; +var LastPickedIsDefault = true; +var NoDefault = false; +var gColorObj; + +// dialog initialization code + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancelColor); + +function Startup() { + if (!window.arguments[1]) { + dump("EdColorPicker: Missing color object param\n"); + return; + } + + // window.arguments[1] is object to get initial values and return color data + gColorObj = window.arguments[1]; + gColorObj.Cancel = false; + + gDialog.ColorPicker = document.getElementById("ColorPicker"); + gDialog.ColorInput = document.getElementById("ColorInput"); + gDialog.LastPickedButton = document.getElementById("LastPickedButton"); + gDialog.LastPickedColor = document.getElementById("LastPickedColor"); + gDialog.CellOrTableGroup = document.getElementById("CellOrTableGroup"); + gDialog.TableRadio = document.getElementById("TableRadio"); + gDialog.CellRadio = document.getElementById("CellRadio"); + gDialog.ColorSwatch = document.getElementById("ColorPickerSwatch"); + gDialog.Ok = document.querySelector("dialog").getButton("accept"); + + // The type of color we are setting: + // text: Text, Link, ActiveLink, VisitedLink, + // or background: Page, Table, or Cell + if (gColorObj.Type) { + ColorType = gColorObj.Type; + // Get string for dialog title from passed-in type + // (note constraint on editor.properties string name) + let IsCSSPrefChecked = Services.prefs.getBoolPref("editor.use_css"); + + if (GetCurrentEditor()) { + if (ColorType == "Page" && IsCSSPrefChecked && IsHTMLEditor()) { + document.title = GetString("BlockColor"); + } else { + document.title = GetString(ColorType + "Color"); + } + } + } + + gDialog.ColorInput.value = ""; + var tmpColor; + var haveTableRadio = false; + + switch (ColorType) { + case "Page": + tmpColor = gColorObj.PageColor; + if (tmpColor && tmpColor.toLowerCase() != "window") { + gColor = tmpColor; + } + break; + case "Table": + if (gColorObj.TableColor) { + gColor = gColorObj.TableColor; + } + break; + case "Cell": + if (gColorObj.CellColor) { + gColor = gColorObj.CellColor; + } + break; + case "TableOrCell": + TableOrCell = true; + document.getElementById("TableOrCellGroup").collapsed = false; + haveTableRadio = true; + if (gColorObj.SelectedType == "Cell") { + gColor = gColorObj.CellColor; + gDialog.CellOrTableGroup.selectedItem = gDialog.CellRadio; + gDialog.CellRadio.focus(); + } else { + gColor = gColorObj.TableColor; + gDialog.CellOrTableGroup.selectedItem = gDialog.TableRadio; + gDialog.TableRadio.focus(); + } + break; + case "Highlight": + HighlightType = true; + if (gColorObj.HighlightColor) { + gColor = gColorObj.HighlightColor; + } + break; + default: + // Any other type will change some kind of text, + TextType = true; + tmpColor = gColorObj.TextColor; + if (tmpColor && tmpColor.toLowerCase() != "windowtext") { + gColor = gColorObj.TextColor; + } + break; + } + + // Set initial color in input field and in the colorpicker + SetCurrentColor(gColor); + gDialog.ColorPicker.value = gColor; + + // Use last-picked colors passed in, or those persistent on dialog + if (TextType) { + if (!("LastTextColor" in gColorObj) || !gColorObj.LastTextColor) { + gColorObj.LastTextColor = + gDialog.LastPickedColor.getAttribute("LastTextColor"); + } + LastPickedColor = gColorObj.LastTextColor; + } else if (HighlightType) { + if (!("LastHighlightColor" in gColorObj) || !gColorObj.LastHighlightColor) { + gColorObj.LastHighlightColor = + gDialog.LastPickedColor.getAttribute("LastHighlightColor"); + } + LastPickedColor = gColorObj.LastHighlightColor; + } else { + if ( + !("LastBackgroundColor" in gColorObj) || + !gColorObj.LastBackgroundColor + ) { + gColorObj.LastBackgroundColor = gDialog.LastPickedColor.getAttribute( + "LastBackgroundColor" + ); + } + LastPickedColor = gColorObj.LastBackgroundColor; + } + + // Set method to detect clicking on OK button + // so we don't get fooled by changing "default" behavior + gDialog.Ok.setAttribute("onclick", "SetDefaultToOk()"); + + if (!LastPickedColor) { + // Hide the button, as there is no last color available. + gDialog.LastPickedButton.hidden = true; + } else { + gDialog.LastPickedColor.setAttribute( + "style", + "background-color: " + LastPickedColor + ); + + // Make "Last-picked" the default button, until the user selects a color. + gDialog.Ok.removeAttribute("default"); + gDialog.LastPickedButton.setAttribute("default", "true"); + } + + // Caller can prevent user from submitting an empty, i.e., default color + NoDefault = gColorObj.NoDefault; + if (NoDefault) { + // Hide the "Default button -- user must pick a color + document.getElementById("DefaultColorButton").collapsed = true; + } + + // Set focus to colorpicker if not set to table radio buttons above + if (!haveTableRadio) { + gDialog.ColorPicker.focus(); + } + + SetWindowLocation(); +} + +function SelectColor() { + var color = gDialog.ColorPicker.value; + if (color) { + SetCurrentColor(color); + } +} + +function RemoveColor() { + SetCurrentColor(""); + gDialog.ColorInput.focus(); + SetDefaultToOk(); +} + +function SelectColorByKeypress(aEvent) { + if (aEvent.charCode == aEvent.DOM_VK_SPACE) { + SelectColor(); + SetDefaultToOk(); + } +} + +function SelectLastPickedColor() { + SetCurrentColor(LastPickedColor); + if (onAccept()) { + // window.close(); + return true; + } + + return false; +} + +function SetCurrentColor(color) { + // TODO: Validate color? + if (!color) { + color = ""; + } + gColor = TrimString(color).toLowerCase(); + if (gColor == "mixed") { + gColor = ""; + } + gDialog.ColorInput.value = gColor; + SetColorSwatch(); +} + +function SetColorSwatch() { + gDialog.ColorSwatch.setAttribute( + "style", + `background-color: ${TrimString(gDialog.ColorInput.value) || "inherit"}` + ); +} + +function SetDefaultToOk() { + gDialog.LastPickedButton.removeAttribute("default"); + gDialog.Ok.setAttribute("default", "true"); + LastPickedIsDefault = false; +} + +function ValidateData() { + if (LastPickedIsDefault) { + gColor = LastPickedColor; + } else { + gColor = gDialog.ColorInput.value; + } + + gColor = TrimString(gColor).toLowerCase(); + + // TODO: Validate the color string! + + if (NoDefault && !gColor) { + ShowInputErrorMessage(GetString("NoColorError")); + SetTextboxFocus(gDialog.ColorInput); + return false; + } + return true; +} + +function onAccept(event) { + if (!ValidateData()) { + event.preventDefault(); + return; + } + + // Set return values and save in persistent color attributes + if (TextType) { + gColorObj.TextColor = gColor; + if (gColor.length > 0) { + gDialog.LastPickedColor.setAttribute("LastTextColor", gColor); + gColorObj.LastTextColor = gColor; + } + } else if (HighlightType) { + gColorObj.HighlightColor = gColor; + if (gColor.length > 0) { + gDialog.LastPickedColor.setAttribute("LastHighlightColor", gColor); + gColorObj.LastHighlightColor = gColor; + } + } else { + gColorObj.BackgroundColor = gColor; + if (gColor.length > 0) { + gDialog.LastPickedColor.setAttribute("LastBackgroundColor", gColor); + gColorObj.LastBackgroundColor = gColor; + } + // If table or cell requested, tell caller which element to set on + if (TableOrCell && gDialog.TableRadio.selected) { + gColorObj.Type = "Table"; + } + } + SaveWindowLocation(); +} + +function onCancelColor() { + // Tells caller that user canceled + gColorObj.Cancel = true; + SaveWindowLocation(); +} diff --git a/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml b/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml new file mode 100644 index 0000000000..8576fc27da --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdColorPicker.xhtml @@ -0,0 +1,103 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdColorPicker.dtd"> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup()" +> + <dialog> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdColorPicker.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <hbox id="TableOrCellGroup" align="center" collapsed="true"> + <label + control="CellOrTableGroup" + value="&background.label;" + accesskey="&background.accessKey;" + /> + <radiogroup id="CellOrTableGroup" orient="horizontal"> + <radio + id="TableRadio" + label="&table.label;" + accesskey="&table.accessKey;" + /> + <radio + id="CellRadio" + label="&cell.label;" + accesskey="&cell.accessKey;" + /> + </radiogroup> + </hbox> + <hbox align="center"> + <label value="&chooseColor1.label;" /> + <html:input + type="color" + id="ColorPicker" + onclick="SetDefaultToOk();" + ondblclick="if (onAccept()) { window.close(); }" + onkeypress="SelectColorByKeypress(event);" + onchange="SelectColor();" + /> + <spacer flex="1" /> + <button + id="LastPickedButton" + label="&lastPickedColor.label;" + accesskey="&lastPickedColor.accessKey;" + crop="right" + oncommand="SelectLastPickedColor();" + > + <spacer + id="LastPickedColor" + LastTextColor="" + LastBackgroundColor="" + persist="LastTextColor LastBackgroundColor" + /> + </button> + </hbox> + + <spacer class="spacer" /> + <hbox align="center" flex="1"> + <vbox> + <label + class="tip-caption" + value="&chooseColor2.label;" + accesskey="&chooseColor2.accessKey;" + control="ColorInput" + /> + <label class="tip-caption" value="&setColorExample.label;" /> + </vbox> + <html:input + id="ColorInput" + type="text" + style="width: 8em" + oninput="SetColorSwatch(); SetDefaultToOk();" + /> + <label id="ColorPickerSwatch" /> + <spacer flex="1" /> + <button + id="DefaultColorButton" + label="&default.label;" + accesskey="&default.accessKey;" + oncommand="RemoveColor()" + /> + </hbox> + <separator class="groove" /> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdColorProps.js b/comm/mail/components/compose/content/dialogs/EdColorProps.js new file mode 100644 index 0000000000..c2635912d5 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdColorProps.js @@ -0,0 +1,476 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + Behavior notes: + Radio buttons select "UseDefaultColors" vs. "UseCustomColors" modes. + If any color attribute is set in the body, mode is "Custom Colors", + even if 1 or more (but not all) are actually null (= "use default") + When in "Custom Colors" mode, all colors will be set on body tag, + even if they are just default colors, to assure compatible colors in page. + User cannot select "use default" for individual colors +*/ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +// Cancel() is in EdDialogCommon.js + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +var gBodyElement; +var prefs; +var gBackgroundImage; + +// Initialize in case we can't get them from prefs??? +var defaultTextColor = "#000000"; +var defaultLinkColor = "#000099"; +var defaultActiveColor = "#000099"; +var defaultVisitedColor = "#990099"; +var defaultBackgroundColor = "#FFFFFF"; +const styleStr = "style"; +const textStr = "text"; +const linkStr = "link"; +const vlinkStr = "vlink"; +const alinkStr = "alink"; +const bgcolorStr = "bgcolor"; +const backgroundStr = "background"; +const cssColorStr = "color"; +const cssBackgroundColorStr = "background-color"; +const cssBackgroundImageStr = "background-image"; +const colorStyle = cssColorStr + ": "; +const backColorStyle = cssBackgroundColorStr + ": "; +const backImageStyle = "; " + cssBackgroundImageStr + ": url("; + +var customTextColor; +var customLinkColor; +var customActiveColor; +var customVisitedColor; +var customBackgroundColor; +var previewBGColor; + +// dialog initialization code +function Startup() { + var editor = GetCurrentEditor(); + if (!editor) { + window.close(); + return; + } + + gDialog.ColorPreview = document.getElementById("ColorPreview"); + gDialog.NormalText = document.getElementById("NormalText"); + gDialog.LinkText = document.getElementById("LinkText"); + gDialog.ActiveLinkText = document.getElementById("ActiveLinkText"); + gDialog.VisitedLinkText = document.getElementById("VisitedLinkText"); + gDialog.PageColorGroup = document.getElementById("PageColorGroup"); + gDialog.DefaultColorsRadio = document.getElementById("DefaultColorsRadio"); + gDialog.CustomColorsRadio = document.getElementById("CustomColorsRadio"); + gDialog.BackgroundImageInput = document.getElementById( + "BackgroundImageInput" + ); + + try { + gBodyElement = editor.rootElement; + } catch (e) {} + + if (!gBodyElement) { + dump("Failed to get BODY element!\n"); + window.close(); + } + + // Set element we will edit + globalElement = gBodyElement.cloneNode(false); + + // Initialize default colors from browser prefs + var browserColors = GetDefaultBrowserColors(); + if (browserColors) { + // Use author's browser pref colors passed into dialog + defaultTextColor = browserColors.TextColor; + defaultLinkColor = browserColors.LinkColor; + defaultActiveColor = browserColors.ActiveLinkColor; + defaultVisitedColor = browserColors.VisitedLinkColor; + defaultBackgroundColor = browserColors.BackgroundColor; + } + + // We only need to test for this once per dialog load + gHaveDocumentUrl = GetDocumentBaseUrl(); + + InitDialog(); + + gDialog.PageColorGroup.focus(); + + SetWindowLocation(); +} + +function InitDialog() { + // Get image from document + gBackgroundImage = GetHTMLOrCSSStyleValue( + globalElement, + backgroundStr, + cssBackgroundImageStr + ); + if (/url\((.*)\)/.test(gBackgroundImage)) { + gBackgroundImage = RegExp.$1; + } + + if (gBackgroundImage) { + // Shorten data URIs for display. + shortenImageData(gBackgroundImage, gDialog.BackgroundImageInput); + gDialog.ColorPreview.setAttribute( + styleStr, + backImageStyle + gBackgroundImage + ");" + ); + } + + SetRelativeCheckbox(); + + customTextColor = GetHTMLOrCSSStyleValue(globalElement, textStr, cssColorStr); + customTextColor = ConvertRGBColorIntoHEXColor(customTextColor); + customLinkColor = globalElement.getAttribute(linkStr); + customActiveColor = globalElement.getAttribute(alinkStr); + customVisitedColor = globalElement.getAttribute(vlinkStr); + customBackgroundColor = GetHTMLOrCSSStyleValue( + globalElement, + bgcolorStr, + cssBackgroundColorStr + ); + customBackgroundColor = ConvertRGBColorIntoHEXColor(customBackgroundColor); + + var haveCustomColor = + customTextColor || + customLinkColor || + customVisitedColor || + customActiveColor || + customBackgroundColor; + + // Set default color explicitly for any that are missing + // PROBLEM: We are using "windowtext" and "window" for the Windows OS + // default color values. This works with CSS in preview window, + // but we should NOT use these as values for HTML attributes! + + if (!customTextColor) { + customTextColor = defaultTextColor; + } + if (!customLinkColor) { + customLinkColor = defaultLinkColor; + } + if (!customActiveColor) { + customActiveColor = defaultActiveColor; + } + if (!customVisitedColor) { + customVisitedColor = defaultVisitedColor; + } + if (!customBackgroundColor) { + customBackgroundColor = defaultBackgroundColor; + } + + if (haveCustomColor) { + // If any colors are set, then check the "Custom" radio button + gDialog.PageColorGroup.selectedItem = gDialog.CustomColorsRadio; + UseCustomColors(); + } else { + gDialog.PageColorGroup.selectedItem = gDialog.DefaultColorsRadio; + UseDefaultColors(); + } +} + +function GetColorAndUpdate(ColorWellID) { + // Only allow selecting when in custom mode + if (!gDialog.CustomColorsRadio.selected) { + return; + } + + var colorWell = document.getElementById(ColorWellID); + if (!colorWell) { + return; + } + + // Don't allow a blank color, i.e., using the "default" + var colorObj = { + NoDefault: true, + Type: "", + TextColor: 0, + PageColor: 0, + Cancel: false, + }; + + switch (ColorWellID) { + case "textCW": + colorObj.Type = "Text"; + colorObj.TextColor = customTextColor; + break; + case "linkCW": + colorObj.Type = "Link"; + colorObj.TextColor = customLinkColor; + break; + case "activeCW": + colorObj.Type = "ActiveLink"; + colorObj.TextColor = customActiveColor; + break; + case "visitedCW": + colorObj.Type = "VisitedLink"; + colorObj.TextColor = customVisitedColor; + break; + case "backgroundCW": + colorObj.Type = "Page"; + colorObj.PageColor = customBackgroundColor; + break; + } + + window.openDialog( + "chrome://messenger/content/messengercompose/EdColorPicker.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "", + colorObj + ); + + // User canceled the dialog + if (colorObj.Cancel) { + return; + } + + var color = ""; + switch (ColorWellID) { + case "textCW": + color = customTextColor = colorObj.TextColor; + break; + case "linkCW": + color = customLinkColor = colorObj.TextColor; + break; + case "activeCW": + color = customActiveColor = colorObj.TextColor; + break; + case "visitedCW": + color = customVisitedColor = colorObj.TextColor; + break; + case "backgroundCW": + color = customBackgroundColor = colorObj.BackgroundColor; + break; + } + + setColorWell(ColorWellID, color); + SetColorPreview(ColorWellID, color); +} + +function SetColorPreview(ColorWellID, color) { + switch (ColorWellID) { + case "textCW": + gDialog.NormalText.setAttribute(styleStr, colorStyle + color); + break; + case "linkCW": + gDialog.LinkText.setAttribute(styleStr, colorStyle + color); + break; + case "activeCW": + gDialog.ActiveLinkText.setAttribute(styleStr, colorStyle + color); + break; + case "visitedCW": + gDialog.VisitedLinkText.setAttribute(styleStr, colorStyle + color); + break; + case "backgroundCW": + // Must combine background color and image style values + var styleValue = backColorStyle + color; + if (gBackgroundImage) { + styleValue += ";" + backImageStyle + gBackgroundImage + ");"; + } + + gDialog.ColorPreview.setAttribute(styleStr, styleValue); + previewBGColor = color; + break; + } +} + +function UseCustomColors() { + SetElementEnabledById("TextButton", true); + SetElementEnabledById("LinkButton", true); + SetElementEnabledById("ActiveLinkButton", true); + SetElementEnabledById("VisitedLinkButton", true); + SetElementEnabledById("BackgroundButton", true); + SetElementEnabledById("Text", true); + SetElementEnabledById("Link", true); + SetElementEnabledById("Active", true); + SetElementEnabledById("Visited", true); + SetElementEnabledById("Background", true); + + SetColorPreview("textCW", customTextColor); + SetColorPreview("linkCW", customLinkColor); + SetColorPreview("activeCW", customActiveColor); + SetColorPreview("visitedCW", customVisitedColor); + SetColorPreview("backgroundCW", customBackgroundColor); + + setColorWell("textCW", customTextColor); + setColorWell("linkCW", customLinkColor); + setColorWell("activeCW", customActiveColor); + setColorWell("visitedCW", customVisitedColor); + setColorWell("backgroundCW", customBackgroundColor); +} + +function UseDefaultColors() { + SetColorPreview("textCW", defaultTextColor); + SetColorPreview("linkCW", defaultLinkColor); + SetColorPreview("activeCW", defaultActiveColor); + SetColorPreview("visitedCW", defaultVisitedColor); + SetColorPreview("backgroundCW", defaultBackgroundColor); + + // Setting to blank color will remove color from buttons, + setColorWell("textCW", ""); + setColorWell("linkCW", ""); + setColorWell("activeCW", ""); + setColorWell("visitedCW", ""); + setColorWell("backgroundCW", ""); + + // Disable color buttons and labels + SetElementEnabledById("TextButton", false); + SetElementEnabledById("LinkButton", false); + SetElementEnabledById("ActiveLinkButton", false); + SetElementEnabledById("VisitedLinkButton", false); + SetElementEnabledById("BackgroundButton", false); + SetElementEnabledById("Text", false); + SetElementEnabledById("Link", false); + SetElementEnabledById("Active", false); + SetElementEnabledById("Visited", false); + SetElementEnabledById("Background", false); +} + +function chooseFile() { + // Get a local image file, converted into URL format + GetLocalFileURL("img").then(fileURL => { + // Always try to relativize local file URLs + if (gHaveDocumentUrl) { + fileURL = MakeRelativeUrl(fileURL); + } + + gDialog.BackgroundImageInput.value = fileURL; + + SetRelativeCheckbox(); + ValidateAndPreviewImage(true); + SetTextboxFocus(gDialog.BackgroundImageInput); + }); +} + +function ChangeBackgroundImage() { + // Don't show error message for image while user is typing + ValidateAndPreviewImage(false); + SetRelativeCheckbox(); +} + +function ValidateAndPreviewImage(ShowErrorMessage) { + // First make a string with just background color + var styleValue = backColorStyle + previewBGColor + ";"; + + var retVal = true; + var image = TrimString(gDialog.BackgroundImageInput.value); + if (image) { + if (isImageDataShortened(image)) { + gBackgroundImage = restoredImageData(gDialog.BackgroundImageInput); + } else { + gBackgroundImage = image; + + // Display must use absolute URL if possible + var displayImage = gHaveDocumentUrl ? MakeAbsoluteUrl(image) : image; + styleValue += backImageStyle + displayImage + ");"; + } + } else { + gBackgroundImage = null; + } + + // Set style on preview (removes image if not valid) + gDialog.ColorPreview.setAttribute(styleStr, styleValue); + + // Note that an "empty" string is valid + return retVal; +} + +function ValidateData() { + var editor = GetCurrentEditor(); + try { + // Colors values are updated as they are picked, no validation necessary + if (gDialog.DefaultColorsRadio.selected) { + editor.removeAttributeOrEquivalent(globalElement, textStr, true); + globalElement.removeAttribute(linkStr); + globalElement.removeAttribute(vlinkStr); + globalElement.removeAttribute(alinkStr); + editor.removeAttributeOrEquivalent(globalElement, bgcolorStr, true); + } else { + // Do NOT accept the CSS "WindowsOS" color strings! + // Problem: We really should try to get the actual color values + // from windows, but I don't know how to do that! + var tmpColor = customTextColor.toLowerCase(); + if (tmpColor != "windowtext") { + editor.setAttributeOrEquivalent( + globalElement, + textStr, + customTextColor, + true + ); + } else { + editor.removeAttributeOrEquivalent(globalElement, textStr, true); + } + + tmpColor = customBackgroundColor.toLowerCase(); + if (tmpColor != "window") { + editor.setAttributeOrEquivalent( + globalElement, + bgcolorStr, + customBackgroundColor, + true + ); + } else { + editor.removeAttributeOrEquivalent(globalElement, bgcolorStr, true); + } + + globalElement.setAttribute(linkStr, customLinkColor); + globalElement.setAttribute(vlinkStr, customVisitedColor); + globalElement.setAttribute(alinkStr, customActiveColor); + } + + if (ValidateAndPreviewImage(true)) { + // A valid image may be null for no image + if (gBackgroundImage) { + globalElement.setAttribute(backgroundStr, gBackgroundImage); + } else { + editor.removeAttributeOrEquivalent(globalElement, backgroundStr, true); + } + + return true; + } + } catch (e) {} + return false; +} + +function onAccept(event) { + // If it's a file, convert to a data URL. + if (gBackgroundImage && /^file:/i.test(gBackgroundImage)) { + let nsFile = Services.io + .newURI(gBackgroundImage) + .QueryInterface(Ci.nsIFileURL).file; + if (nsFile.exists()) { + let reader = new FileReader(); + reader.addEventListener("load", function () { + gBackgroundImage = reader.result; + gDialog.BackgroundImageInput.value = reader.result; + if (onAccept(event)) { + window.close(); + } + }); + File.createFromNsIFile(nsFile).then(file => { + reader.readAsDataURL(file); + }); + event.preventDefault(); // Don't close just yet... + return false; + } + } + if (ValidateData()) { + // Copy attributes to element we are changing + try { + GetCurrentEditor().cloneAttributes(gBodyElement, globalElement); + } catch (e) {} + + SaveWindowLocation(); + return true; // do close the window + } + event.preventDefault(); + return false; +} diff --git a/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml b/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml new file mode 100644 index 0000000000..633b1639d9 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdColorProps.xhtml @@ -0,0 +1,211 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % edColorPropertiesDTD SYSTEM "chrome://messenger/locale/messengercompose/EditorColorProperties.dtd"> +%edColorPropertiesDTD; +<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd"> +%composeEditorOverlayDTD; +<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd"> +%edDialogOverlay; ]> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup()" +> + <dialog> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdColorProps.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <html:fieldset align="start"> + <html:legend>&pageColors.label;</html:legend> + <radiogroup id="PageColorGroup"> + <radio + id="DefaultColorsRadio" + label="&defaultColorsRadio.label;" + oncommand="UseDefaultColors()" + accesskey="&defaultColorsRadio.accessKey;" + tooltiptext="&defaultColorsRadio.tooltip;" + /> + <radio + id="CustomColorsRadio" + label="&customColorsRadio.label;" + oncommand="UseCustomColors()" + accesskey="&customColorsRadio.accessKey;" + tooltiptext="&customColorsRadio.tooltip;" + /> + </radiogroup> + <hbox class="indent"> + <hbox> + <vbox> + <hbox flex="1" align="center"> + <label + id="Text" + control="TextButton" + value="&normalText.label;&colon.character;" + accesskey="&normalText.accessKey;" + /> + </hbox> + <hbox flex="1" align="center"> + <label + id="Link" + flex="1" + control="LinkButton" + value="&linkText.label;&colon.character;" + accesskey="&linkText.accessKey;" + /> + </hbox> + <hbox flex="1" align="center"> + <label + id="Active" + flex="1" + control="ActiveLinkButton" + value="&activeLinkText.label;&colon.character;" + accesskey="&activeLinkText.accessKey;" + /> + </hbox> + <hbox flex="1" align="center"> + <label + id="Visited" + flex="1" + control="VisitedLinkButton" + value="&visitedLinkText.label;&colon.character;" + accesskey="&visitedLinkText.accessKey;" + /> + </hbox> + <hbox flex="1" align="center"> + <label + id="Background" + flex="1" + control="BackgroundButton" + value="&background.label;" + accesskey="&background.accessKey;" + /> + </hbox> + </vbox> + <vbox> + <button + id="TextButton" + class="color-button" + oncommand="GetColorAndUpdate('textCW');" + > + <spacer id="textCW" class="color-well" /> + </button> + <button + id="LinkButton" + class="color-button" + oncommand="GetColorAndUpdate('linkCW');" + > + <spacer id="linkCW" class="color-well" /> + </button> + <button + id="ActiveLinkButton" + class="color-button" + oncommand="GetColorAndUpdate('activeCW');" + > + <spacer id="activeCW" class="color-well" /> + </button> + <button + id="VisitedLinkButton" + class="color-button" + oncommand="GetColorAndUpdate('visitedCW');" + > + <spacer id="visitedCW" class="color-well" /> + </button> + <button + id="BackgroundButton" + class="color-button" + oncommand="GetColorAndUpdate('backgroundCW');" + > + <spacer id="backgroundCW" class="color-well" /> + </button> + </vbox> + </hbox> + <vbox id="ColorPreview"> + <spacer flex="1" /> + <label class="larger" id="NormalText" value="&normalText.label;" /> + <spacer flex="1" /> + <label class="larger" id="LinkText" value="&linkText.label;" /> + <spacer flex="1" /> + <label + class="larger" + id="ActiveLinkText" + value="&activeLinkText.label;" + /> + <spacer flex="1" /> + <label + class="larger" + id="VisitedLinkText" + value="&visitedLinkText.label;" + /> + <spacer flex="1" /> + </vbox> + <spacer flex="1" /> + </hbox> + <spacer class="spacer" /> + </html:fieldset> + <spacer class="spacer" /> + <label + control="BackgroundImageInput" + value="&backgroundImage.label;" + tooltiptext="&backgroundImage.tooltip;" + accesskey="&backgroundImage.accessKey;" + /> + <tooltip id="shortenedDataURI"> + <label value="&backgroundImage.shortenedDataURI;" /> + </tooltip> + <html:input + id="BackgroundImageInput" + type="text" + class="uri-element input-inline" + onchange="ChangeBackgroundImage()" + aria-label="&backgroundImage.tooltip;" + /> + <hbox align="center"> + <checkbox + id="MakeRelativeCheckbox" + for="BackgroundImageInput" + label="&makeUrlRelative.label;" + accesskey="&makeUrlRelative.accessKey;" + oncommand="MakeInputValueRelativeOrAbsolute(this);" + tooltiptext="&makeUrlRelative.tooltip;" + /> + <spacer flex="1" /> + <button + id="ChooseFile" + oncommand="chooseFile()" + label="&chooseFileButton.label;" + accesskey="&chooseFileButton.accessKey;" + /> + </hbox> + <spacer class="smallspacer" /> + <hbox> + <spacer flex="1" /> + <button + id="AdvancedEditButton" + oncommand="onAdvancedEdit();" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + <separator class="groove" /> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdConvertToTable.js b/comm/mail/components/compose/content/dialogs/EdConvertToTable.js new file mode 100644 index 0000000000..e7f19cff67 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdConvertToTable.js @@ -0,0 +1,325 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +var gIndex; +var gCommaIndex = "0"; +var gSpaceIndex = "1"; +var gOtherIndex = "2"; + +// dialog initialization code +function Startup() { + if (!GetCurrentEditor()) { + window.close(); + return; + } + + gDialog.sepRadioGroup = document.getElementById("SepRadioGroup"); + gDialog.sepCharacterInput = document.getElementById("SepCharacterInput"); + gDialog.deleteSepCharacter = document.getElementById("DeleteSepCharacter"); + gDialog.collapseSpaces = document.getElementById("CollapseSpaces"); + + // We persist the user's separator character + gDialog.sepCharacterInput.value = + gDialog.sepRadioGroup.getAttribute("character"); + + gIndex = gDialog.sepRadioGroup.getAttribute("index"); + + switch (gIndex) { + case gCommaIndex: + default: + gDialog.sepRadioGroup.selectedItem = document.getElementById("comma"); + break; + case gSpaceIndex: + gDialog.sepRadioGroup.selectedItem = document.getElementById("space"); + break; + case gOtherIndex: + gDialog.sepRadioGroup.selectedItem = document.getElementById("other"); + break; + } + + // Set initial enable state on character input and "collapse" checkbox + SelectCharacter(gIndex); + + SetWindowLocation(); +} + +function InputSepCharacter() { + var str = gDialog.sepCharacterInput.value; + + // Limit input to 1 character + if (str.length > 1) { + str = str.slice(0, 1); + } + + // We can never allow tag or entity delimiters for separator character + if (str == "<" || str == ">" || str == "&" || str == ";" || str == " ") { + str = ""; + } + + gDialog.sepCharacterInput.value = str; +} + +function SelectCharacter(radioGroupIndex) { + gIndex = radioGroupIndex; + SetElementEnabledById("SepCharacterInput", gIndex == gOtherIndex); + SetElementEnabledById("CollapseSpaces", gIndex == gSpaceIndex); +} + +/* eslint-disable complexity */ +function onAccept() { + var sepCharacter = ""; + switch (gIndex) { + case gCommaIndex: + sepCharacter = ","; + break; + case gSpaceIndex: + sepCharacter = " "; + break; + case gOtherIndex: + sepCharacter = gDialog.sepCharacterInput.value.slice(0, 1); + break; + } + + var editor = GetCurrentEditor(); + var str; + try { + str = editor.outputToString( + "text/html", + kOutputLFLineBreak | kOutputSelectionOnly + ); + } catch (e) {} + if (!str) { + SaveWindowLocation(); + return; + } + + // Replace nbsp with spaces: + str = str.replace(/\u00a0/g, " "); + + // Strip out </p> completely + str = str.replace(/\s*<\/p>\s*/g, ""); + + // Trim whitespace adjacent to <p> and <br> tags + // and replace <p> with <br> + // (which will be replaced with </tr> below) + str = str.replace(/\s*<p>\s*|\s*<br>\s*/g, "<br>"); + + // Trim leading <br>s + str = str.replace(/^(<br>)+/, ""); + + // Trim trailing <br>s + str = str.replace(/(<br>)+$/, ""); + + // Reduce multiple internal <br> to just 1 + // TODO: Maybe add a checkbox to let user decide + // str = str.replace(/(<br>)+/g, "<br>"); + + // Trim leading and trailing spaces + str = str.trim(); + + // Remove all tag contents so we don't replace + // separator character within tags + // Also converts lists to something useful + var stack = []; + var start; + var end; + var searchStart = 0; + var listSeparator = ""; + var listItemSeparator = ""; + var endList = false; + + do { + start = str.indexOf("<", searchStart); + + if (start >= 0) { + end = str.indexOf(">", start + 1); + if (end > start) { + let tagContent = str.slice(start + 1, end).trim(); + + if (/^ol|^ul|^dl/.test(tagContent)) { + // Replace list tag with <BR> to start new row + // at beginning of second or greater list tag + str = str.slice(0, start) + listSeparator + str.slice(end + 1); + if (listSeparator == "") { + listSeparator = "<br>"; + } + + // Reset for list item separation into cells + listItemSeparator = ""; + } else if (/^li|^dt|^dd/.test(tagContent)) { + // Start a new row if this is first item after the ending the last list + if (endList) { + listItemSeparator = "<br>"; + } + + // Start new cell at beginning of second or greater list items + str = str.slice(0, start) + listItemSeparator + str.slice(end + 1); + + if (endList || listItemSeparator == "") { + listItemSeparator = sepCharacter; + } + + endList = false; + } else { + // Find end tags + endList = /^\/ol|^\/ul|^\/dl/.test(tagContent); + if (endList || /^\/li|^\/dt|^\/dd/.test(tagContent)) { + // Strip out tag + str = str.slice(0, start) + str.slice(end + 1); + } else { + // Not a list-related tag: Store tag contents in an array + stack.push(tagContent); + + // Keep the "<" and ">" while removing from source string + start++; + str = str.slice(0, start) + str.slice(end); + } + } + } + searchStart = start + 1; + } + } while (start >= 0); + + // Replace separator characters with table cells + var replaceString; + if (gDialog.deleteSepCharacter.checked) { + replaceString = ""; + } else { + // Don't delete separator character, + // so include it at start of string to replace + replaceString = sepCharacter; + } + + replaceString += "<td>"; + + if (sepCharacter.length > 0) { + var tempStr = sepCharacter; + var regExpChars = ".!@#$%^&*-+[]{}()|\\/"; + if (regExpChars.includes(sepCharacter)) { + tempStr = "\\" + sepCharacter; + } + + if (gIndex == gSpaceIndex) { + // If checkbox is checked, + // one or more adjacent spaces are one separator + if (gDialog.collapseSpaces.checked) { + tempStr = "\\s+"; + } else { + tempStr = "\\s"; + } + } + var pattern = new RegExp(tempStr, "g"); + str = str.replace(pattern, replaceString); + } + + // Put back tag contents that we removed above + searchStart = 0; + var stackIndex = 0; + do { + start = str.indexOf("<", searchStart); + end = start + 1; + if (start >= 0 && str.charAt(end) == ">") { + // We really need a FIFO stack! + str = str.slice(0, end) + stack[stackIndex++] + str.slice(end); + } + searchStart = end; + } while (start >= 0); + + // End table row and start another for each br or p + str = str.replace(/\s*<br>\s*/g, "</tr>\n<tr><td>"); + + // Add the table tags and the opening and closing tr/td tags + // Default table attributes should be same as those used in nsHTMLEditor::CreateElementWithDefaults() + // (Default width="100%" is used in EdInsertTable.js) + str = + '<table border="1" width="100%" cellpadding="2" cellspacing="2">\n<tr><td>' + + str + + "</tr>\n</table>\n"; + + editor.beginTransaction(); + + // Delete the selection -- makes it easier to find where table will insert + var nodeBeforeTable = null; + var nodeAfterTable = null; + try { + editor.deleteSelection(editor.eNone, editor.eStrip); + + var anchorNodeBeforeInsert = editor.selection.anchorNode; + var offset = editor.selection.anchorOffset; + if (anchorNodeBeforeInsert.nodeType == Node.TEXT_NODE) { + // Text was split. Table should be right after the first or before + nodeBeforeTable = anchorNodeBeforeInsert.previousSibling; + nodeAfterTable = anchorNodeBeforeInsert; + } else { + // Table should be inserted right after node pointed to by selection + if (offset > 0) { + nodeBeforeTable = anchorNodeBeforeInsert.childNodes.item(offset - 1); + } + + nodeAfterTable = anchorNodeBeforeInsert.childNodes.item(offset); + } + + editor.insertHTML(str); + } catch (e) {} + + var table = null; + if (nodeAfterTable) { + var previous = nodeAfterTable.previousSibling; + if (previous && previous.nodeName.toLowerCase() == "table") { + table = previous; + } + } + if (!table && nodeBeforeTable) { + var next = nodeBeforeTable.nextSibling; + if (next && next.nodeName.toLowerCase() == "table") { + table = next; + } + } + + if (table) { + // Fixup table only if pref is set + var firstRow; + try { + if (Services.prefs.getBoolPref("editor.table.maintain_structure")) { + editor.normalizeTable(table); + } + + firstRow = editor.getFirstRow(table); + } catch (e) {} + + // Put caret in first cell + if (firstRow) { + var node2 = firstRow.firstChild; + do { + if ( + node2.nodeName.toLowerCase() == "td" || + node2.nodeName.toLowerCase() == "th" + ) { + try { + editor.selection.collapse(node2, 0); + } catch (e) {} + break; + } + node2 = node2.nextSibling; + } while (node2); + } + } + + editor.endTransaction(); + + // Save persisted attributes + gDialog.sepRadioGroup.setAttribute("index", gIndex); + if (gIndex == gOtherIndex) { + gDialog.sepRadioGroup.setAttribute("character", sepCharacter); + } + + SaveWindowLocation(); +} +/* eslint-enable complexity */ diff --git a/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml b/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml new file mode 100644 index 0000000000..6f2d9ad5b1 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdConvertToTable.xhtml @@ -0,0 +1,86 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EdConvertToTable.dtd"> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="Startup()" + lightweightthemes="true" + style="min-width: 20em" +> + <dialog> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <!--- Element-specific methods --> + <script src="chrome://messenger/content/messengercompose/EdConvertToTable.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + <description class="wrap" flex="1">&instructions1.label;</description> + <description class="wrap" flex="1">&instructions2.label;</description> + <radiogroup + id="SepRadioGroup" + persist="index character" + index="0" + character="" + > + <radio + id="comma" + label="&commaRadio.label;" + oncommand="SelectCharacter('0');" + /> + <radio + id="space" + label="&spaceRadio.label;" + oncommand="SelectCharacter('1');" + /> + <hbox> + <spacer class="radio-spacer" /> + <checkbox + id="CollapseSpaces" + label="&collapseSpaces.label;" + checked="true" + persist="checked" + tooltiptext="&collapseSpaces.tooltip;" + /> + </hbox> + <hbox align="center"> + <radio + id="other" + label="&otherRadio.label;" + oncommand="SelectCharacter('2');" + /> + <html:input + id="SepCharacterInput" + type="text" + aria-labelledby="other" + class="narrow input-inline" + oninput="InputSepCharacter()" + /> + </hbox> + </radiogroup> + <spacer class="spacer" /> + <checkbox + id="DeleteSepCharacter" + label="&deleteCharCheck.label;" + persist="checked" + /> + <spacer class="spacer" /> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdDialogCommon.js b/comm/mail/components/compose/content/dialogs/EdDialogCommon.js new file mode 100644 index 0000000000..ce377e4bbf --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdDialogCommon.js @@ -0,0 +1,679 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Each editor window must include this file + +/* import-globals-from ../editorUtilities.js */ +/* globals InitDialog, ChangeLinkLocation, ValidateData */ + +// Object to attach commonly-used widgets (all dialogs should use this) +var gDialog = {}; + +var gHaveDocumentUrl = false; +var gValidationError = false; + +// Use for 'defaultIndex' param in InitPixelOrPercentMenulist +const gPixel = 0; +const gPercent = 1; + +const gMaxPixels = 100000; // Used for image size, borders, spacing, and padding +// Gecko code uses 1000 for maximum rowspan, colspan +// Also, editing performance is really bad above this +const gMaxRows = 1000; +const gMaxColumns = 1000; +const gMaxTableSize = 1000000; // Width or height of table or cells + +// A XUL element with id="location" for managing +// dialog location relative to parent window +var gLocation; + +// The element being edited - so AdvancedEdit can have access to it +var globalElement; + +/* Validate contents of an input field + * + * inputWidget The 'input' element for the the attribute's value + * listWidget The 'menulist' XUL element for choosing "pixel" or "percent" + * May be null when no pixel/percent is used. + * minVal minimum allowed for input widget's value + * maxVal maximum allowed for input widget's value + * (when "listWidget" is used, maxVal is used for "pixel" maximum, + * 100% is assumed if "percent" is the user's choice) + * element The DOM element that we set the attribute on. May be null. + * attName Name of the attribute to set. May be null or ignored if "element" is null + * mustHaveValue If true, error dialog is displayed if "value" is empty string + * + * This calls "ValidateNumberRange()", which puts up an error dialog to inform the user. + * If error, we also: + * Shift focus and select contents of the inputWidget, + * Switch to appropriate panel of tabbed dialog if user implements "SwitchToValidate()", + * and/or will expand the dialog to full size if "More / Fewer" feature is implemented + * + * Returns the "value" as a string, or "" if error or input contents are empty + * The global "gValidationError" variable is set true if error was found + */ +function ValidateNumber( + inputWidget, + listWidget, + minVal, + maxVal, + element, + attName, + mustHaveValue +) { + if (!inputWidget) { + gValidationError = true; + return ""; + } + + // Global error return value + gValidationError = false; + var maxLimit = maxVal; + var isPercent = false; + + var numString = TrimString(inputWidget.value); + if (numString || mustHaveValue) { + if (listWidget) { + isPercent = listWidget.selectedIndex == 1; + } + if (isPercent) { + maxLimit = 100; + } + + // This method puts up the error message + numString = ValidateNumberRange(numString, minVal, maxLimit, mustHaveValue); + if (!numString) { + // Switch to appropriate panel for error reporting + SwitchToValidatePanel(); + + // Error - shift to offending input widget + SetTextboxFocus(inputWidget); + gValidationError = true; + } else { + if (isPercent) { + numString += "%"; + } + if (element) { + GetCurrentEditor().setAttributeOrEquivalent( + element, + attName, + numString, + true + ); + } + } + } else if (element) { + GetCurrentEditor().removeAttributeOrEquivalent(element, attName, true); + } + return numString; +} + +/* Validate contents of an input field + * + * value number to validate + * minVal minimum allowed for input widget's value + * maxVal maximum allowed for input widget's value + * (when "listWidget" is used, maxVal is used for "pixel" maximum, + * 100% is assumed if "percent" is the user's choice) + * mustHaveValue If true, error dialog is displayed if "value" is empty string + * + * If inputWidget's value is outside of range, or is empty when "mustHaveValue" = true, + * an error dialog is popuped up to inform the user. The focus is shifted + * to the inputWidget. + * + * Returns the "value" as a string, or "" if error or input contents are empty + * The global "gValidationError" variable is set true if error was found + */ +function ValidateNumberRange(value, minValue, maxValue, mustHaveValue) { + // Initialize global error flag + gValidationError = false; + value = TrimString(String(value)); + + // We don't show error for empty string unless caller wants to + if (!value && !mustHaveValue) { + return ""; + } + + var numberStr = ""; + + if (value.length > 0) { + // Extract just numeric characters + var number = Number(value.replace(/\D+/g, "")); + if (number >= minValue && number <= maxValue) { + // Return string version of the number + return String(number); + } + numberStr = String(number); + } + + var message = ""; + + if (numberStr.length > 0) { + // We have a number from user outside of allowed range + message = GetString("ValidateRangeMsg"); + message = message.replace(/%n%/, numberStr); + message += "\n "; + } + message += GetString("ValidateNumberMsg"); + + // Replace variable placeholders in message with number values + message = message.replace(/%min%/, minValue).replace(/%max%/, maxValue); + ShowInputErrorMessage(message); + + // Return an empty string to indicate error + gValidationError = true; + return ""; +} + +function SetTextboxFocusById(id) { + SetTextboxFocus(document.getElementById(id)); +} + +function SetTextboxFocus(input) { + if (input) { + input.focus(); + } +} + +function ShowInputErrorMessage(message) { + Services.prompt.alert(window, GetString("InputError"), message); + window.focus(); +} + +// Get the text appropriate to parent container +// to determine what a "%" value is referring to. +// elementForAtt is element we are actually setting attributes on +// (a temporary copy of element in the doc to allow canceling), +// but elementInDoc is needed to find parent context in document +function GetAppropriatePercentString(elementForAtt, elementInDoc) { + var editor = GetCurrentEditor(); + try { + var name = elementForAtt.nodeName.toLowerCase(); + if (name == "td" || name == "th") { + return GetString("PercentOfTable"); + } + + // Check if element is within a table cell + if (editor.getElementOrParentByTagName("td", elementInDoc)) { + return GetString("PercentOfCell"); + } + return GetString("PercentOfWindow"); + } catch (e) { + return ""; + } +} + +function ClearListbox(listbox) { + if (listbox) { + listbox.clearSelection(); + while (listbox.hasChildNodes()) { + listbox.lastChild.remove(); + } + } +} + +function forceInteger(elementID) { + var editField = document.getElementById(elementID); + if (!editField) { + return; + } + + var stringIn = editField.value; + if (stringIn && stringIn.length > 0) { + // Strip out all nonnumeric characters + stringIn = stringIn.replace(/\D+/g, ""); + if (!stringIn) { + stringIn = ""; + } + + // Write back only if changed + if (stringIn != editField.value) { + editField.value = stringIn; + } + } +} + +function InitPixelOrPercentMenulist( + elementForAtt, + elementInDoc, + attribute, + menulistID, + defaultIndex +) { + if (!defaultIndex) { + defaultIndex = gPixel; + } + + // var size = elementForAtt.getAttribute(attribute); + var size = GetHTMLOrCSSStyleValue(elementForAtt, attribute, attribute); + var menulist = document.getElementById(menulistID); + var pixelItem; + var percentItem; + + if (!menulist) { + dump("NO MENULIST found for ID=" + menulistID + "\n"); + return size; + } + + menulist.removeAllItems(); + pixelItem = menulist.appendItem(GetString("Pixels")); + + if (!pixelItem) { + return 0; + } + + percentItem = menulist.appendItem( + GetAppropriatePercentString(elementForAtt, elementInDoc) + ); + if (size && size.length > 0) { + // Search for a "%" or "px" + if (size.includes("%")) { + // Strip out the % + size = size.substr(0, size.indexOf("%")); + if (percentItem) { + menulist.selectedItem = percentItem; + } + } else { + if (size.includes("px")) { + // Strip out the px + size = size.substr(0, size.indexOf("px")); + } + menulist.selectedItem = pixelItem; + } + } else { + menulist.selectedIndex = defaultIndex; + } + + return size; +} + +function onAdvancedEdit() { + // First validate data from widgets in the "simpler" property dialog + if (ValidateData()) { + // Set true if OK is clicked in the Advanced Edit dialog + window.AdvancedEditOK = false; + // Open the AdvancedEdit dialog, passing in the element to be edited + // (the copy named "globalElement") + window.openDialog( + "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml", + "_blank", + "chrome,close,titlebar,modal,resizable=yes", + "", + globalElement + ); + window.focus(); + if (window.AdvancedEditOK) { + // Copy edited attributes to the dialog widgets: + InitDialog(); + } + } +} + +function getColor(ColorPickerID) { + var colorPicker = document.getElementById(ColorPickerID); + var color; + if (colorPicker) { + // Extract color from colorPicker and assign to colorWell. + color = colorPicker.getAttribute("color"); + if (color && color == "") { + return null; + } + // Clear color so next if it's called again before + // color picker is actually used, we dedect the "don't set color" state + colorPicker.setAttribute("color", ""); + } + + return color; +} + +function setColorWell(ColorWellID, color) { + var colorWell = document.getElementById(ColorWellID); + if (colorWell) { + if (!color || color == "") { + // Don't set color (use default) + // Trigger change to not show color swatch + colorWell.setAttribute("default", "true"); + // Style in CSS sets "background-color", + // but color won't clear unless we do this: + colorWell.removeAttribute("style"); + } else { + colorWell.removeAttribute("default"); + // Use setAttribute so colorwell can be a XUL element, such as button + colorWell.setAttribute("style", "background-color:" + color); + } + } +} + +function SwitchToValidatePanel() { + // no default implementation + // Only EdTableProps.js currently implements this +} + +/** + * @returns {Promise} URL spec of the file chosen, or null + */ +function GetLocalFileURL(filterType) { + var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + var fileType = "html"; + + if (filterType == "img") { + fp.init(window, GetString("SelectImageFile"), Ci.nsIFilePicker.modeOpen); + fp.appendFilters(Ci.nsIFilePicker.filterImages); + fileType = "image"; + } else if (filterType.startsWith("html")) { + // Current usage of this is in Link dialog, + // where we always want HTML first + fp.init(window, GetString("OpenHTMLFile"), Ci.nsIFilePicker.modeOpen); + + // When loading into Composer, direct user to prefer HTML files and text files, + // so we call separately to control the order of the filter list + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.appendFilters(Ci.nsIFilePicker.filterText); + + // Link dialog also allows linking to images + if (filterType.includes("img", 1)) { + fp.appendFilters(Ci.nsIFilePicker.filterImages); + } + } + // Default or last filter is "All Files" + fp.appendFilters(Ci.nsIFilePicker.filterAll); + + // set the file picker's current directory to last-opened location saved in prefs + SetFilePickerDirectory(fp, fileType); + + return new Promise(resolve => { + fp.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !fp.file) { + resolve(null); + return; + } + SaveFilePickerDirectory(fp, fileType); + resolve(fp.fileURL.spec); + }); + }); +} + +function SetWindowLocation() { + gLocation = document.getElementById("location"); + if (gLocation) { + const screenX = Math.max( + 0, + Math.min( + window.opener.screenX + Number(gLocation.getAttribute("offsetX")), + screen.availWidth - window.outerWidth + ) + ); + const screenY = Math.max( + 0, + Math.min( + window.opener.screenY + Number(gLocation.getAttribute("offsetY")), + screen.availHeight - window.outerHeight + ) + ); + window.moveTo(screenX, screenY); + } +} + +function SaveWindowLocation() { + if (gLocation) { + gLocation.setAttribute("offsetX", window.screenX - window.opener.screenX); + gLocation.setAttribute("offsetY", window.screenY - window.opener.screenY); + } +} + +function onCancel() { + SaveWindowLocation(); +} + +function SetRelativeCheckbox(checkbox) { + if (!checkbox) { + checkbox = document.getElementById("MakeRelativeCheckbox"); + if (!checkbox) { + return; + } + } + + var editor = GetCurrentEditor(); + // Mail never allows relative URLs, so hide the checkbox + if (editor && editor.flags & Ci.nsIEditor.eEditorMailMask) { + checkbox.collapsed = true; + return; + } + + var input = document.getElementById(checkbox.getAttribute("for")); + if (!input) { + return; + } + + var url = TrimString(input.value); + var urlScheme = GetScheme(url); + + // Check it if url is relative (no scheme). + checkbox.checked = url.length > 0 && !urlScheme; + + // Now do checkbox enabling: + var enable = false; + + var docUrl = GetDocumentBaseUrl(); + var docScheme = GetScheme(docUrl); + + if (url && docUrl && docScheme) { + if (urlScheme) { + // Url is absolute + // If we can make a relative URL, then enable must be true! + // (this lets the smarts of MakeRelativeUrl do all the hard work) + enable = GetScheme(MakeRelativeUrl(url)).length == 0; + } else if (url[0] == "#") { + // Url is relative + // Check if url is a named anchor + // but document doesn't have a filename + // (it's probably "index.html" or "index.htm", + // but we don't want to allow a malformed URL) + var docFilename = GetFilename(docUrl); + enable = docFilename.length > 0; + } else { + // Any other url is assumed + // to be ok to try to make absolute + enable = true; + } + } + + SetElementEnabled(checkbox, enable); +} + +// oncommand handler for the Relativize checkbox in EditorOverlay.xhtml +function MakeInputValueRelativeOrAbsolute(checkbox) { + var input = document.getElementById(checkbox.getAttribute("for")); + if (!input) { + return; + } + + var docUrl = GetDocumentBaseUrl(); + if (!docUrl) { + // Checkbox should be disabled if not saved, + // but keep this error message in case we change that + Services.prompt.alert(window, "", GetString("SaveToUseRelativeUrl")); + window.focus(); + } else { + // Note that "checked" is opposite of its last state, + // which determines what we want to do here + if (checkbox.checked) { + input.value = MakeRelativeUrl(input.value); + } else { + input.value = MakeAbsoluteUrl(input.value); + } + + // Reset checkbox to reflect url state + SetRelativeCheckbox(checkbox); + } +} + +var IsBlockParent = [ + "applet", + "blockquote", + "body", + "center", + "dd", + "div", + "form", + "li", + "noscript", + "object", + "td", + "th", +]; + +var NotAnInlineParent = [ + "col", + "colgroup", + "dl", + "dir", + "menu", + "ol", + "table", + "tbody", + "tfoot", + "thead", + "tr", + "ul", +]; + +function FillLinkMenulist(linkMenulist, headingsArray) { + var editor = GetCurrentEditor(); + try { + var treeWalker = editor.document.createTreeWalker( + editor.document, + 1, + null, + true + ); + var headingList = []; + var anchorList = []; // for sorting + var anchorMap = {}; // for weeding out duplicates and making heading anchors unique + var anchor; + var i; + for ( + var element = treeWalker.nextNode(); + element; + element = treeWalker.nextNode() + ) { + // grab headings + // Skip headings that already have a named anchor as their first child + // (this may miss nearby anchors, but at least we don't insert another + // under the same heading) + if ( + HTMLHeadingElement.isInstance(element) && + element.textContent && + !( + HTMLAnchorElement.isInstance(element.firstChild) && + element.firstChild.name + ) + ) { + headingList.push(element); + } + + // grab named anchors + if (HTMLAnchorElement.isInstance(element) && element.name) { + anchor = "#" + element.name; + if (!(anchor in anchorMap)) { + anchorList.push({ anchor, sortkey: anchor.toLowerCase() }); + anchorMap[anchor] = true; + } + } + + // grab IDs + if (element.id) { + anchor = "#" + element.id; + if (!(anchor in anchorMap)) { + anchorList.push({ anchor, sortkey: anchor.toLowerCase() }); + anchorMap[anchor] = true; + } + } + } + // add anchor for headings + for (i = 0; i < headingList.length; i++) { + var heading = headingList[i]; + + // Use just first 40 characters, don't add "...", + // and replace whitespace with "_" and strip non-word characters + anchor = + "#" + + ConvertToCDATAString( + TruncateStringAtWordEnd(heading.textContent, 40, false) + ); + + // Append "_" to any name already in the list + while (anchor in anchorMap) { + anchor += "_"; + } + anchorList.push({ anchor, sortkey: anchor.toLowerCase() }); + anchorMap[anchor] = true; + + // Save nodes in an array so we can create anchor node under it later + headingsArray[anchor] = heading; + } + let menuItems = []; + if (anchorList.length) { + // case insensitive sort + anchorList.sort((a, b) => { + if (a.sortkey < b.sortkey) { + return -1; + } + if (a.sortkey > b.sortkey) { + return 1; + } + return 0; + }); + for (i = 0; i < anchorList.length; i++) { + menuItems.push(createMenuItem(anchorList[i].anchor)); + } + } else { + // Don't bother with named anchors in Mail. + if (editor && editor.flags & Ci.nsIEditor.eEditorMailMask) { + linkMenulist.removeAttribute("enablehistory"); + return; + } + let item = createMenuItem(GetString("NoNamedAnchorsOrHeadings")); + item.setAttribute("disabled", "true"); + menuItems.push(item); + } + window.addEventListener("contextmenu", event => { + if (document.getElementById("datalist-menuseparator")) { + return; + } + let menuseparator = document.createXULElement("menuseparator"); + menuseparator.setAttribute("id", "datalist-menuseparator"); + document.getElementById("textbox-contextmenu").appendChild(menuseparator); + for (let menuitem of menuItems) { + document.getElementById("textbox-contextmenu").appendChild(menuitem); + } + }); + } catch (e) {} +} + +function createMenuItem(label) { + var menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("label", label); + menuitem.addEventListener("click", event => { + gDialog.hrefInput.value = label; + ChangeLinkLocation(); + }); + return menuitem; +} + +// Shared by Image and Link dialogs for the "Choose" button for links +function chooseLinkFile() { + GetLocalFileURL("html, img").then(fileURL => { + // Always try to relativize local file URLs + if (gHaveDocumentUrl) { + fileURL = MakeRelativeUrl(fileURL); + } + + gDialog.hrefInput.value = fileURL; + + // Do stuff specific to a particular dialog + // (This is defined separately in Image and Link dialogs) + ChangeLinkLocation(); + }); +} diff --git a/comm/mail/components/compose/content/dialogs/EdDictionary.js b/comm/mail/components/compose/content/dialogs/EdDictionary.js new file mode 100644 index 0000000000..a79a01469c --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdDictionary.js @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var gSpellChecker; +var gWordToAdd; + +function Startup() { + if (!GetCurrentEditor()) { + window.close(); + return; + } + // Get the SpellChecker shell + if ("gSpellChecker" in window.opener && window.opener.gSpellChecker) { + gSpellChecker = window.opener.gSpellChecker; + } + + if (!gSpellChecker) { + dump("SpellChecker not found!!!\n"); + window.close(); + return; + } + // The word to add word is passed as the 2nd extra parameter in window.openDialog() + gWordToAdd = window.arguments[1]; + + gDialog.WordInput = document.getElementById("WordInput"); + gDialog.DictionaryList = document.getElementById("DictionaryList"); + + gDialog.WordInput.value = gWordToAdd; + FillDictionaryList(); + + // Select the supplied word if it is already in the list + SelectWordToAddInList(); + SetTextboxFocus(gDialog.WordInput); +} + +function ValidateWordToAdd() { + gWordToAdd = TrimString(gDialog.WordInput.value); + if (gWordToAdd.length > 0) { + return true; + } + return false; +} + +function SelectWordToAddInList() { + for (var i = 0; i < gDialog.DictionaryList.getRowCount(); i++) { + var wordInList = gDialog.DictionaryList.getItemAtIndex(i); + if (wordInList && gWordToAdd == wordInList.label) { + gDialog.DictionaryList.selectedIndex = i; + break; + } + } +} + +function AddWord() { + if (ValidateWordToAdd()) { + try { + gSpellChecker.AddWordToDictionary(gWordToAdd); + } catch (e) { + dump( + "Exception occurred in gSpellChecker.AddWordToDictionary\nWord to add probably already existed\n" + ); + } + + // Rebuild the dialog list + FillDictionaryList(); + + SelectWordToAddInList(); + gDialog.WordInput.value = ""; + } +} + +function RemoveWord() { + var selIndex = gDialog.DictionaryList.selectedIndex; + if (selIndex >= 0) { + var word = gDialog.DictionaryList.selectedItem.label; + + // Remove word from list + gDialog.DictionaryList.selectedItem.remove(); + + // Remove from dictionary + try { + // Not working: BUG 43348 + gSpellChecker.RemoveWordFromDictionary(word); + } catch (e) { + dump("Failed to remove word from dictionary\n"); + } + + ResetSelectedItem(selIndex); + } +} + +function FillDictionaryList() { + var selIndex = gDialog.DictionaryList.selectedIndex; + + // Clear the current contents of the list + ClearListbox(gDialog.DictionaryList); + + // Get the list from the spell checker + gSpellChecker.GetPersonalDictionary(); + + var haveList = false; + + // Get words until an empty string is returned + do { + var word = gSpellChecker.GetPersonalDictionaryWord(); + if (word != "") { + gDialog.DictionaryList.appendItem(word, ""); + haveList = true; + } + } while (word != ""); + + // XXX: BUG 74467: If list is empty, it doesn't layout to full height correctly + // (ignores "rows" attribute) (bug is latered, so we are fixing here for now) + if (!haveList) { + gDialog.DictionaryList.appendItem("", ""); + } + + ResetSelectedItem(selIndex); +} + +function ResetSelectedItem(index) { + var lastIndex = gDialog.DictionaryList.getRowCount() - 1; + if (index > lastIndex) { + index = lastIndex; + } + + // If we didn't have a selected item, + // set it to the first item + if (index == -1 && lastIndex >= 0) { + index = 0; + } + + gDialog.DictionaryList.selectedIndex = index; +} diff --git a/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml b/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml new file mode 100644 index 0000000000..c5c33212a9 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdDictionary.xhtml @@ -0,0 +1,88 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorPersonalDictionary.dtd"> +<window + id="dictionaryDlg" + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + persist="screenX screenY" + lightweightthemes="true" + onload="Startup()" +> + <dialog + buttonlabelaccept="&CloseButton.label;" + buttonaccesskeyaccept="&CloseButton.accessKey;" + buttons="accept" + > + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdDictionary.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <hbox flex="1"> + <div xmlns="http://www.w3.org/1999/xhtml" class="grid-two-column"> + <div class="flex-items-center grid-item-span-row"> + <xul:label + id="WordInputLabel" + value="&wordEditField.label;" + control="WordInput" + accesskey="&wordEditField.accessKey;" + /> + </div> + <div> + <input + id="WordInput" + type="text" + style="width: 14.5em" + aria-labelledby="WordInputLabel" + /> + </div> + <div> + <xul:button + id="AddWord" + oncommand="AddWord()" + label="&AddButton.label;" + accesskey="&AddButton.accessKey;" + /> + </div> + <div class="flex-items-center grid-item-span-row"> + <xul:label + value="&DictionaryList.label;" + control="DictionaryList" + accesskey="&DictionaryList.accessKey;" + /> + </div> + <div> + <xul:richlistbox + id="DictionaryList" + style="width: 15em; height: 10em" + /> + </div> + <div> + <xul:button + id="RemoveWord" + oncommand="RemoveWord()" + label="&RemoveButton.label;" + accesskey="&RemoveButton.accessKey;" + /> + </div> + </div> + </hbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdHLineProps.js b/comm/mail/components/compose/content/dialogs/EdHLineProps.js new file mode 100644 index 0000000000..4a5393d1dc --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdHLineProps.js @@ -0,0 +1,227 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var tagName = "hr"; +var gHLineElement; +var width; +var height; +var align; +var shading; +const gMaxHRSize = 1000; // This is hard-coded in nsHTMLHRElement::StringToAttribute() + +// dialog initialization code + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + var editor = GetCurrentEditor(); + if (!editor) { + window.close(); + return; + } + try { + // Get the selected horizontal line + gHLineElement = editor.getSelectedElement(tagName); + } catch (e) {} + + if (!gHLineElement) { + // We should never be here if not editing an existing HLine + window.close(); + return; + } + gDialog.heightInput = document.getElementById("height"); + gDialog.widthInput = document.getElementById("width"); + gDialog.leftAlign = document.getElementById("leftAlign"); + gDialog.centerAlign = document.getElementById("centerAlign"); + gDialog.rightAlign = document.getElementById("rightAlign"); + gDialog.alignGroup = gDialog.rightAlign.radioGroup; + gDialog.shading = document.getElementById("3dShading"); + gDialog.pixelOrPercentMenulist = document.getElementById( + "pixelOrPercentMenulist" + ); + + // Make a copy to use for AdvancedEdit and onSaveDefault + globalElement = gHLineElement.cloneNode(false); + + // Initialize control values based on existing attributes + InitDialog(); + + // SET FOCUS TO FIRST CONTROL + SetTextboxFocus(gDialog.widthInput); + + // Resize window + window.sizeToContent(); + + SetWindowLocation(); +} + +// Set dialog widgets with attribute data +// We get them from globalElement copy so this can be used +// by AdvancedEdit(), which is shared by all property dialogs +function InitDialog() { + // Just to be confusing, "size" is used instead of height because it does + // not accept % values, only pixels + var height = GetHTMLOrCSSStyleValue(globalElement, "size", "height"); + if (height.includes("px")) { + height = height.substr(0, height.indexOf("px")); + } + if (!height) { + height = 2; // Default value + } + + // We will use "height" here and in UI + gDialog.heightInput.value = height; + + // Get the width attribute of the element, stripping out "%" + // This sets contents of menulist (adds pixel and percent menuitems elements) + gDialog.widthInput.value = InitPixelOrPercentMenulist( + globalElement, + gHLineElement, + "width", + "pixelOrPercentMenulist" + ); + + var marginLeft = GetHTMLOrCSSStyleValue( + globalElement, + "align", + "margin-left" + ).toLowerCase(); + var marginRight = GetHTMLOrCSSStyleValue( + globalElement, + "align", + "margin-right" + ).toLowerCase(); + align = marginLeft + " " + marginRight; + gDialog.leftAlign.checked = align == "left left" || align == "0px auto"; + gDialog.centerAlign.checked = + align == "center center" || align == "auto auto" || align == " "; + gDialog.rightAlign.checked = align == "right right" || align == "auto 0px"; + + if (gDialog.centerAlign.checked) { + gDialog.alignGroup.selectedItem = gDialog.centerAlign; + } else if (gDialog.rightAlign.checked) { + gDialog.alignGroup.selectedItem = gDialog.rightAlign; + } else { + gDialog.alignGroup.selectedItem = gDialog.leftAlign; + } + + gDialog.shading.checked = !globalElement.hasAttribute("noshade"); +} + +function onSaveDefault() { + // "false" means set attributes on the globalElement, + // not the real element being edited + if (ValidateData()) { + var alignInt; + if (align == "left") { + alignInt = 0; + } else if (align == "right") { + alignInt = 2; + } else { + alignInt = 1; + } + Services.prefs.setIntPref("editor.hrule.align", alignInt); + + var percent; + var widthInt; + var heightInt; + + if (width) { + if (width.includes("%")) { + percent = true; + widthInt = Number(width.substr(0, width.indexOf("%"))); + } else { + percent = false; + widthInt = Number(width); + } + } else { + percent = true; + widthInt = Number(100); + } + + heightInt = height ? Number(height) : 2; + + Services.prefs.setIntPref("editor.hrule.width", widthInt); + Services.prefs.setBoolPref("editor.hrule.width_percent", percent); + Services.prefs.setIntPref("editor.hrule.height", heightInt); + Services.prefs.setBoolPref("editor.hrule.shading", shading); + + // Write the prefs out NOW! + Services.prefs.savePrefFile(null); + } +} + +// Get and validate data from widgets. +// Set attributes on globalElement so they can be accessed by AdvancedEdit() +function ValidateData() { + // Height is always pixels + height = ValidateNumber( + gDialog.heightInput, + null, + 1, + gMaxHRSize, + globalElement, + "size", + false + ); + if (gValidationError) { + return false; + } + + width = ValidateNumber( + gDialog.widthInput, + gDialog.pixelOrPercentMenulist, + 1, + gMaxPixels, + globalElement, + "width", + false + ); + if (gValidationError) { + return false; + } + + align = "left"; + if (gDialog.centerAlign.selected) { + // Don't write out default attribute + align = ""; + } else if (gDialog.rightAlign.selected) { + align = "right"; + } + if (align) { + globalElement.setAttribute("align", align); + } else { + try { + GetCurrentEditor().removeAttributeOrEquivalent( + globalElement, + "align", + true + ); + } catch (e) {} + } + + if (gDialog.shading.checked) { + shading = true; + globalElement.removeAttribute("noshade"); + } else { + shading = false; + globalElement.setAttribute("noshade", "noshade"); + } + return true; +} + +function onAccept(event) { + if (ValidateData()) { + // Copy attributes from the globalElement to the document element + try { + GetCurrentEditor().cloneAttributes(gHLineElement, globalElement); + } catch (e) {} + return; + } + event.preventDefault(); +} diff --git a/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml b/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml new file mode 100644 index 0000000000..21fa52147c --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdHLineProps.xhtml @@ -0,0 +1,131 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % edHLineProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorHLineProperties.dtd"> +%edHLineProperties; +<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd"> +%edDialogOverlay; ]> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup()" +> + <dialog> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <!--- Element-specific methods --> + <script src="chrome://messenger/content/messengercompose/EdHLineProps.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <html:fieldset> + <html:legend>&dimensionsBox.label;</html:legend> + <html:table> + <html:tr> + <html:th> + <label + id="widthLabel" + control="width" + value="&widthEditField.label;" + accesskey="&widthEditField.accessKey;" + /> + </html:th> + <html:td> + <html:input + id="width" + type="number" + class="narrow input-inline" + aria-labelledby="widthLabel" + /> + </html:td> + <html:td> + <menulist id="pixelOrPercentMenulist" /> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label + id="heightLabel" + control="height" + value="&heightEditField.label;" + accesskey="&heightEditField.accessKey;" + /> + </html:th> + <html:td> + <html:input + id="height" + type="number" + class="narrow input-inline" + aria-labelledby="heightLabel" + /> + </html:td> + <html:td> + <label value="&pixelsPopup.value;" /> + </html:td> + </html:tr> + </html:table> + <checkbox + id="3dShading" + label="&threeDShading.label;" + accesskey="&threeDShading.accessKey;" + /> + </html:fieldset> + <html:fieldset> + <html:legend>&alignmentBox.label;</html:legend> + <radiogroup id="alignmentGroup" orient="horizontal"> + <spacer class="spacer" /> + <radio + id="leftAlign" + label="&leftRadio.label;" + accesskey="&leftRadio.accessKey;" + /> + <radio + id="centerAlign" + label="¢erRadio.label;" + accesskey="¢erRadio.accessKey;" + /> + <radio + id="rightAlign" + label="&rightRadio.label;" + accesskey="&rightRadio.accessKey;" + /> + </radiogroup> + </html:fieldset> + <spacer class="spacer" /> + <hbox> + <button + id="SaveDefault" + label="&saveSettings.label;" + accesskey="&saveSettings.accessKey;" + oncommand="onSaveDefault()" + tooltiptext="&saveSettings.tooltip;" + /> + <spacer flex="1" /> + <button + id="AdvancedEditButton" + oncommand="onAdvancedEdit();" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + <separator class="groove" /> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdImageDialog.js b/comm/mail/components/compose/content/dialogs/EdImageDialog.js new file mode 100644 index 0000000000..91e558cd50 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdImageDialog.js @@ -0,0 +1,639 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + Note: We encourage non-empty alt text for images inserted into a page. + When there's no alt text, we always write 'alt=""' as the attribute, since "alt" is a required attribute. + We allow users to not have alt text by checking a "Don't use alterate text" radio button, + and we don't accept spaces as valid alt text. A space used to be required to avoid the error message + if user didn't enter alt text, but is unnecessary now that we no longer annoy the user + with the error dialog if alt="" is present on an img element. + We trim all spaces at the beginning and end of user's alt text +*/ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var gInsertNewImage = true; +var gDoAltTextError = false; +var gConstrainOn = false; +// Note used in current version, but these are set correctly +// and could be used to reset width and height used for constrain ratio +var gConstrainWidth = 0; +var gConstrainHeight = 0; +var imageElement; +var gImageMap = 0; +var gCanRemoveImageMap = false; +var gRemoveImageMap = false; +var gImageMapDisabled = false; +var gActualWidth = ""; +var gActualHeight = ""; +var gOriginalSrc = ""; +var gTimerID; +var gValidateTab; +var gInsertNewIMap; + +// These must correspond to values in EditorDialog.css for each theme +// (unfortunately, setting "style" attribute here doesn't work!) +var gPreviewImageWidth = 80; +var gPreviewImageHeight = 50; + +// dialog initialization code + +function ImageStartup() { + gDialog.tabBox = document.getElementById("TabBox"); + gDialog.tabLocation = document.getElementById("imageLocationTab"); + gDialog.tabDimensions = document.getElementById("imageDimensionsTab"); + gDialog.tabBorder = document.getElementById("imageBorderTab"); + gDialog.srcInput = document.getElementById("srcInput"); + gDialog.titleInput = document.getElementById("titleInput"); + gDialog.altTextInput = document.getElementById("altTextInput"); + gDialog.altTextRadioGroup = document.getElementById("altTextRadioGroup"); + gDialog.altTextRadio = document.getElementById("altTextRadio"); + gDialog.noAltTextRadio = document.getElementById("noAltTextRadio"); + gDialog.actualSizeRadio = document.getElementById("actualSizeRadio"); + gDialog.constrainCheckbox = document.getElementById("constrainCheckbox"); + gDialog.widthInput = document.getElementById("widthInput"); + gDialog.heightInput = document.getElementById("heightInput"); + gDialog.widthUnitsMenulist = document.getElementById("widthUnitsMenulist"); + gDialog.heightUnitsMenulist = document.getElementById("heightUnitsMenulist"); + gDialog.imagelrInput = document.getElementById("imageleftrightInput"); + gDialog.imagetbInput = document.getElementById("imagetopbottomInput"); + gDialog.border = document.getElementById("border"); + gDialog.alignTypeSelect = document.getElementById("alignTypeSelect"); + gDialog.PreviewWidth = document.getElementById("PreviewWidth"); + gDialog.PreviewHeight = document.getElementById("PreviewHeight"); + gDialog.PreviewImage = document.getElementById("preview-image"); + gDialog.PreviewImage.addEventListener("load", PreviewImageLoaded); + gDialog.OkButton = document.querySelector("dialog").getButton("accept"); +} + +// Set dialog widgets with attribute data +// We get them from globalElement copy so this can be used +// by AdvancedEdit(), which is shared by all property dialogs +function InitImage() { + // Set the controls to the image's attributes + var src = globalElement.getAttribute("src"); + + // For image insertion the 'src' attribute is null. + if (src) { + // Shorten data URIs for display. + shortenImageData(src, gDialog.srcInput); + } + + // Set "Relativize" checkbox according to current URL state + SetRelativeCheckbox(); + + // Force loading of image from its source and show preview image + LoadPreviewImage(); + + gDialog.titleInput.value = globalElement.getAttribute("title"); + + var hasAltText = globalElement.hasAttribute("alt"); + var altText = globalElement.getAttribute("alt"); + gDialog.altTextInput.value = altText; + if (altText || (!hasAltText && globalElement.hasAttribute("src"))) { + gDialog.altTextRadioGroup.selectedItem = gDialog.altTextRadio; + } else if (hasAltText) { + gDialog.altTextRadioGroup.selectedItem = gDialog.noAltTextRadio; + } + SetAltTextDisabled( + gDialog.altTextRadioGroup.selectedItem == gDialog.noAltTextRadio + ); + + // setup the height and width widgets + var width = InitPixelOrPercentMenulist( + globalElement, + gInsertNewImage ? null : imageElement, + "width", + "widthUnitsMenulist", + gPixel + ); + var height = InitPixelOrPercentMenulist( + globalElement, + gInsertNewImage ? null : imageElement, + "height", + "heightUnitsMenulist", + gPixel + ); + + // Set actual radio button if both set values are the same as actual + SetSizeWidgets(width, height); + + gDialog.widthInput.value = gConstrainWidth = width || gActualWidth || ""; + gDialog.heightInput.value = gConstrainHeight = height || gActualHeight || ""; + + // set spacing editfields + gDialog.imagelrInput.value = globalElement.getAttribute("hspace"); + gDialog.imagetbInput.value = globalElement.getAttribute("vspace"); + + // dialog.border.value = globalElement.getAttribute("border"); + var bv = GetHTMLOrCSSStyleValue(globalElement, "border", "border-top-width"); + if (bv.includes("px")) { + // Strip out the px + bv = bv.substr(0, bv.indexOf("px")); + } else if (bv == "thin") { + bv = "1"; + } else if (bv == "medium") { + bv = "3"; + } else if (bv == "thick") { + bv = "5"; + } + gDialog.border.value = bv; + + // Get alignment setting + var align = globalElement.getAttribute("align"); + if (align) { + align = align.toLowerCase(); + } + + switch (align) { + case "top": + case "middle": + case "right": + case "left": + gDialog.alignTypeSelect.value = align; + break; + default: + // Default or "bottom" + gDialog.alignTypeSelect.value = "bottom"; + } + + // Get image map for image + gImageMap = GetImageMap(); + + doOverallEnabling(); + doDimensionEnabling(); +} + +function SetSizeWidgets(width, height) { + if ( + !(width || height) || + (gActualWidth && + gActualHeight && + width == gActualWidth && + height == gActualHeight) + ) { + gDialog.actualSizeRadio.radioGroup.selectedItem = gDialog.actualSizeRadio; + } + + if (!gDialog.actualSizeRadio.selected) { + // Decide if user's sizes are in the same ratio as actual sizes + if (gActualWidth && gActualHeight) { + if (gActualWidth > gActualHeight) { + gDialog.constrainCheckbox.checked = + Math.round((gActualHeight * width) / gActualWidth) == height; + } else { + gDialog.constrainCheckbox.checked = + Math.round((gActualWidth * height) / gActualHeight) == width; + } + } + } +} + +// Disable alt text input when "Don't use alt" radio is checked +function SetAltTextDisabled(disable) { + gDialog.altTextInput.disabled = disable; +} + +function GetImageMap() { + var usemap = globalElement.getAttribute("usemap"); + if (usemap) { + gCanRemoveImageMap = true; + let mapname = usemap.substr(1); + try { + return GetCurrentEditor().document.querySelector( + '[name="' + mapname + '"]' + ); + } catch (e) {} + } else { + gCanRemoveImageMap = false; + } + + return null; +} + +function chooseFile() { + if (gTimerID) { + clearTimeout(gTimerID); + } + + // Put focus into the input field + SetTextboxFocus(gDialog.srcInput); + + GetLocalFileURL("img").then(fileURL => { + // Always try to relativize local file URLs + if (gHaveDocumentUrl) { + fileURL = MakeRelativeUrl(fileURL); + } + + gDialog.srcInput.value = fileURL; + + SetRelativeCheckbox(); + doOverallEnabling(); + LoadPreviewImage(); + }); +} + +function PreviewImageLoaded() { + if (gDialog.PreviewImage) { + // Image loading has completed -- we can get actual width + gActualWidth = gDialog.PreviewImage.naturalWidth; + gActualHeight = gDialog.PreviewImage.naturalHeight; + + if (gActualWidth && gActualHeight) { + // Use actual size or scale to fit preview if either dimension is too large + var width = gActualWidth; + var height = gActualHeight; + if (gActualWidth > gPreviewImageWidth) { + width = gPreviewImageWidth; + height = gActualHeight * (gPreviewImageWidth / gActualWidth); + } + if (height > gPreviewImageHeight) { + height = gPreviewImageHeight; + width = gActualWidth * (gPreviewImageHeight / gActualHeight); + } + gDialog.PreviewImage.width = width; + gDialog.PreviewImage.height = height; + + gDialog.PreviewWidth.setAttribute("value", gActualWidth); + gDialog.PreviewHeight.setAttribute("value", gActualHeight); + + document.getElementById("imagePreview").hidden = false; + + SetSizeWidgets(gDialog.widthInput.value, gDialog.heightInput.value); + } + + if (gDialog.actualSizeRadio.selected) { + SetActualSize(); + } + + window.sizeToContent(); + } +} + +function LoadPreviewImage() { + var imageSrc = gDialog.srcInput.value.trim(); + if (!imageSrc) { + return; + } + if (isImageDataShortened(imageSrc)) { + imageSrc = restoredImageData(gDialog.srcInput); + } + + try { + // Remove the image URL from image cache so it loads fresh + // (if we don't do this, loads after the first will always use image cache + // and we won't see image edit changes or be able to get actual width and height) + + // We must have an absolute URL to preview it or remove it from the cache + imageSrc = MakeAbsoluteUrl(imageSrc); + + if (GetScheme(imageSrc)) { + let uri = Services.io.newURI(imageSrc); + if (uri) { + let imgCache = Cc["@mozilla.org/image/cache;1"].getService( + Ci.imgICache + ); + + // This returns error if image wasn't in the cache; ignore that + imgCache.removeEntry(uri); + } + } + } catch (e) {} + + gDialog.PreviewImage.addEventListener("load", PreviewImageLoaded, true); + gDialog.PreviewImage.src = imageSrc; +} + +function SetActualSize() { + gDialog.widthInput.value = gActualWidth ? gActualWidth : ""; + gDialog.widthUnitsMenulist.selectedIndex = 0; + gDialog.heightInput.value = gActualHeight ? gActualHeight : ""; + gDialog.heightUnitsMenulist.selectedIndex = 0; + doDimensionEnabling(); +} + +function ChangeImageSrc() { + if (gTimerID) { + clearTimeout(gTimerID); + } + + gTimerID = setTimeout(LoadPreviewImage, 800); + + SetRelativeCheckbox(); + doOverallEnabling(); +} + +function doDimensionEnabling() { + // Enabled unless "Actual Size" is selected + var enable = !gDialog.actualSizeRadio.selected; + + // BUG 74145: After input field is disabled, + // setting it enabled causes blinking caret to appear + // even though focus isn't set to it. + SetElementEnabledById("heightInput", enable); + SetElementEnabledById("heightLabel", enable); + SetElementEnabledById("heightUnitsMenulist", enable); + + SetElementEnabledById("widthInput", enable); + SetElementEnabledById("widthLabel", enable); + SetElementEnabledById("widthUnitsMenulist", enable); + + var constrainEnable = + enable && + gDialog.widthUnitsMenulist.selectedIndex == 0 && + gDialog.heightUnitsMenulist.selectedIndex == 0; + + SetElementEnabledById("constrainCheckbox", constrainEnable); +} + +function doOverallEnabling() { + var enabled = TrimString(gDialog.srcInput.value) != ""; + + SetElementEnabled(gDialog.OkButton, enabled); + SetElementEnabledById("AdvancedEditButton1", enabled); + SetElementEnabledById("imagemapLabel", enabled); + SetElementEnabledById("removeImageMap", gCanRemoveImageMap); +} + +function ToggleConstrain() { + // If just turned on, save the current width and height as basis for constrain ratio + // Thus clicking on/off lets user say "Use these values as aspect ration" + if ( + gDialog.constrainCheckbox.checked && + !gDialog.constrainCheckbox.disabled && + gDialog.widthUnitsMenulist.selectedIndex == 0 && + gDialog.heightUnitsMenulist.selectedIndex == 0 + ) { + gConstrainWidth = Number(TrimString(gDialog.widthInput.value)); + gConstrainHeight = Number(TrimString(gDialog.heightInput.value)); + } +} + +function constrainProportions(srcID, destID) { + var srcElement = document.getElementById(srcID); + if (!srcElement) { + return; + } + + var destElement = document.getElementById(destID); + if (!destElement) { + return; + } + + // always force an integer (whether we are constraining or not) + forceInteger(srcID); + + if ( + !gActualWidth || + !gActualHeight || + !(gDialog.constrainCheckbox.checked && !gDialog.constrainCheckbox.disabled) + ) { + return; + } + + // double-check that neither width nor height is in percent mode; bail if so! + if ( + gDialog.widthUnitsMenulist.selectedIndex != 0 || + gDialog.heightUnitsMenulist.selectedIndex != 0 + ) { + return; + } + + // This always uses the actual width and height ratios + // which is kind of funky if you change one number without the constrain + // and then turn constrain on and change a number + // I prefer the old strategy (below) but I can see some merit to this solution + if (srcID == "widthInput") { + destElement.value = Math.round( + (srcElement.value * gActualHeight) / gActualWidth + ); + } else { + destElement.value = Math.round( + (srcElement.value * gActualWidth) / gActualHeight + ); + } + + /* + // With this strategy, the width and height ratio + // can be reset to whatever the user entered. + if (srcID == "widthInput") { + destElement.value = Math.round( srcElement.value * gConstrainHeight / gConstrainWidth ); + } else { + destElement.value = Math.round( srcElement.value * gConstrainWidth / gConstrainHeight ); + } + */ +} + +function removeImageMap() { + gRemoveImageMap = true; + gCanRemoveImageMap = false; + SetElementEnabledById("removeImageMap", false); +} + +function SwitchToValidatePanel() { + if ( + gDialog.tabBox && + gValidateTab && + gDialog.tabBox.selectedTab != gValidateTab + ) { + gDialog.tabBox.selectedTab = gValidateTab; + } +} + +// Get data from widgets, validate, and set for the global element +// accessible to AdvancedEdit() [in EdDialogCommon.js] +function ValidateImage() { + var editor = GetCurrentEditor(); + if (!editor) { + return false; + } + + gValidateTab = gDialog.tabLocation; + if (!gDialog.srcInput.value) { + Services.prompt.alert( + window, + GetString("Alert"), + GetString("MissingImageError") + ); + SwitchToValidatePanel(); + gDialog.srcInput.focus(); + return false; + } + + // We must convert to "file:///" or "http://" format else image doesn't load! + let src = gDialog.srcInput.value.trim(); + + if (isImageDataShortened(src)) { + src = restoredImageData(gDialog.srcInput); + } else { + var checkbox = document.getElementById("MakeRelativeCheckbox"); + try { + if (checkbox && !checkbox.checked) { + src = Services.uriFixup.createFixupURI( + src, + Ci.nsIURIFixup.FIXUP_FLAG_NONE + ).spec; + } + } catch (e) {} + + globalElement.setAttribute("src", src); + } + + let title = gDialog.titleInput.value.trim(); + if (title) { + globalElement.setAttribute("title", title); + } else { + globalElement.removeAttribute("title"); + } + + // Force user to enter Alt text only if "Alternate text" radio is checked + // Don't allow just spaces in alt text + var alt = ""; + var useAlt = gDialog.altTextRadioGroup.selectedItem == gDialog.altTextRadio; + if (useAlt) { + alt = TrimString(gDialog.altTextInput.value); + } + + if (alt || !useAlt) { + globalElement.setAttribute("alt", alt); + } else if (!gDoAltTextError) { + globalElement.removeAttribute("alt"); + } else { + Services.prompt.alert(window, GetString("Alert"), GetString("NoAltText")); + SwitchToValidatePanel(); + gDialog.altTextInput.focus(); + return false; + } + + var width = ""; + var height = ""; + + gValidateTab = gDialog.tabDimensions; + if (!gDialog.actualSizeRadio.selected) { + // Get user values for width and height + width = ValidateNumber( + gDialog.widthInput, + gDialog.widthUnitsMenulist, + 1, + gMaxPixels, + globalElement, + "width", + false, + true + ); + if (gValidationError) { + return false; + } + + height = ValidateNumber( + gDialog.heightInput, + gDialog.heightUnitsMenulist, + 1, + gMaxPixels, + globalElement, + "height", + false, + true + ); + if (gValidationError) { + return false; + } + } + + // We always set the width and height attributes, even if same as actual. + // This speeds up layout of pages since sizes are known before image is loaded + if (!width) { + width = gActualWidth; + } + if (!height) { + height = gActualHeight; + } + + // Remove existing width and height only if source changed + // and we couldn't obtain actual dimensions + var srcChanged = src != gOriginalSrc; + if (width) { + editor.setAttributeOrEquivalent(globalElement, "width", width, true); + } else if (srcChanged) { + editor.removeAttributeOrEquivalent(globalElement, "width", true); + } + + if (height) { + editor.setAttributeOrEquivalent(globalElement, "height", height, true); + } else if (srcChanged) { + editor.removeAttributeOrEquivalent(globalElement, "height", true); + } + + // spacing attributes + gValidateTab = gDialog.tabBorder; + ValidateNumber( + gDialog.imagelrInput, + null, + 0, + gMaxPixels, + globalElement, + "hspace", + false, + true, + true + ); + if (gValidationError) { + return false; + } + + ValidateNumber( + gDialog.imagetbInput, + null, + 0, + gMaxPixels, + globalElement, + "vspace", + false, + true + ); + if (gValidationError) { + return false; + } + + // note this is deprecated and should be converted to stylesheets + ValidateNumber( + gDialog.border, + null, + 0, + gMaxPixels, + globalElement, + "border", + false, + true + ); + if (gValidationError) { + return false; + } + + // Default or setting "bottom" means don't set the attribute + // Note that the attributes "left" and "right" are opposite + // of what we use in the UI, which describes where the TEXT wraps, + // not the image location (which is what the HTML describes) + switch (gDialog.alignTypeSelect.value) { + case "top": + case "middle": + case "right": + case "left": + editor.setAttributeOrEquivalent( + globalElement, + "align", + gDialog.alignTypeSelect.value, + true + ); + break; + default: + try { + editor.removeAttributeOrEquivalent(globalElement, "align", true); + } catch (e) {} + } + + return true; +} diff --git a/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js b/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js new file mode 100644 index 0000000000..9c41679c15 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdImageLinkLoader.js @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var gMsgCompProcessLink = false; +var gMsgCompInputElement = null; +var gMsgCompPrevInputValue = null; +var gMsgCompPrevMozDoNotSendAttribute; +var gMsgCompAttachSourceElement = null; + +function OnLoadDialog() { + gMsgCompAttachSourceElement = document.getElementById("AttachSourceToMail"); + var editor = GetCurrentEditor(); + if ( + gMsgCompAttachSourceElement && + editor && + editor.flags & Ci.nsIEditor.eEditorMailMask + ) { + SetRelativeCheckbox = function () { + SetAttachCheckbox(); + }; + // initialize the AttachSourceToMail checkbox + gMsgCompAttachSourceElement.hidden = false; + + switch (document.querySelector("dialog").id) { + case "imageDlg": + gMsgCompInputElement = gDialog.srcInput; + gMsgCompProcessLink = false; + break; + case "linkDlg": + gMsgCompInputElement = gDialog.hrefInput; + gMsgCompProcessLink = true; + break; + } + if (gMsgCompInputElement) { + SetAttachCheckbox(); + gMsgCompPrevMozDoNotSendAttribute = + globalElement.getAttribute("moz-do-not-send"); + } + } +} +addEventListener("load", OnLoadDialog, false); + +function OnAcceptDialog() { + // Auto-convert file URLs to data URLs. If we're in the link properties + // dialog convert only when requested - for the image dialog do it always. + if ( + /^file:/i.test(gMsgCompInputElement.value.trim()) && + (gMsgCompAttachSourceElement.checked || !gMsgCompProcessLink) + ) { + var dataURI = GenerateDataURL(gMsgCompInputElement.value.trim()); + gMsgCompInputElement.value = dataURI; + gMsgCompAttachSourceElement.checked = true; + } + DoAttachSourceCheckbox(); +} +document.addEventListener("dialogaccept", OnAcceptDialog, true); + +function SetAttachCheckbox() { + var resetCheckbox = false; + var mozDoNotSend = globalElement.getAttribute("moz-do-not-send"); + + // In case somebody played with the advanced property and changed the moz-do-not-send attribute + if (mozDoNotSend != gMsgCompPrevMozDoNotSendAttribute) { + gMsgCompPrevMozDoNotSendAttribute = mozDoNotSend; + resetCheckbox = true; + } + + // Has the URL changed + if ( + gMsgCompInputElement && + gMsgCompInputElement.value != gMsgCompPrevInputValue + ) { + gMsgCompPrevInputValue = gMsgCompInputElement.value; + resetCheckbox = true; + } + + if (gMsgCompInputElement && resetCheckbox) { + // Here is the rule about how to set the checkbox Attach Source To Message: + // If the attribute "moz-do-not-send" has not been set, we look at the scheme of the URL + // and at some preference to decide what is the best for the user. + // If it is set to "false", the checkbox is checked, otherwise unchecked. + var attach = false; + if (mozDoNotSend == null) { + // We haven't yet set the "moz-do-not-send" attribute. + var inputValue = gMsgCompInputElement.value.trim(); + if (/^(file|data):/i.test(inputValue)) { + // For files or data URLs, default to attach them. + attach = true; + } else if ( + !gMsgCompProcessLink && // Implies image dialogue. + /^https?:/i.test(inputValue) + ) { + // For images loaded via http(s) we default to the preference value. + attach = Services.prefs.getBoolPref("mail.compose.attach_http_images"); + } + } else { + attach = mozDoNotSend == "false"; + } + + gMsgCompAttachSourceElement.checked = attach; + } +} + +function DoAttachSourceCheckbox() { + gMsgCompPrevMozDoNotSendAttribute = + (!gMsgCompAttachSourceElement.checked).toString(); + globalElement.setAttribute( + "moz-do-not-send", + gMsgCompPrevMozDoNotSendAttribute + ); +} + +function GenerateDataURL(url) { + var file = Services.io.newURI(url).QueryInterface(Ci.nsIFileURL).file; + var contentType = Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromFile(file); + var inputStream = Cc[ + "@mozilla.org/network/file-input-stream;1" + ].createInstance(Ci.nsIFileInputStream); + inputStream.init(file, 0x01, 0o600, 0); + var stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + stream.setInputStream(inputStream); + let data = ""; + while (stream.available() > 0) { + data += stream.readBytes(stream.available()); + } + let encoded = btoa(data); + stream.close(); + return ( + "data:" + + contentType + + ";filename=" + + encodeURIComponent(file.leafName) + + ";base64," + + encoded + ); +} diff --git a/comm/mail/components/compose/content/dialogs/EdImageProps.js b/comm/mail/components/compose/content/dialogs/EdImageProps.js new file mode 100644 index 0000000000..861d098edc --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdImageProps.js @@ -0,0 +1,293 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ +/* import-globals-from EdImageDialog.js */ + +var gAnchorElement = null; +var gLinkElement = null; +var gOriginalHref = ""; +var gHNodeArray = {}; + +// dialog initialization code + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + var editor = GetCurrentEditor(); + if (!editor) { + window.close(); + return; + } + + ImageStartup(); + gDialog.hrefInput = document.getElementById("hrefInput"); + gDialog.makeRelativeLink = document.getElementById("MakeRelativeLink"); + gDialog.showLinkBorder = document.getElementById("showLinkBorder"); + gDialog.linkTab = document.getElementById("imageLinkTab"); + gDialog.linkAdvanced = document.getElementById("LinkAdvancedEditButton"); + + // Get a single selected image element + var tagName = "img"; + if ("arguments" in window && window.arguments[0]) { + imageElement = window.arguments[0]; + // We've been called from form field properties, so we can't insert a link + gDialog.linkTab.remove(); + gDialog.linkTab = null; + } else { + // First check for <input type="image"> + try { + imageElement = editor.getSelectedElement("input"); + + if (!imageElement || imageElement.getAttribute("type") != "image") { + // Get a single selected image element + imageElement = editor.getSelectedElement(tagName); + if (imageElement) { + gAnchorElement = editor.getElementOrParentByTagName( + "href", + imageElement + ); + } + } + } catch (e) {} + } + + if (imageElement) { + // We found an element and don't need to insert one + if (imageElement.hasAttribute("src")) { + gInsertNewImage = false; + gActualWidth = imageElement.naturalWidth; + gActualHeight = imageElement.naturalHeight; + } + } else { + gInsertNewImage = true; + + // We don't have an element selected, + // so create one with default attributes + try { + imageElement = editor.createElementWithDefaults(tagName); + } catch (e) {} + + if (!imageElement) { + dump("Failed to get selected element or create a new one!\n"); + window.close(); + return; + } + try { + gAnchorElement = editor.getSelectedElement("href"); + } catch (e) {} + } + + // Make a copy to use for AdvancedEdit + globalElement = imageElement.cloneNode(false); + + // We only need to test for this once per dialog load + gHaveDocumentUrl = GetDocumentBaseUrl(); + + InitDialog(); + if (gAnchorElement) { + gOriginalHref = gAnchorElement.getAttribute("href"); + // Make a copy to use for AdvancedEdit + gLinkElement = gAnchorElement.cloneNode(false); + } else { + gLinkElement = editor.createElementWithDefaults("a"); + } + gDialog.hrefInput.value = gOriginalHref; + + FillLinkMenulist(gDialog.hrefInput, gHNodeArray); + ChangeLinkLocation(); + + // Save initial source URL + gOriginalSrc = gDialog.srcInput.value; + + // By default turn constrain on, but both width and height must be in pixels + gDialog.constrainCheckbox.checked = + gDialog.widthUnitsMenulist.selectedIndex == 0 && + gDialog.heightUnitsMenulist.selectedIndex == 0; + + // Start in "Link" tab if 2nd argument is true + if (gDialog.linkTab && "arguments" in window && window.arguments[1]) { + document.getElementById("TabBox").selectedTab = gDialog.linkTab; + SetTextboxFocus(gDialog.hrefInput); + } else { + SetTextboxFocus(gDialog.srcInput); + } + + SetWindowLocation(); +} + +// Set dialog widgets with attribute data +// We get them from globalElement copy so this can be used +// by AdvancedEdit(), which is shared by all property dialogs +function InitDialog() { + InitImage(); + var border = TrimString(gDialog.border.value); + gDialog.showLinkBorder.checked = border != "" && border > 0; +} + +function ChangeLinkLocation() { + var href = TrimString(gDialog.hrefInput.value); + SetRelativeCheckbox(gDialog.makeRelativeLink); + gDialog.showLinkBorder.disabled = !href; + gDialog.linkAdvanced.disabled = !href; + gLinkElement.setAttribute("href", href); +} + +function ToggleShowLinkBorder() { + if (gDialog.showLinkBorder.checked) { + var border = TrimString(gDialog.border.value); + if (!border || border == "0") { + gDialog.border.value = "2"; + } + } else { + gDialog.border.value = "0"; + } +} + +// Get data from widgets, validate, and set for the global element +// accessible to AdvancedEdit() [in EdDialogCommon.js] +function ValidateData() { + return ValidateImage(); +} + +function onAccept(event) { + // Use this now (default = false) so Advanced Edit button dialog doesn't trigger error message + gDoAltTextError = true; + window.opener.gMsgCompose.allowRemoteContent = true; + if (ValidateData()) { + if ("arguments" in window && window.arguments[0]) { + SaveWindowLocation(); + return; + } + + var editor = GetCurrentEditor(); + + editor.beginTransaction(); + + try { + if (gRemoveImageMap) { + globalElement.removeAttribute("usemap"); + if (gImageMap) { + editor.deleteNode(gImageMap); + gInsertNewIMap = true; + gImageMap = null; + } + } else if (gImageMap) { + // un-comment to see that inserting image maps does not work! + /* + gImageMap = editor.createElementWithDefaults("map"); + gImageMap.setAttribute("name", "testing"); + var testArea = editor.createElementWithDefaults("area"); + testArea.setAttribute("shape", "circle"); + testArea.setAttribute("coords", "86,102,52"); + testArea.setAttribute("href", "test"); + gImageMap.appendChild(testArea); + */ + + // Assign to map if there is one + var mapName = gImageMap.getAttribute("name"); + if (mapName != "") { + globalElement.setAttribute("usemap", "#" + mapName); + if (globalElement.getAttribute("border") == "") { + globalElement.setAttribute("border", 0); + } + } + } + + // Create or remove the link as appropriate + var href = gDialog.hrefInput.value; + if (href != gOriginalHref) { + if (href && !gInsertNewImage) { + EditorSetTextProperty("a", "href", href); + // gAnchorElement is needed for cloning attributes later. + if (!gAnchorElement) { + gAnchorElement = editor.getElementOrParentByTagName( + "href", + imageElement + ); + } + } else { + EditorRemoveTextProperty("href", ""); + } + } + + // If inside a link, always write the 'border' attribute + if (href) { + if (gDialog.showLinkBorder.checked) { + // Use default = 2 if border attribute is empty + if (!globalElement.hasAttribute("border")) { + globalElement.setAttribute("border", "2"); + } + } else { + globalElement.setAttribute("border", "0"); + } + } + + if (gInsertNewImage) { + if (href) { + gLinkElement.appendChild(imageElement); + editor.insertElementAtSelection(gLinkElement, true); + } else { + // 'true' means delete the selection before inserting + editor.insertElementAtSelection(imageElement, true); + } + } + + // Check to see if the link was to a heading + // Do this last because it moves the caret (BAD!) + if (href in gHNodeArray) { + var anchorNode = editor.createElementWithDefaults("a"); + if (anchorNode) { + anchorNode.name = href.substr(1); + // Remember to use editor method so it is undoable! + editor.insertNode(anchorNode, gHNodeArray[href], 0); + } + } + // All values are valid - copy to actual element in doc or + // element we just inserted + editor.cloneAttributes(imageElement, globalElement); + if (gAnchorElement) { + editor.cloneAttributes(gAnchorElement, gLinkElement); + } + + // If document is empty, the map element won't insert, + // so always insert the image first + if (gImageMap && gInsertNewIMap) { + // Insert the ImageMap element at beginning of document + var body = editor.rootElement; + editor.setShouldTxnSetSelection(false); + editor.insertNode(gImageMap, body, 0); + editor.setShouldTxnSetSelection(true); + } + } catch (e) { + dump(e); + } + + editor.endTransaction(); + + SaveWindowLocation(); + return; + } + + gDoAltTextError = false; + + event.preventDefault(); +} + +function onLinkAdvancedEdit() { + window.AdvancedEditOK = false; + window.openDialog( + "chrome://messenger/content/messengercompose/EdAdvancedEdit.xhtml", + "_blank", + "chrome,close,titlebar,modal,resizable=yes", + "", + gLinkElement + ); + window.focus(); + if (window.AdvancedEditOK) { + gDialog.hrefInput.value = gLinkElement.getAttribute("href"); + } +} diff --git a/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml b/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml new file mode 100644 index 0000000000..c894a30175 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdImageProps.xhtml @@ -0,0 +1,454 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % edImageProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorImageProperties.dtd"> +%edImageProperties; +<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd"> +%composeEditorOverlayDTD; +<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd"> +%edDialogOverlay; ]> + +<!-- dialog containing a control requiring initial setup --> +<window + windowtype="Mail:image" + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + style="min-height: 24em" + lightweightthemes="true" + onload="Startup()" +> + <dialog id="imageDlg" buttons="accept,cancel" style="width: 68ch"> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdImageProps.js" /> + <script src="chrome://messenger/content/messengercompose/EdImageDialog.js" /> + <script src="chrome://messenger/content/messengercompose/EdImageLinkLoader.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <tabbox id="TabBox"> + <tabs flex="1"> + <tab id="imageLocationTab" label="&imageLocationTab.label;" /> + <tab id="imageDimensionsTab" label="&imageDimensionsTab.label;" /> + <tab id="imageAppearanceTab" label="&imageAppearanceTab.label;" /> + <tab id="imageLinkTab" label="&imageLinkTab.label;" /> + </tabs> + <tabpanels> + <vbox id="imageLocation"> + <spacer class="spacer" /> + <label + id="srcLabel" + control="srcInput" + value="&locationEditField.label;" + accesskey="&locationEditField.accessKey;" + tooltiptext="&locationEditField.tooltip;" + /> + <tooltip id="shortenedDataURI"> + <label value="&locationEditField.shortenedDataURI;" /> + </tooltip> + <html:input + id="srcInput" + type="text" + oninput="ChangeImageSrc();" + tabindex="1" + class="uri-element input-inline" + title="&locationEditField.tooltip;" + aria-labelledby="srcLabel" + /> + <hbox id="MakeRelativeHbox"> + <checkbox + id="MakeRelativeCheckbox" + tabindex="2" + label="&makeUrlRelative.label;" + accesskey="&makeUrlRelative.accessKey;" + oncommand="MakeInputValueRelativeOrAbsolute(this);" + tooltiptext="&makeUrlRelative.tooltip;" + /> + <checkbox + id="AttachSourceToMail" + hidden="true" + label="&attachImageSource.label;" + accesskey="&attachImageSource.accesskey;" + oncommand="DoAttachSourceCheckbox()" + /> + <spacer flex="1" /> + <button + id="ChooseFile" + tabindex="3" + oncommand="chooseFile()" + label="&chooseFileButton.label;" + accesskey="&chooseFileButton.accessKey;" + /> + </hbox> + <spacer class="spacer" /> + <radiogroup id="altTextRadioGroup" flex="1"> + <hbox> + <vbox> + <hbox align="center" flex="1"> + <label + id="titleLabel" + style="margin-left: 26px" + control="titleInput" + accesskey="&title.accessKey;" + value="&title.label;" + tooltiptext="&title.tooltip;" + /> + </hbox> + <hbox align="center" flex="1"> + <radio + id="altTextRadio" + value="usealt-yes" + label="&altText.label;" + accesskey="&altText.accessKey;" + tooltiptext="&altTextEditField.tooltip;" + persist="selected" + oncommand="SetAltTextDisabled(false);" + tabindex="5" + /> + </hbox> + </vbox> + <vbox flex="1"> + <html:input + id="titleInput" + type="text" + class="MinWidth20em input-inline" + title="&title.tooltip;" + tabindex="4" + aria-labelledby="titleLabel" + /> + <html:input + id="altTextInput" + type="text" + class="MinWidth20em input-inline" + title="&altTextEditField.tooltip;" + oninput="SetAltTextDisabled(false);" + tabindex="6" + aria-labelledby="altTextRadio" + /> + </vbox> + </hbox> + <radio + id="noAltTextRadio" + value="usealt-no" + label="&noAltText.label;" + accesskey="&noAltText.accessKey;" + persist="selected" + oncommand="SetAltTextDisabled(true);" + /> + </radiogroup> + </vbox> + + <vbox id="imageDimensions" align="start"> + <spacer class="spacer" /> + <hbox> + <radiogroup id="imgSizeGroup"> + <radio + id="actualSizeRadio" + label="&actualSizeRadio.label;" + accesskey="&actualSizeRadio.accessKey;" + tooltiptext="&actualSizeRadio.tooltip;" + oncommand="SetActualSize()" + value="actual" + /> + <radio + id="customSizeRadio" + label="&customSizeRadio.label;" + selected="true" + accesskey="&customSizeRadio.accessKey;" + tooltiptext="&customSizeRadio.tooltip;" + oncommand="doDimensionEnabling();" + value="custom" + /> + </radiogroup> + <spacer flex="1" /> + <vbox> + <spacer flex="1" /> + <checkbox + id="constrainCheckbox" + label="&constrainCheckbox.label;" + accesskey="&constrainCheckbox.accessKey;" + oncommand="ToggleConstrain()" + tooltiptext="&constrainCheckbox.tooltip;" + /> + </vbox> + <spacer flex="1" /> + </hbox> + <spacer class="spacer" /> + <hbox class="indent"> + <html:table> + <html:tr> + <html:th> + <label + id="widthLabel" + control="widthInput" + accesskey="&widthEditField.accessKey;" + value="&widthEditField.label;" + /> + </html:th> + <html:td> + <html:input + id="widthInput" + type="number" + min="0" + class="narrow input-inline" + oninput="constrainProportions(this.id,'heightInput')" + aria-labelledby="widthLabel" + /> + </html:td> + <html:td> + <menulist + id="widthUnitsMenulist" + oncommand="doDimensionEnabling();" + /> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label + id="heightLabel" + control="heightInput" + accesskey="&heightEditField.accessKey;" + value="&heightEditField.label;" + /> + </html:th> + <html:td> + <html:input + id="heightInput" + type="number" + min="0" + class="narrow input-inline" + oninput="constrainProportions(this.id,'widthInput')" + aria-labelledby="heightLabel" + /> + </html:td> + <html:td> + <menulist + id="heightUnitsMenulist" + oncommand="doDimensionEnabling();" + /> + </html:td> + </html:tr> + </html:table> + </hbox> + <spacer flex="1" /> + </vbox> + + <vbox id="imageAppearance"> + <html:legend id="spacingLabel">&spacingBox.label;</html:legend> + <html:table> + <html:tr> + <html:th> + <label + id="leftrightLabel" + class="align-right" + control="imageleftrightInput" + accesskey="&leftRightEditField.accessKey;" + value="&leftRightEditField.label;" + /> + </html:th> + <html:td> + <html:input + id="imageleftrightInput" + type="number" + min="0" + class="narrow input-inline" + aria-labelledby="leftrightLabel" + /> + </html:td> + <html:td id="leftrighttypeLabel"> &pixelsPopup.value; </html:td> + <html:td style="width: 80%"> + <spacer /> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label + id="topbottomLabel" + class="align-right" + control="imagetopbottomInput" + accesskey="&topBottomEditField.accessKey;" + value="&topBottomEditField.label;" + /> + </html:th> + <html:td> + <html:input + id="imagetopbottomInput" + type="number" + min="0" + class="narrow input-inline" + aria-labelledby="topbottomLabel" + /> + </html:td> + <html:td id="topbottomtypeLabel"> &pixelsPopup.value; </html:td> + <html:td> + <spacer /> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label + id="borderLabel" + class="align-right" + control="border" + accesskey="&borderEditField.accessKey;" + value="&borderEditField.label;" + /> + </html:th> + <html:td> + <html:input + id="border" + type="number" + min="0" + class="narrow input-inline" + aria-labelledby="borderLabel" + /> + </html:td> + <html:td id="bordertypeLabel"> &pixelsPopup.value; </html:td> + <html:td> + <spacer /> + </html:td> + </html:tr> + </html:table> + <separator class="thin" /> + <html:legend id="alignLabel">&alignment.label;</html:legend> + <menulist id="alignTypeSelect" class="align-menu"> + <menupopup> + <menuitem + class="align-menu menuitem-iconic" + value="top" + label="&topPopup.value;" + /> + <menuitem + class="align-menu menuitem-iconic" + value="middle" + label="¢erPopup.value;" + /> + <menuitem + class="align-menu menuitem-iconic" + value="bottom" + label="&bottomPopup.value;" + /> + <!-- HTML attribute value is opposite of the button label on purpose --> + <menuitem + class="align-menu menuitem-iconic" + value="right" + label="&wrapLeftPopup.value;" + /> + <menuitem + class="align-menu menuitem-iconic" + value="left" + label="&wrapRightPopup.value;" + /> + </menupopup> + </menulist> + <separator class="thin" /> + <html:legend id="imagemapLabel">&imagemapBox.label;</html:legend> + <html:div class="grid-two-column-equalsize"> + <button + id="removeImageMap" + oncommand="removeImageMap()" + accesskey="&removeImageMapButton.accessKey;" + label="&removeImageMapButton.label;" + /> + <spacer /><!-- remove when we restore Image Map Editor --> + </html:div> + </vbox> + <vbox> + <spacer class="spacer" /> + <vbox id="LinkLocationBox"> + <label + id="hrefLabel" + control="hrefInput" + accesskey="&LinkURLEditField2.accessKey;" + width="1" + >&LinkURLEditField2.label;</label + > + <html:input + id="hrefInput" + type="text" + class="uri-element padded input-inline" + oninput="ChangeLinkLocation();" + aria-labelledby="hrefLabel" + /> + <hbox align="center"> + <checkbox + id="MakeRelativeLink" + for="hrefInput" + label="&makeUrlRelative.label;" + accesskey="&makeUrlRelative.accessKey;" + oncommand="MakeInputValueRelativeOrAbsolute(this);" + tooltiptext="&makeUrlRelative.tooltip;" + /> + <spacer flex="1" /> + <button + label="&chooseFileLinkButton.label;" + accesskey="&chooseFileLinkButton.accessKey;" + oncommand="chooseLinkFile();" + /> + </hbox> + </vbox> + <spacer class="spacer" /> + <hbox> + <checkbox + id="showLinkBorder" + label="&showImageLinkBorder.label;" + accesskey="&showImageLinkBorder.accessKey;" + oncommand="ToggleShowLinkBorder();" + /> + <spacer flex="1" /> + </hbox> + <separator class="thin" /> + <hbox pack="end"> + <button + id="LinkAdvancedEditButton" + label="&LinkAdvancedEditButton.label;" + accesskey="&LinkAdvancedEditButton.accessKey;" + tooltiptext="&LinkAdvancedEditButton.tooltip;" + oncommand="onLinkAdvancedEdit();" + /> + </hbox> + </vbox> + </tabpanels> + </tabbox> + + <spacer flex="1" /> + + <html:fieldset id="imagePreview" hidden="hidden"> + <html:legend>&previewBox.label;</html:legend> + + <html:figure> + <html:img id="preview-image" style="display: inline-block" alt="" /> + <html:figcaption style="float: right"> + <label value="&actualSize.label;" /> + <label id="PreviewWidth" />x<label id="PreviewHeight" /> + </html:figcaption> + </html:figure> + </html:fieldset> + + <hbox pack="end"> + <button + id="AdvancedEditButton1" + oncommand="onAdvancedEdit()" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + + <separator class="groove" /> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdInsSrc.js b/comm/mail/components/compose/content/dialogs/EdInsSrc.js new file mode 100644 index 0000000000..d00f119ed7 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsSrc.js @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* Insert Source HTML dialog */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var gFullDataStrings = new Map(); +var gShortDataStrings = new Map(); +var gListenerAttached = false; + +window.addEventListener("load", Startup); + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + let editor = GetCurrentEditor(); + if (!editor) { + window.close(); + return; + } + + document + .querySelector("dialog") + .getButton("accept") + .removeAttribute("default"); + + // Create dialog object to store controls for easy access + gDialog.srcInput = document.getElementById("srcInput"); + + // Attach a paste listener so we can detect pasted data URIs we need to shorten. + gDialog.srcInput.addEventListener("paste", onPaste); + + let selection; + try { + selection = editor.outputToString( + "text/html", + kOutputFormatted | kOutputSelectionOnly | kOutputWrap + ); + } catch (e) {} + if (selection) { + selection = selection.replace(/<body[^>]*>/, "").replace(/<\/body>/, ""); + + // Shorten data URIs for display. + selection = replaceDataURIs(selection); + + if (selection) { + gDialog.srcInput.value = selection; + } + } + // Set initial focus + gDialog.srcInput.focus(); + SetWindowLocation(); +} + +function replaceDataURIs(input) { + return input.replace( + /(data:.+;base64,)([^"' >]+)/gi, + function (match, nonDataPart, dataPart) { + if (gShortDataStrings.has(dataPart)) { + // We found the exact same data URI, just return the shortened URI. + return nonDataPart + gShortDataStrings.get(dataPart); + } + + let l = 5; + let key; + // Normally we insert the ellipsis after five characters but if it's not unique + // we include more data. + do { + key = + dataPart.substr(0, l) + "…" + dataPart.substr(dataPart.length - 10); + l++; + } while (gFullDataStrings.has(key) && l < dataPart.length - 10); + gFullDataStrings.set(key, dataPart); + gShortDataStrings.set(dataPart, key); + + // Attach listeners. In case anyone copies/cuts from the HTML window, + // we want to restore the data URI on the clipboard. + if (!gListenerAttached) { + gDialog.srcInput.addEventListener("copy", onCopyOrCut); + gDialog.srcInput.addEventListener("cut", onCopyOrCut); + gListenerAttached = true; + } + + return nonDataPart + key; + } + ); +} + +function onCopyOrCut(event) { + let startPos = gDialog.srcInput.selectionStart; + if (startPos == undefined) { + return; + } + let endPos = gDialog.srcInput.selectionEnd; + let clipboard = gDialog.srcInput.value.substring(startPos, endPos); + + // Add back the original data URIs we stashed away earlier. + clipboard = clipboard.replace( + /(data:.+;base64,)([^"' >]+)/gi, + function (match, nonDataPart, key) { + if (!gFullDataStrings.has(key)) { + // User changed data URI. + return match; + } + return nonDataPart + gFullDataStrings.get(key); + } + ); + event.clipboardData.setData("text/plain", clipboard); + if (event.type == "cut") { + // We have to cut the selection manually. + gDialog.srcInput.value = + gDialog.srcInput.value.substr(0, startPos) + + gDialog.srcInput.value.substr(endPos); + } + event.preventDefault(); +} + +function onPaste(event) { + let startPos = gDialog.srcInput.selectionStart; + if (startPos == undefined) { + return; + } + let endPos = gDialog.srcInput.selectionEnd; + let clipboard = event.clipboardData.getData("text/plain"); + + // We do out own paste by replacing the selection with the pre-processed + // clipboard data. + gDialog.srcInput.value = + gDialog.srcInput.value.substr(0, startPos) + + replaceDataURIs(clipboard) + + gDialog.srcInput.value.substr(endPos); + event.preventDefault(); +} + +function onAccept(event) { + let html = gDialog.srcInput.value; + if (!html) { + event.preventDefault(); + return; + } + + // Add back the original data URIs we stashed away earlier. + html = html.replace( + /(data:.+;base64,)([^"' >]+)/gi, + function (match, nonDataPart, key) { + if (!gFullDataStrings.has(key)) { + // User changed data URI. + return match; + } + return nonDataPart + gFullDataStrings.get(key); + } + ); + + try { + GetCurrentEditor().insertHTML(html); + } catch (e) {} + SaveWindowLocation(); +} diff --git a/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml b/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml new file mode 100644 index 0000000000..1f35de996d --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsSrc.xhtml @@ -0,0 +1,67 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE html SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertSource.dtd"> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + style="min-height: 430px; min-width: 600px" + scrolling="false" +> + <head> + <title>&windowTitle.label;</title> + <link rel="localization" href="branding/brand.ftl" /> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/dialogShadowDom.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/messengercompose/editorUtilities.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/messengercompose/EdDialogCommon.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/messengercompose/EdInsSrc.js" + ></script> + </head> + <body> + <xul:dialog + buttonlabelaccept="&insertButton.label;" + buttonaccesskeyaccept="&insertButton.accesskey;" + > + <p id="srcMessage">&sourceEditField.label;</p> + <textarea id="srcInput" style="flex: 1" rows="18" cols="70"></textarea> + <p> + &example.label; + <code class="bold"> + &exampleOpenTag.label; + <i>&exampleText.label;</i> &exampleCloseTag.label; + </code> + </p> + <hr /> + </xul:dialog> + </body> +</html> diff --git a/comm/mail/components/compose/content/dialogs/EdInsertChars.js b/comm/mail/components/compose/content/dialogs/EdInsertChars.js new file mode 100644 index 0000000000..b710fb91a0 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsertChars.js @@ -0,0 +1,412 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +// ------------------------------------------------------------------ +// From Unicode 3.0 Page 54. 3.11 Conjoining Jamo Behavior +var SBase = 0xac00; +var LBase = 0x1100; +var VBase = 0x1161; +var TBase = 0x11a7; +var LCount = 19; +var VCount = 21; +var TCount = 28; +var NCount = VCount * TCount; +// End of Unicode 3.0 + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onClose); + +// dialog initialization code +function Startup() { + if (!GetCurrentEditor()) { + window.close(); + return; + } + + StartupLatin(); + + // Set a variable on the opener window so we + // can track ownership of close this window with it + window.opener.InsertCharWindow = window; + window.sizeToContent(); + + SetWindowLocation(); +} + +function onAccept(event) { + // Insert the character + try { + GetCurrentEditor().insertText(LatinM.label); + } catch (e) {} + + // Set persistent attributes to save + // which category, letter, and character modifier was used + CategoryGroup.setAttribute("category", category); + CategoryGroup.setAttribute("letter_index", indexL); + CategoryGroup.setAttribute("char_index", indexM); + + // Don't close the dialog + event.preventDefault(); +} + +// Don't allow inserting in HTML Source Mode +function onFocus() { + var enable = true; + if ("gEditorDisplayMode" in window.opener) { + enable = !window.opener.IsInHTMLSourceMode(); + } + + SetElementEnabled( + document.querySelector("dialog").getButton("accept"), + enable + ); +} + +function onClose() { + window.opener.InsertCharWindow = null; + SaveWindowLocation(); +} + +// ------------------------------------------------------------------ +var LatinL; +var LatinM; +var LatinL_Label; +var LatinM_Label; +var indexL = 0; +var indexM = 0; +var indexM_AU = 0; +var indexM_AL = 0; +var indexM_U = 0; +var indexM_L = 0; +var indexM_S = 0; +var LItems = 0; +var category; +var CategoryGroup; +var initialize = true; + +function StartupLatin() { + LatinL = document.getElementById("LatinL"); + LatinM = document.getElementById("LatinM"); + LatinL_Label = document.getElementById("LatinL_Label"); + LatinM_Label = document.getElementById("LatinM_Label"); + + var Symbol = document.getElementById("Symbol"); + var AccentUpper = document.getElementById("AccentUpper"); + var AccentLower = document.getElementById("AccentLower"); + var Upper = document.getElementById("Upper"); + var Lower = document.getElementById("Lower"); + CategoryGroup = document.getElementById("CatGrp"); + + // Initialize which radio button is set from persistent attribute... + var category = CategoryGroup.getAttribute("category"); + + // ...as well as indexes into the letter and character lists + var index = Number(CategoryGroup.getAttribute("letter_index")); + if (index && index >= 0) { + indexL = index; + } + index = Number(CategoryGroup.getAttribute("char_index")); + if (index && index >= 0) { + indexM = index; + } + + switch (category) { + case "AccentUpper": // Uppercase Diacritical + CategoryGroup.selectedItem = AccentUpper; + indexM_AU = indexM; + break; + case "AccentLower": // Lowercase Diacritical + CategoryGroup.selectedItem = AccentLower; + indexM_AL = indexM; + break; + case "Upper": // Uppercase w/o Diacritical + CategoryGroup.selectedItem = Upper; + indexM_U = indexM; + break; + case "Lower": // Lowercase w/o Diacritical + CategoryGroup.selectedItem = Lower; + indexM_L = indexM; + break; + default: + category = "Symbol"; + CategoryGroup.selectedItem = Symbol; + indexM_S = indexM; + break; + } + + ChangeCategory(category); + initialize = false; +} + +function ChangeCategory(newCategory) { + if (category != newCategory || initialize) { + category = newCategory; + // Note: Must do L before M to set LatinL.selectedIndex + UpdateLatinL(); + UpdateLatinM(); + UpdateCharacter(); + } +} + +function SelectLatinLetter() { + if (LatinL.selectedIndex != indexL) { + indexL = LatinL.selectedIndex; + UpdateLatinM(); + UpdateCharacter(); + } +} + +function SelectLatinModifier() { + if (LatinM.selectedIndex != indexM) { + indexM = LatinM.selectedIndex; + UpdateCharacter(); + } +} +function DisableLatinL(disable) { + if (disable) { + LatinL_Label.setAttribute("disabled", "true"); + LatinL.setAttribute("disabled", "true"); + } else { + LatinL_Label.removeAttribute("disabled"); + LatinL.removeAttribute("disabled"); + } +} + +function UpdateLatinL() { + LatinL.removeAllItems(); + if (category == "AccentUpper" || category == "AccentLower") { + DisableLatinL(false); + // No Q or q + var alphabet = + category == "AccentUpper" + ? "ABCDEFGHIJKLMNOPRSTUVWXYZ" + : "abcdefghijklmnoprstuvwxyz"; + for (var letter = 0; letter < alphabet.length; letter++) { + LatinL.appendItem(alphabet.charAt(letter)); + } + + LatinL.selectedIndex = indexL; + } else { + // Other categories don't hinge on a "letter" + DisableLatinL(true); + // Note: don't change the indexL so it can be used next time + } +} + +function UpdateLatinM() { + LatinM.removeAllItems(); + var i, accent; + switch (category) { + case "AccentUpper": // Uppercase Diacritical + accent = upper[indexL]; + for (i = 0; i < accent.length; i++) { + LatinM.appendItem(accent.charAt(i)); + } + + if (indexM_AU < accent.length) { + indexM = indexM_AU; + } else { + indexM = accent.length - 1; + } + indexM_AU = indexM; + break; + + case "AccentLower": // Lowercase Diacritical + accent = lower[indexL]; + for (i = 0; i < accent.length; i++) { + LatinM.appendItem(accent.charAt(i)); + } + + if (indexM_AL < accent.length) { + indexM = indexM_AL; + } else { + indexM = lower[indexL].length - 1; + } + indexM_AL = indexM; + break; + + case "Upper": // Uppercase w/o Diacritical + for (i = 0; i < otherupper.length; i++) { + LatinM.appendItem(otherupper.charAt(i)); + } + + if (indexM_U < otherupper.length) { + indexM = indexM_U; + } else { + indexM = otherupper.length - 1; + } + indexM_U = indexM; + break; + + case "Lower": // Lowercase w/o Diacritical + for (i = 0; i < otherlower.length; i++) { + LatinM.appendItem(otherlower.charAt(i)); + } + + if (indexM_L < otherlower.length) { + indexM = indexM_L; + } else { + indexM = otherlower.length - 1; + } + indexM_L = indexM; + break; + + case "Symbol": // Symbol + for (i = 0; i < symbol.length; i++) { + LatinM.appendItem(symbol.charAt(i)); + } + + if (indexM_S < symbol.length) { + indexM = indexM_S; + } else { + indexM = symbol.length - 1; + } + indexM_S = indexM; + break; + } + LatinM.selectedIndex = indexM; +} + +function UpdateCharacter() { + indexM = LatinM.selectedIndex; + + switch (category) { + case "AccentUpper": // Uppercase Diacritical + indexM_AU = indexM; + break; + case "AccentLower": // Lowercase Diacritical + indexM_AL = indexM; + break; + case "Upper": // Uppercase w/o Diacritical + indexM_U = indexM; + break; + case "Lower": // Lowercase w/o Diacritical + indexM_L = indexM; + break; + case "Symbol": + indexM_S = indexM; + break; + } + // dump("Letter Index="+indexL+", Character Index="+indexM+", Character = "+LatinM.label+"\n"); +} + +const upper = [ + // A + "\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5\u0100\u0102\u0104\u01cd\u01de\u01de\u01e0\u01fa\u0200\u0202\u0226\u1e00\u1ea0\u1ea2\u1ea4\u1ea6\u1ea8\u1eaa\u1eac\u1eae\u1eb0\u1eb2\u1eb4\u1eb6", + // B + "\u0181\u0182\u0184\u1e02\u1e04\u1e06", + // C + "\u00c7\u0106\u0108\u010a\u010c\u0187\u1e08", + // D + "\u010e\u0110\u0189\u018a\u1e0a\u1e0c\u1e0e\u1e10\u1e12", + // E + "\u00C8\u00C9\u00CA\u00CB\u0112\u0114\u0116\u0118\u011A\u0204\u0206\u0228\u1e14\u1e16\u1e18\u1e1a\u1e1c\u1eb8\u1eba\u1ebc\u1ebe\u1ec0\u1ec2\u1ec4\u1ec6", + // F + "\u1e1e", + // G + "\u011c\u011E\u0120\u0122\u01e4\u01e6\u01f4\u1e20", + // H + "\u0124\u0126\u021e\u1e22\u1e24\u1e26\u1e28\u1e2a", + // I + "\u00CC\u00CD\u00CE\u00CF\u0128\u012a\u012C\u012e\u0130\u0208\u020a\u1e2c\u1e2e\u1ec8\u1eca", + // J + "\u0134\u01f0", + // K + "\u0136\u0198\u01e8\u1e30\u1e32\u1e34", + // L + "\u0139\u013B\u013D\u013F\u0141\u1e36\u1e38\u1e3a\u1e3c", + // M + "\u1e3e\u1e40\u1e42", + // N + "\u00D1\u0143\u0145\u0147\u014A\u01F8\u1e44\u1e46\u1e48\u1e4a", + // O + "\u00D2\u00D3\u00D4\u00D5\u00D6\u014C\u014E\u0150\u01ea\u01ec\u020c\u020e\u022A\u022C\u022E\u0230\u1e4c\u1e4e\u1e50\u1e52\u1ecc\u1ece\u1ed0\u1ed2\u1ed4\u1ed6\u1ed8\u1eda\u1edc\u1ede\u1ee0\u1ee2", + // P + "\u1e54\u1e56", + // No Q + // R + "\u0154\u0156\u0158\u0210\u0212\u1e58\u1e5a\u1e5c\u1e5e", + // S + "\u015A\u015C\u015E\u0160\u0218\u1e60\u1e62\u1e64\u1e66\u1e68", + // T + "\u0162\u0164\u0166\u021A\u1e6a\u1e6c\u1e6e\u1e70", + // U + "\u00D9\u00DA\u00DB\u00DC\u0168\u016A\u016C\u016E\u0170\u0172\u0214\u0216\u1e72\u1e74\u1e76\u1e78\u1e7a\u1ee4\u1ee6\u1ee8\u1eea\u1eec\u1eee\u1ef0", + // V + "\u1e7c\u1e7e", + // W + "\u0174\u1e80\u1e82\u1e84\u1e86\u1e88", + // X + "\u1e8a\u1e8c", + // Y + "\u00DD\u0176\u0178\u0232\u1e8e\u1ef2\u1ef4\u1ef6\u1ef8", + // Z + "\u0179\u017B\u017D\u0224\u1e90\u1e92\u1e94", +]; + +const lower = [ + // a + "\u00e0\u00e1\u00e2\u00e3\u00e4\u00e5\u0101\u0103\u0105\u01ce\u01df\u01e1\u01fb\u0201\u0203\u0227\u1e01\u1e9a\u1ea1\u1ea3\u1ea5\u1ea7\u1ea9\u1eab\u1ead\u1eaf\u1eb1\u1eb3\u1eb5\u1eb7", + // b + "\u0180\u0183\u0185\u1e03\u1e05\u1e07", + // c + "\u00e7\u0107\u0109\u010b\u010d\u0188\u1e09", + // d + "\u010f\u0111\u1e0b\u1e0d\u1e0f\u1e11\u1e13", + // e + "\u00e8\u00e9\u00ea\u00eb\u0113\u0115\u0117\u0119\u011b\u0205\u0207\u0229\u1e15\u1e17\u1e19\u1e1b\u1e1d\u1eb9\u1ebb\u1ebd\u1ebf\u1ec1\u1ec3\u1ec5\u1ec7", + // f + "\u1e1f", + // g + "\u011d\u011f\u0121\u0123\u01e5\u01e7\u01f5\u1e21", + // h + "\u0125\u0127\u021f\u1e23\u1e25\u1e27\u1e29\u1e2b\u1e96", + // i + "\u00ec\u00ed\u00ee\u00ef\u0129\u012b\u012d\u012f\u0131\u01d0\u0209\u020b\u1e2d\u1e2f\u1ec9\u1ecb", + // j + "\u0135", + // k + "\u0137\u0138\u01e9\u1e31\u1e33\u1e35", + // l + "\u013a\u013c\u013e\u0140\u0142\u1e37\u1e39\u1e3b\u1e3d", + // m + "\u1e3f\u1e41\u1e43", + // n + "\u00f1\u0144\u0146\u0148\u0149\u014b\u01f9\u1e45\u1e47\u1e49\u1e4b", + // o + "\u00f2\u00f3\u00f4\u00f5\u00f6\u014d\u014f\u0151\u01d2\u01eb\u01ed\u020d\u020e\u022b\u022d\u022f\u0231\u1e4d\u1e4f\u1e51\u1e53\u1ecd\u1ecf\u1ed1\u1ed3\u1ed5\u1ed7\u1ed9\u1edb\u1edd\u1edf\u1ee1\u1ee3", + // p + "\u1e55\u1e57", + // No q + // r + "\u0155\u0157\u0159\u0211\u0213\u1e59\u1e5b\u1e5d\u1e5f", + // s + "\u015b\u015d\u015f\u0161\u0219\u1e61\u1e63\u1e65\u1e67\u1e69", + // t + "\u0162\u0163\u0165\u0167\u021b\u1e6b\u1e6d\u1e6f\u1e71\u1e97", + // u + "\u00f9\u00fa\u00fb\u00fc\u0169\u016b\u016d\u016f\u0171\u0173\u01d4\u01d6\u01d8\u01da\u01dc\u0215\u0217\u1e73\u1e75\u1e77\u1e79\u1e7b\u1ee5\u1ee7\u1ee9\u1eeb\u1eed\u1eef\u1ef1", + // v + "\u1e7d\u1e7f", + // w + "\u0175\u1e81\u1e83\u1e85\u1e87\u1e89\u1e98", + // x + "\u1e8b\u1e8d", + // y + "\u00fd\u00ff\u0177\u0233\u1e8f\u1e99\u1ef3\u1ef5\u1ef7\u1ef9", + // z + "\u017a\u017c\u017e\u0225\u1e91\u1e93\u1e95", +]; + +const symbol = + "\u00a1\u00a2\u00a3\u00a4\u00a5\u20ac\u00a6\u00a7\u00a8\u00a9\u00aa\u00ab\u00ac\u00ae\u00af\u00b0\u00b1\u00b2\u00b3\u00b4\u00b5\u00b6\u00b7\u00b8\u00b9\u00ba\u00bb\u00bc\u00bd\u00be\u00bf\u00d7\u00f7"; + +const otherupper = + "\u00c6\u00d0\u00d8\u00de\u0132\u0152\u0186\u01c4\u01c5\u01c7\u01c8\u01ca\u01cb\u01F1\u01f2"; + +const otherlower = + "\u00e6\u00f0\u00f8\u00fe\u00df\u0133\u0153\u01c6\u01c9\u01cc\u01f3"; diff --git a/comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml new file mode 100644 index 0000000000..c610abdd88 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsertChars.xhtml @@ -0,0 +1,92 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/EdInsertChars.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertChars.dtd"> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="Startup()" + onfocus="onFocus()" + lightweightthemes="true" + style="min-width: 20em" +> + <dialog + id="insertCharsDlg" + buttonlabelaccept="&insertButton.label;" + buttonlabelcancel="&closeButton.label;" + > + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdInsertChars.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <html:fieldset> + <html:legend>&category.label;</html:legend> + <radiogroup id="CatGrp" persist="category letter_index char_index"> + <radio + id="AccentUpper" + label="&accentUpper.label;" + oncommand="ChangeCategory(this.id)" + /> + <radio + id="AccentLower" + label="&accentLower.label;" + oncommand="ChangeCategory(this.id)" + /> + <radio + id="Upper" + label="&otherUpper.label;" + oncommand="ChangeCategory(this.id)" + /> + <radio + id="Lower" + label="&otherLower.label;" + oncommand="ChangeCategory(this.id)" + /> + <radio + id="Symbol" + label="&commonSymbols.label;" + oncommand="ChangeCategory(this.id)" + /> + </radiogroup> + <spacer class="spacer" /> + </html:fieldset> + <html:div class="grid-two-column-equalsize"> + <!-- value is set in JS from editor.properties strings --> + <label + id="LatinL_Label" + control="LatinL" + value="&letter.label;" + accesskey="&letter.accessKey;" + /> + <menulist id="LatinL" oncommand="SelectLatinLetter()"> + <menupopup /> + </menulist> + <label + id="LatinM_Label" + control="LatinM" + value="&character.label;" + accesskey="&character.accessKey;" + /> + <menulist id="LatinM" oncommand="SelectLatinModifier()"> + <menupopup /> + </menulist> + </html:div> + <separator class="groove" /> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdInsertMath.js b/comm/mail/components/compose/content/dialogs/EdInsertMath.js new file mode 100644 index 0000000000..a60a3affcc --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsertMath.js @@ -0,0 +1,317 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* Insert MathML dialog */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + var editor = GetCurrentEditor(); + if (!editor) { + window.close(); + return; + } + + // Create dialog object for easy access + gDialog.accept = document.querySelector("dialog").getButton("accept"); + gDialog.mode = document.getElementById("optionMode"); + gDialog.direction = document.getElementById("optionDirection"); + gDialog.input = document.getElementById("input"); + gDialog.output = document.getElementById("output"); + gDialog.tabbox = document.getElementById("tabboxInsertLaTeXCommand"); + + // Set initial focus + gDialog.input.focus(); + + // Load TeXZilla + // TeXZilla.js contains non-ASCII characters and explicitly sets + // window.TeXZilla, so we have to specify the charset parameter but don't + // need to worry about the targetObj parameter. + /* globals TeXZilla */ + Services.scriptloader.loadSubScript( + "chrome://messenger/content/messengercompose/TeXZilla.js", + {}, + "UTF-8" + ); + + // Verify if the selection is on a <math> and initialize the dialog. + gDialog.oldMath = editor.getElementOrParentByTagName("math", null); + if (gDialog.oldMath) { + // When these attributes are absent or invalid, they default to "inline" and "ltr" respectively. + gDialog.mode.selectedIndex = + gDialog.oldMath.getAttribute("display") == "block" ? 1 : 0; + gDialog.direction.selectedIndex = + gDialog.oldMath.getAttribute("dir") == "rtl" ? 1 : 0; + gDialog.input.value = TeXZilla.getTeXSource(gDialog.oldMath); + } + + // Create the tabbox with LaTeX commands. + createCommandPanel({ + "√⅗²": [ + "{⋯}^{⋯}", + "{⋯}_{⋯}", + "{⋯}_{⋯}^{⋯}", + "\\underset{⋯}{⋯}", + "\\overset{⋯}{⋯}", + "\\underoverset{⋯}{⋯}{⋯}", + "\\left(⋯\\right)", + "\\left[⋯\\right]", + "\\frac{⋯}{⋯}", + "\\binom{⋯}{⋯}", + "\\sqrt{⋯}", + "\\sqrt[⋯]{⋯}", + "\\cos\\left({⋯}\\right)", + "\\sin\\left({⋯}\\right)", + "\\tan\\left({⋯}\\right)", + "\\exp\\left({⋯}\\right)", + "\\ln\\left({⋯}\\right)", + "\\underbrace{⋯}", + "\\underline{⋯}", + "\\overbrace{⋯}", + "\\widevec{⋯}", + "\\widetilde{⋯}", + "\\widehat{⋯}", + "\\widecheck{⋯}", + "\\widebar{⋯}", + "\\dot{⋯}", + "\\ddot{⋯}", + "\\boxed{⋯}", + "\\slash{⋯}", + ], + "(▦)": [ + "\\begin{matrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{matrix}", + "\\begin{pmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{pmatrix}", + "\\begin{bmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{bmatrix}", + "\\begin{Bmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{Bmatrix}", + "\\begin{vmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{vmatrix}", + "\\begin{Vmatrix} ⋯ & ⋯ \\\\ ⋯ & ⋯ \\end{Vmatrix}", + "\\begin{cases} ⋯ \\\\ ⋯ \\end{cases}", + "\\begin{aligned} ⋯ &= ⋯ \\\\ ⋯ &= ⋯ \\end{aligned}", + ], + }); + createSymbolPanels([ + "∏∐∑∫∬∭⨌∮⊎⊕⊖⊗⊘⊙⋀⋁⋂⋃⌈⌉⌊⌋⎰⎱⟨⟩⟪⟫∥⫼⨀⨁⨂⨄⨅⨆ðıȷℏℑℓ℘ℜℵℶ", + "∀∃∄∅∉∊∋∌⊂⊃⊄⊅⊆⊇⊈⊈⊉⊊⊊⊋⊋⊏⊐⊑⊒⊓⊔⊥⋐⋑⋔⫅⫆⫋⫋⫌⫌…⋮⋯⋰⋱♭♮♯∂∇", + "±×÷†‡•∓∔∗∘∝∠∡∢∧∨∴∵∼∽≁≃≅≇≈≈≊≍≎≏≐≑≒≓≖≗≜≡≢≬⊚⊛⊞⊡⊢⊣⊤⊥", + "⊨⊩⊪⊫⊬⊭⊯⊲⊲⊳⊴⊵⊸⊻⋄⋅⋇⋈⋉⋊⋋⋌⋍⋎⋏⋒⋓⌅⌆⌣△▴▵▸▹▽▾▿◂◃◊○★♠♡♢♣⧫", + "≦≧≨≩≩≪≫≮≯≰≱≲≳≶≷≺≻≼≽≾≿⊀⊁⋖⋗⋘⋙⋚⋛⋞⋟⋦⋧⋨⋩⩽⩾⪅⪆⪇⪈⪉⪊⪋⪌⪕⪯⪰⪷⪸⪹⪺", + "←↑→↓↔↕↖↗↘↙↜↝↞↠↢↣↦↩↪↫↬↭↭↰↱↼↽↾↿⇀⇁⇂⇃⇄⇆⇇⇈⇉⇊⇋⇌⇐⇑⇒⇓⇕⇖⇗⇘⇙⟺", + "αβγδϵ϶εζηθϑικϰλμνξℴπϖρϱσςτυϕφχψωΓΔΘΛΞΠΣϒΦΨΩϝ℧", + "𝕒𝕓𝕔𝕕𝕖𝕗𝕘𝕙𝕚𝕛𝕜𝕝𝕞𝕟𝕠𝕡𝕢𝕣𝕤𝕥𝕦𝕧𝕨𝕩𝕪𝕫𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ", + "𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵", + "𝔞𝔟𝔠𝔡𝔢𝔣𝔤𝔥𝔦𝔧𝔨𝔩𝔪𝔫𝔬𝔭𝔮𝔯𝔰𝔱𝔲𝔳𝔴𝔵𝔶𝔷𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ", + ]); + gDialog.tabbox.selectedIndex = 0; + + updateMath(); + + SetWindowLocation(); +} + +function insertLaTeXCommand(aButton) { + gDialog.input.focus(); + + // For a single math symbol, just use the insertText command. + if (aButton.label) { + gDialog.input.editor.insertText(aButton.label); + return; + } + + // Otherwise, it's a LaTeX command with at least one argument... + var latex = TeXZilla.getTeXSource(aButton.firstElementChild); + var selectionStart = gDialog.input.selectionStart; + var selectionEnd = gDialog.input.selectionEnd; + + // If the selection is not empty, we replace the first argument of the LaTeX + // command with the current selection. + var selection = gDialog.input.value.substring(selectionStart, selectionEnd); + if (selection != "") { + latex = latex.replace("⋯", selection); + } + + // Try and move to the next position. + var latexNewStart = latex.indexOf("⋯"), + latexNewEnd; + if (latexNewStart == -1) { + // This is a unary function and the selection was used as an argument above. + // We select the expression again so that one can choose to apply further + // command to it or just move the caret after that text. + latexNewStart = 0; + latexNewEnd = latex.length; + } else { + // Otherwise, select the dots representing the next argument. + latexNewEnd = latexNewStart + 1; + } + + // Update the input text and selection. + gDialog.input.editor.insertText(latex); + gDialog.input.setSelectionRange( + selectionStart + latexNewStart, + selectionStart + latexNewEnd + ); + + updateMath(); +} + +function createCommandPanel(aCommandPanelList) { + const columnCount = 10; + + for (var label in aCommandPanelList) { + var commands = aCommandPanelList[label]; + + // Create the <table> element with the <tr>. + var table = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "table" + ); + + var i = 0, + row; + for (var command of commands) { + if (i % columnCount == 0) { + // Create a new row. + row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr"); + table.appendChild(row); + } + + // Create a new button to insert the symbol. + var button = document.createXULElement("toolbarbutton"); + var td = document.createElementNS("http://www.w3.org/1999/xhtml", "td"); + button.setAttribute("class", "tabbable"); + button.appendChild(TeXZilla.toMathML(command)); + td.append(button); + row.appendChild(td); + + i++; + } + + // Create a new <tab> element. + var tab = document.createXULElement("tab"); + tab.setAttribute("label", label); + gDialog.tabbox.tabs.appendChild(tab); + + // Append the new tab panel. + gDialog.tabbox.tabpanels.appendChild(table); + } +} + +function createSymbolPanels(aSymbolPanelList) { + const columnCount = 13, + tabLabelLength = 3; + + for (var symbols of aSymbolPanelList) { + // Create the <table> element with the <tr>. + var table = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "table" + ); + var i = 0, + tabLabel = "", + row; + for (var symbol of symbols) { + if (i % columnCount == 0) { + // Create a new row. + row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr"); + table.appendChild(row); + } + + // Build the tab label from the first symbols of this tab. + if (i < tabLabelLength) { + tabLabel += symbol; + } + + // Create a new button to insert the symbol. + var button = document.createXULElement("toolbarbutton"); + var td = document.createElementNS("http://www.w3.org/1999/xhtml", "td"); + button.setAttribute("label", symbol); + button.setAttribute("class", "tabbable"); + td.append(button); + row.appendChild(td); + + i++; + } + + // Create a new <tab> element with the label determined above. + var tab = document.createXULElement("tab"); + tab.setAttribute("label", tabLabel); + gDialog.tabbox.tabs.appendChild(tab); + + // Append the new tab panel. + gDialog.tabbox.tabpanels.appendChild(table); + } +} + +function onAccept(event) { + if (gDialog.output.firstElementChild) { + var editor = GetCurrentEditor(); + editor.beginTransaction(); + + try { + var newMath = editor.document.importNode( + gDialog.output.firstElementChild, + true + ); + if (gDialog.oldMath) { + // Replace the old <math> element with the new one. + editor.selectElement(gDialog.oldMath); + editor.insertElementAtSelection(newMath, true); + } else { + // Insert the new <math> element. + editor.insertElementAtSelection(newMath, false); + } + } catch (e) {} + + editor.endTransaction(); + } else { + dump("Null value -- not inserting in MathML Source dialog\n"); + event.preventDefault(); + } + SaveWindowLocation(); +} + +function updateMath() { + // Remove the preview, if any. + if (gDialog.output.firstElementChild) { + gDialog.output.firstElementChild.remove(); + } + + // Try to convert the LaTeX source into MathML using TeXZilla. + // We use the placeholder text if no input is provided. + try { + var input = gDialog.input.value || gDialog.input.placeholder; + var newMath = TeXZilla.toMathML( + input, + gDialog.mode.selectedIndex, + gDialog.direction.selectedIndex, + true + ); + gDialog.output.appendChild(document.importNode(newMath, true)); + gDialog.output.style.opacity = gDialog.input.value ? 1 : 0.5; + } catch (e) {} + // Disable the accept button if parsing fails or when the placeholder is used. + gDialog.accept.disabled = + !gDialog.input.value || !gDialog.output.firstElementChild; +} + +function updateMode() { + if (gDialog.output.firstElementChild) { + gDialog.output.firstElementChild.setAttribute( + "display", + gDialog.mode.selectedIndex ? "block" : "inline" + ); + } +} + +function updateDirection() { + if (gDialog.output.firstElementChild) { + gDialog.output.firstElementChild.setAttribute( + "dir", + gDialog.direction.selectedIndex ? "rtl" : "ltr" + ); + } +} diff --git a/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml new file mode 100644 index 0000000000..d76a518b0a --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsertMath.xhtml @@ -0,0 +1,73 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public +- License, v. 2.0. If a copy of the MPL was not distributed with this +- file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertMath.dtd"> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup();" +> + <dialog + buttonlabelaccept="&insertButton.label;" + buttonaccesskeyaccept="&insertButton.accesskey;" + > + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdInsertMath.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <label id="srcMessage" value="&sourceEditField.label;" /> + <html:textarea + id="input" + rows="5" + oninput="updateMath();" + placeholder="\sqrt{x_1} + \frac{π^3}{2}" + /> + <vbox flex="1" style="overflow: auto; width: 30em; height: 5em"> + <description id="output" /> + </vbox> + <tabbox id="tabboxInsertLaTeXCommand"> + <tabs /> + <tabpanels oncommand="insertLaTeXCommand(event.target);" /> + </tabbox> + <spacer class="spacer" /> + <html:fieldset> + <html:legend>&options.label;</html:legend> + <hbox> + <radiogroup id="optionMode" oncommand="updateMode();"> + <radio + label="&optionInline.label;" + accesskey="&optionInline.accesskey;" + /> + <radio + label="&optionDisplay.label;" + accesskey="&optionDisplay.accesskey;" + /> + </radiogroup> + <radiogroup id="optionDirection" oncommand="updateDirection();"> + <radio label="&optionLTR.label;" accesskey="&optionLTR.accesskey;" /> + <radio label="&optionRTL.label;" accesskey="&optionRTL.accesskey;" /> + </radiogroup> + </hbox> + </html:fieldset> + <spacer class="spacer" /> + <separator class="groove" /> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTOC.js b/comm/mail/components/compose/content/dialogs/EdInsertTOC.js new file mode 100644 index 0000000000..45d0972f3b --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsertTOC.js @@ -0,0 +1,378 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +// tocHeadersArray is the array containing the pairs tag/class +// defining TOC entries +var tocHeadersArray = new Array(6); + +// a global used when building the TOC +var currentHeaderLevel = 0; + +// a global set to true if the TOC is to be readonly +var readonly = false; + +// a global set to true if user wants indexes in the TOC +var orderedList = true; + +// constants +const kMozToc = "mozToc"; +const kMozTocLength = 6; +const kMozTocIdPrefix = "mozTocId"; +const kMozTocIdPrefixLength = 8; +const kMozTocClassPrefix = "mozToc"; +const kMozTocClassPrefixLength = 6; + +document.addEventListener("dialogaccept", () => BuildTOC(true)); + +// Startup() is called when EdInsertTOC.xhtml is opened +function Startup() { + // early way out if if we have no editor + if (!GetCurrentEditor()) { + window.close(); + return; + } + + var i; + // clean the table of tag/class pairs we look for + for (i = 0; i < 6; ++i) { + tocHeadersArray[i] = ["", ""]; + } + + // reset all settings + for (i = 1; i < 7; ++i) { + var menulist = document.getElementById("header" + i + "Menulist"); + var menuitem = document.getElementById("header" + i + "none"); + var textbox = document.getElementById("header" + i + "Class"); + menulist.selectedItem = menuitem; + textbox.setAttribute("disabled", "true"); + } + + var theDocument = GetCurrentEditor().document; + + // do we already have a TOC in the document ? It should have "mozToc" ID + var toc = theDocument.getElementById(kMozToc); + + // default TOC definition, use h1-h6 for TOC entry levels 1-6 + var headers = "h1 1 h2 2 h3 3 h4 4 h5 5 h6 6"; + + var orderedListCheckbox = document.getElementById("orderedListCheckbox"); + orderedListCheckbox.checked = true; + + if (toc) { + // man, there is already a TOC here + + if (toc.getAttribute("class") == "readonly") { + // and it's readonly + var checkbox = document.getElementById("readOnlyCheckbox"); + checkbox.checked = true; + readonly = true; + } + + // let's see if it's an OL or an UL + orderedList = toc.nodeName.toLowerCase() == "ol"; + orderedListCheckbox.checked = orderedList; + + var nodeList = toc.childNodes; + // let's look at the children of the TOC ; if we find a comment beginning + // with "mozToc", it contains the TOC definition + for (i = 0; i < nodeList.length; ++i) { + if ( + nodeList.item(i).nodeType == Node.COMMENT_NODE && + nodeList.item(i).data.startsWith(kMozToc) + ) { + // yep, there is already a definition here; parse it ! + headers = nodeList + .item(i) + .data.substr( + kMozTocLength + 1, + nodeList.item(i).length - kMozTocLength - 1 + ); + break; + } + } + } + + // let's get an array filled with the (tag.class, index level) pairs + var headersArray = headers.split(" "); + + for (i = 0; i < headersArray.length; i += 2) { + var tag = headersArray[i], + className = ""; + var index = headersArray[i + 1]; + menulist = document.getElementById("header" + index + "Menulist"); + if (menulist) { + var sep = tag.indexOf("."); + if (sep != -1) { + // the tag variable contains in fact "tag.className", let's parse + // the class and get the real tag name + var tmp = tag.substr(0, sep); + className = tag.substr(sep + 1, tag.length - sep - 1); + tag = tmp; + } + + // update the dialog + menuitem = document.getElementById("header" + index + tag.toUpperCase()); + textbox = document.getElementById("header" + index + "Class"); + menulist.selectedItem = menuitem; + if (tag != "") { + textbox.removeAttribute("disabled"); + } + if (className != "") { + textbox.value = className; + } + tocHeadersArray[index - 1] = [tag, className]; + } + } +} + +function BuildTOC(update) { + // controlClass() is a node filter that accepts a node if + // (a) we don't look for a class (b) we look for a class and + // node has it + function controlClass(node, index) { + currentHeaderLevel = index + 1; + if (tocHeadersArray[index][1] == "") { + // we are not looking for a specific class, this node is ok + return NodeFilter.FILTER_ACCEPT; + } + if (node.getAttribute("class")) { + // yep, we look for a class, let's look at all the classes + // the node has + var classArray = node.getAttribute("class").split(" "); + for (var j = 0; j < classArray.length; j++) { + if (classArray[j] == tocHeadersArray[index][1]) { + // hehe, we found it... + return NodeFilter.FILTER_ACCEPT; + } + } + } + return NodeFilter.FILTER_SKIP; + } + + // the main node filter for our node iterator + // it selects the tag names as specified in the dialog + // then calls the controlClass filter above + function acceptNode(node) { + switch (node.nodeName.toLowerCase()) { + case tocHeadersArray[0][0]: + return controlClass(node, 0); + case tocHeadersArray[1][0]: + return controlClass(node, 1); + case tocHeadersArray[2][0]: + return controlClass(node, 2); + case tocHeadersArray[3][0]: + return controlClass(node, 3); + case tocHeadersArray[4][0]: + return controlClass(node, 4); + case tocHeadersArray[5][0]: + return controlClass(node, 5); + default: + return NodeFilter.FILTER_SKIP; + } + } + + var editor = GetCurrentEditor(); + var theDocument = editor.document; + // let's create a TreeWalker to look for our nodes + var treeWalker = theDocument.createTreeWalker( + theDocument.documentElement, + NodeFilter.SHOW_ELEMENT, + acceptNode, + true + ); + // we need an array to store all TOC entries we find in the document + var tocArray = []; + if (treeWalker) { + var tocSourceNode = treeWalker.nextNode(); + while (tocSourceNode) { + var headerIndex = currentHeaderLevel; + + // we have a node, we need to get all its textual contents + var textTreeWalker = theDocument.createTreeWalker( + tocSourceNode, + NodeFilter.SHOW_TEXT, + null, + true + ); + var textNode = textTreeWalker.nextNode(), + headerText = ""; + while (textNode) { + headerText += textNode.data; + textNode = textTreeWalker.nextNode(); + } + + var anchor = tocSourceNode.firstChild, + id; + // do we have a named anchor as 1st child of our node ? + if ( + anchor.nodeName.toLowerCase() == "a" && + anchor.hasAttribute("name") && + anchor.getAttribute("name").startsWith(kMozTocIdPrefix) + ) { + // yep, get its name + id = anchor.getAttribute("name"); + } else { + // no we don't and we need to create one + anchor = theDocument.createElement("a"); + tocSourceNode.insertBefore(anchor, tocSourceNode.firstChild); + // let's give it a random ID + var c = 1000000 * Math.random(); + id = kMozTocIdPrefix + Math.round(c); + anchor.setAttribute("name", id); + anchor.setAttribute( + "class", + kMozTocClassPrefix + tocSourceNode.nodeName.toUpperCase() + ); + } + // and store that new entry in our array + tocArray.push(headerIndex, headerText, id); + tocSourceNode = treeWalker.nextNode(); + } + } + + /* generate the TOC itself */ + headerIndex = 0; + var item, toc; + for (var i = 0; i < tocArray.length; i += 3) { + if (!headerIndex) { + // do we need to create an ol/ul container for the first entry ? + ++headerIndex; + toc = theDocument.getElementById(kMozToc); + if (!toc || !update) { + // we need to create a list container for the table of contents + toc = GetCurrentEditor().createElementWithDefaults( + orderedList ? "ol" : "ul" + ); + // grrr, we need to create a LI inside the list otherwise + // Composer will refuse an empty list and will remove it ! + var pit = theDocument.createElement("li"); + toc.appendChild(pit); + GetCurrentEditor().insertElementAtSelection(toc, true); + // ah, now it's inserted so let's remove the useless list item... + toc.removeChild(pit); + // we need to recognize later that this list is our TOC + toc.setAttribute("id", kMozToc); + } else if (orderedList != (toc.nodeName.toLowerCase() == "ol")) { + // we have to update an existing TOC, is the existing TOC of the + // desired type (ordered or not) ? + + // nope, we have to recreate the list + var newToc = GetCurrentEditor().createElementWithDefaults( + orderedList ? "ol" : "ul" + ); + toc.parentNode.insertBefore(newToc, toc); + // and remove the old one + toc.remove(); + toc = newToc; + toc.setAttribute("id", kMozToc); + } else { + // we can keep the list itself but let's get rid of the TOC entries + while (toc.hasChildNodes()) { + toc.lastChild.remove(); + } + } + + var commentText = "mozToc "; + for (var j = 0; j < 6; j++) { + if (tocHeadersArray[j][0] != "") { + commentText += tocHeadersArray[j][0]; + if (tocHeadersArray[j][1] != "") { + commentText += "." + tocHeadersArray[j][1]; + } + commentText += " " + (j + 1) + " "; + } + } + // important, we have to remove trailing spaces + commentText = TrimStringRight(commentText); + + // forge a comment we'll insert in the TOC ; that comment will hold + // the TOC definition for us + var ct = theDocument.createComment(commentText); + toc.appendChild(ct); + + // assign a special class to the TOC top element if the TOC is readonly + // the definition of this class is in EditorOverride.css + if (readonly) { + toc.setAttribute("class", "readonly"); + } else { + toc.removeAttribute("class"); + } + + // We need a new variable to hold the local ul/ol container + // The toplevel TOC element is not the parent element of a + // TOC entry if its depth is > 1... + var tocList = toc; + // create a list item + var tocItem = theDocument.createElement("li"); + // and an anchor in this list item + var tocAnchor = theDocument.createElement("a"); + // make it target the source of the TOC entry + tocAnchor.setAttribute("href", "#" + tocArray[i + 2]); + // and put the textual contents of the TOC entry in that anchor + var tocEntry = theDocument.createTextNode(tocArray[i + 1]); + // now, insert everything where it has to be inserted + tocAnchor.appendChild(tocEntry); + tocItem.appendChild(tocAnchor); + tocList.appendChild(tocItem); + item = tocList; + } else { + if (tocArray[i] < headerIndex) { + // if the depth of the new TOC entry is less than the depth of the + // last entry we created, find the good ul/ol ancestor + for (j = headerIndex - tocArray[i]; j > 0; --j) { + if (item != toc) { + item = item.parentNode.parentNode; + } + } + tocItem = theDocument.createElement("li"); + } else if (tocArray[i] > headerIndex) { + // to the contrary, it's deeper than the last one + // we need to create sub ul/ol's and li's + for (j = tocArray[i] - headerIndex; j > 0; --j) { + tocList = theDocument.createElement(orderedList ? "ol" : "ul"); + item.lastChild.appendChild(tocList); + tocItem = theDocument.createElement("li"); + tocList.appendChild(tocItem); + item = tocList; + } + } else { + tocItem = theDocument.createElement("li"); + } + tocAnchor = theDocument.createElement("a"); + tocAnchor.setAttribute("href", "#" + tocArray[i + 2]); + tocEntry = theDocument.createTextNode(tocArray[i + 1]); + tocAnchor.appendChild(tocEntry); + tocItem.appendChild(tocAnchor); + item.appendChild(tocItem); + headerIndex = tocArray[i]; + } + } + SaveWindowLocation(); +} + +function selectHeader(elt, index) { + var tag = elt.value; + tocHeadersArray[index - 1][0] = tag; + var textbox = document.getElementById("header" + index + "Class"); + if (tag == "") { + textbox.setAttribute("disabled", "true"); + } else { + textbox.removeAttribute("disabled"); + } +} + +function changeClass(elt, index) { + tocHeadersArray[index - 1][1] = elt.value; +} + +function ToggleReadOnlyToc(elt) { + readonly = elt.checked; +} + +function ToggleOrderedList(elt) { + orderedList = elt.checked; +} diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml new file mode 100644 index 0000000000..38c85c764d --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsertTOC.xhtml @@ -0,0 +1,505 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertTOC.dtd"> + +<window + title="&Window.title;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="Startup();" + lightweightthemes="true" + oncancel="window.close(); return true;" +> + <dialog> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdInsertTOC.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + <spacer id="dummy" style="display: none" /> + <vbox flex="1"> + <html:fieldset> + <html:legend>&buildToc.label;</html:legend> + <html:table> + <html:tr> + <html:th></html:th> + <html:th>&tag.label;</html:th> + <html:th>&class.label;</html:th> + </html:tr> + <html:tr> + <html:th id="header1Label">&header1.label;</html:th> + <html:td> + <menulist id="header1Menulist"> + <menupopup> + <menuitem + id="header1none" + label="--" + value="" + oncommand="selectHeader(this, 1)" + /> + <menuseparator /> + <menuitem + id="header1H1" + label="h1" + value="h1" + oncommand="selectHeader(this, 1)" + /> + <menuitem + id="header1H2" + label="h2" + value="h2" + oncommand="selectHeader(this, 1)" + /> + <menuitem + id="header1H3" + label="h3" + value="h3" + oncommand="selectHeader(this, 1)" + /> + <menuitem + id="header1H4" + label="h4" + value="h4" + oncommand="selectHeader(this, 1)" + /> + <menuitem + id="header1H5" + label="h5" + value="h5" + oncommand="selectHeader(this, 1)" + /> + <menuitem + id="header1H6" + label="h6" + value="h6" + oncommand="selectHeader(this, 1)" + /> + <menuitem + id="header1DIV" + label="div" + value="div" + oncommand="selectHeader(this, 1)" + /> + <menuitem + id="header1P" + label="p" + value="p" + oncommand="selectHeader(this, 1)" + /> + </menupopup> + </menulist> + </html:td> + <html:td> + <html:input + id="header1Class" + type="text" + class="input-inline" + size="10" + onchange="changeClass(this, 1)" + aria-labelledby="header1Label" + /> + </html:td> + </html:tr> + <html:tr> + <html:th id="header2Label">&header2.label;</html:th> + <html:td> + <menulist id="header2Menulist"> + <menupopup> + <menuitem + id="header2none" + label="--" + value="" + oncommand="selectHeader(this, 2)" + /> + <menuseparator /> + <menuitem + id="header2H1" + label="h1" + value="h1" + oncommand="selectHeader(this, 2)" + /> + <menuitem + id="header2H2" + label="h2" + value="h2" + oncommand="selectHeader(this, 2)" + /> + <menuitem + id="header2H3" + label="h3" + value="h3" + oncommand="selectHeader(this, 2)" + /> + <menuitem + id="header2H4" + label="h4" + value="h4" + oncommand="selectHeader(this, 2)" + /> + <menuitem + id="header2H5" + label="h5" + value="h5" + oncommand="selectHeader(this, 2)" + /> + <menuitem + id="header2H6" + label="h6" + value="h6" + oncommand="selectHeader(this, 2)" + /> + <menuitem + id="header2DIV" + label="div" + value="div" + oncommand="selectHeader(this, 2)" + /> + <menuitem + id="header2P" + label="p" + value="p" + oncommand="selectHeader(this, 2)" + /> + </menupopup> + </menulist> + </html:td> + <html:td> + <html:input + id="header2Class" + type="text" + class="input-inline" + size="10" + onchange="changeClass(this, 2)" + aria-labelledby="header2Label" + /> + </html:td> + </html:tr> + <html:tr> + <html:th id="header3Label">&header3.label;</html:th> + <html:td> + <menulist id="header3Menulist"> + <menupopup> + <menuitem + id="header3none" + label="--" + value="" + oncommand="selectHeader(this, 3)" + /> + <menuseparator /> + <menuitem + id="header3H1" + label="h1" + value="h1" + oncommand="selectHeader(this, 3)" + /> + <menuitem + id="header3H2" + label="h2" + value="h2" + oncommand="selectHeader(this, 3)" + /> + <menuitem + id="header3H3" + label="h3" + value="h3" + oncommand="selectHeader(this, 3)" + /> + <menuitem + id="header3H4" + label="h4" + value="h4" + oncommand="selectHeader(this, 3)" + /> + <menuitem + id="header3H5" + label="h5" + value="h5" + oncommand="selectHeader(this, 3)" + /> + <menuitem + id="header3H6" + label="h6" + value="h6" + oncommand="selectHeader(this, 3)" + /> + <menuitem + id="header3DIV" + label="div" + value="div" + oncommand="selectHeader(this, 3)" + /> + <menuitem + id="header3P" + label="p" + value="p" + oncommand="selectHeader(this, 3)" + /> + </menupopup> + </menulist> + </html:td> + <html:td> + <html:input + id="header3Class" + type="text" + class="input-inline" + size="10" + onchange="changeClass(this, 3)" + aria-labelledby="header3Label" + /> + </html:td> + </html:tr> + <html:tr> + <html:th id="header4Label">&header4.label;</html:th> + <html:td> + <menulist id="header4Menulist"> + <menupopup> + <menuitem + id="header4none" + label="--" + value="" + oncommand="selectHeader(this, 4)" + /> + <menuseparator /> + <menuitem + id="header4H1" + label="h1" + value="h1" + oncommand="selectHeader(this, 4)" + /> + <menuitem + id="header4H2" + label="h2" + value="h2" + oncommand="selectHeader(this, 4)" + /> + <menuitem + id="header4H3" + label="h3" + value="h3" + oncommand="selectHeader(this, 4)" + /> + <menuitem + id="header4H4" + label="h4" + value="h4" + oncommand="selectHeader(this, 4)" + /> + <menuitem + id="header4H5" + label="h5" + value="h5" + oncommand="selectHeader(this, 4)" + /> + <menuitem + id="header4H6" + label="h6" + value="h6" + oncommand="selectHeader(this, 4)" + /> + <menuitem + id="header4DIV" + label="div" + value="div" + oncommand="selectHeader(this, 4)" + /> + <menuitem + id="header4P" + label="p" + value="p" + oncommand="selectHeader(this, 4)" + /> + </menupopup> + </menulist> + </html:td> + <html:td> + <html:input + id="header4Class" + type="text" + class="input-inline" + size="10" + onchange="changeClass(this, 4)" + aria-labelledby="header4Label" + /> + </html:td> + </html:tr> + <html:tr> + <html:th id="header5Label">&header5.label;</html:th> + <html:td> + <menulist id="header5Menulist"> + <menupopup> + <menuitem + id="header5none" + label="--" + value="" + oncommand="selectHeader(this, 5)" + /> + <menuseparator /> + <menuitem + id="header5H1" + label="h1" + value="h1" + oncommand="selectHeader(this, 5)" + /> + <menuitem + id="header5H2" + label="h2" + value="h2" + oncommand="selectHeader(this, 5)" + /> + <menuitem + id="header5H3" + label="h3" + value="h3" + oncommand="selectHeader(this, 5)" + /> + <menuitem + id="header5H4" + label="h4" + value="h4" + oncommand="selectHeader(this, 5)" + /> + <menuitem + id="header5H5" + label="h5" + value="h5" + oncommand="selectHeader(this, 5)" + /> + <menuitem + id="header5H6" + label="h6" + value="h6" + oncommand="selectHeader(this, 5)" + /> + <menuitem + id="header5DIV" + label="div" + value="div" + oncommand="selectHeader(this, 5)" + /> + <menuitem + id="header5P" + label="p" + value="p" + oncommand="selectHeader(this, 5)" + /> + </menupopup> + </menulist> + </html:td> + <html:td> + <html:input + id="header5Class" + type="text" + class="input-inline" + size="10" + onchange="changeClass(this, 5)" + aria-labelledby="header5Label" + /> + </html:td> + </html:tr> + <html:tr> + <html:th id="header6Label">&header6.label;</html:th> + <html:td> + <menulist id="header6Menulist"> + <menupopup> + <menuitem + id="header6none" + label="--" + value="" + oncommand="selectHeader(this, 6)" + /> + <menuseparator /> + <menuitem + id="header6H1" + label="h1" + value="h1" + oncommand="selectHeader(this, 6)" + /> + <menuitem + id="header6H2" + label="h2" + value="h2" + oncommand="selectHeader(this, 6)" + /> + <menuitem + id="header6H3" + label="h3" + value="h3" + oncommand="selectHeader(this, 6)" + /> + <menuitem + id="header6H4" + label="h4" + value="h4" + oncommand="selectHeader(this, 6)" + /> + <menuitem + id="header6H5" + label="h5" + value="h5" + oncommand="selectHeader(this, 6)" + /> + <menuitem + id="header6H6" + label="h6" + value="h6" + oncommand="selectHeader(this, 6)" + /> + <menuitem + id="header6DIV" + label="div" + value="div" + oncommand="selectHeader(this, 6)" + /> + <menuitem + id="header6P" + label="p" + value="p" + oncommand="selectHeader(this, 6)" + /> + </menupopup> + </menulist> + </html:td> + <html:td> + <html:input + id="header6Class" + type="text" + class="input-inline" + size="10" + onchange="changeClass(this, 6)" + aria-labelledby="header6Label" + /> + </html:td> + </html:tr> + </html:table> + </html:fieldset> + <vbox> + <checkbox + id="orderedListCheckbox" + label="&orderedList.label;" + oncommand="ToggleOrderedList(this)" + /> + <checkbox + id="readOnlyCheckbox" + label="&makeReadOnly.label;" + oncommand="ToggleReadOnlyToc(this)" + /> + </vbox> + <separator class="groove" /> + </vbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTable.js b/comm/mail/components/compose/content/dialogs/EdInsertTable.js new file mode 100644 index 0000000000..5da0da46d3 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsertTable.js @@ -0,0 +1,258 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +// Cancel() is in EdDialogCommon.js + +var gTableElement = null; +var gRows; +var gColumns; +var gActiveEditor; + +// dialog initialization code + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + gActiveEditor = GetCurrentTableEditor(); + if (!gActiveEditor) { + dump("Failed to get active editor!\n"); + window.close(); + return; + } + + try { + gTableElement = gActiveEditor.createElementWithDefaults("table"); + } catch (e) {} + + if (!gTableElement) { + dump("Failed to create a new table!\n"); + window.close(); + return; + } + gDialog.rowsInput = document.getElementById("rowsInput"); + gDialog.columnsInput = document.getElementById("columnsInput"); + gDialog.widthInput = document.getElementById("widthInput"); + gDialog.borderInput = document.getElementById("borderInput"); + gDialog.widthPixelOrPercentMenulist = document.getElementById( + "widthPixelOrPercentMenulist" + ); + gDialog.OkButton = document.querySelector("dialog").getButton("accept"); + + // Make a copy to use for AdvancedEdit + globalElement = gTableElement.cloneNode(false); + try { + if ( + Services.prefs.getBoolPref("editor.use_css") && + IsHTMLEditor() && + !(gActiveEditor.flags & Ci.nsIEditor.eEditorMailMask) + ) { + // only for Composer and not for htmlmail + globalElement.setAttribute("style", "text-align: left;"); + } + } catch (e) {} + + // Initialize all widgets with image attributes + InitDialog(); + + // Set initial number to 2 rows, 2 columns: + // Note, these are not attributes on the table, + // so don't put them in InitDialog(), + // else the user's values will be trashed when they use + // the Advanced Edit dialog + gDialog.rowsInput.value = 2; + gDialog.columnsInput.value = 2; + + // If no default value on the width, set to 100% + if (gDialog.widthInput.value.length == 0) { + gDialog.widthInput.value = "100"; + gDialog.widthPixelOrPercentMenulist.selectedIndex = 1; + } + + SetTextboxFocusById("rowsInput"); + + SetWindowLocation(); +} + +// Set dialog widgets with attribute data +// We get them from globalElement copy so this can be used +// by AdvancedEdit(), which is shared by all property dialogs +function InitDialog() { + // Get default attributes set on the created table: + // Get the width attribute of the element, stripping out "%" + // This sets contents of menu combobox list + // 2nd param = null: Use current selection to find if parent is table cell or window + gDialog.widthInput.value = InitPixelOrPercentMenulist( + globalElement, + null, + "width", + "widthPixelOrPercentMenulist", + gPercent + ); + gDialog.borderInput.value = globalElement.getAttribute("border"); +} + +function ChangeRowOrColumn(id) { + // Allow only integers + forceInteger(id); + + // Enable OK only if both rows and columns have a value > 0 + var enable = + gDialog.rowsInput.value.length > 0 && + gDialog.rowsInput.value > 0 && + gDialog.columnsInput.value.length > 0 && + gDialog.columnsInput.value > 0; + + SetElementEnabled(gDialog.OkButton, enable); + SetElementEnabledById("AdvancedEditButton1", enable); +} + +// Get and validate data from widgets. +// Set attributes on globalElement so they can be accessed by AdvancedEdit() +function ValidateData() { + gRows = ValidateNumber( + gDialog.rowsInput, + null, + 1, + gMaxRows, + null, + null, + true + ); + if (gValidationError) { + return false; + } + + gColumns = ValidateNumber( + gDialog.columnsInput, + null, + 1, + gMaxColumns, + null, + null, + true + ); + if (gValidationError) { + return false; + } + + // Set attributes: NOTE: These may be empty strings (last param = false) + ValidateNumber( + gDialog.borderInput, + null, + 0, + gMaxPixels, + globalElement, + "border", + false + ); + // TODO: Deal with "BORDER" without value issue + if (gValidationError) { + return false; + } + + ValidateNumber( + gDialog.widthInput, + gDialog.widthPixelOrPercentMenulist, + 1, + gMaxTableSize, + globalElement, + "width", + false + ); + if (gValidationError) { + return false; + } + + return true; +} + +function onAccept(event) { + if (ValidateData()) { + gActiveEditor.beginTransaction(); + try { + gActiveEditor.cloneAttributes(gTableElement, globalElement); + + // Create necessary rows and cells for the table + var tableBody = gActiveEditor.createElementWithDefaults("tbody"); + if (tableBody) { + gTableElement.appendChild(tableBody); + + // Create necessary rows and cells for the table + for (var i = 0; i < gRows; i++) { + var newRow = gActiveEditor.createElementWithDefaults("tr"); + if (newRow) { + tableBody.appendChild(newRow); + for (var j = 0; j < gColumns; j++) { + var newCell = gActiveEditor.createElementWithDefaults("td"); + if (newCell) { + newRow.appendChild(newCell); + } + } + } + } + } + // Detect when entire cells are selected: + // Get number of cells selected + var tagNameObj = { value: "" }; + var countObj = { value: 0 }; + var element = gActiveEditor.getSelectedOrParentTableElement( + tagNameObj, + countObj + ); + var deletePlaceholder = false; + + if (tagNameObj.value == "table") { + // Replace entire selected table with new table, so delete the table + gActiveEditor.deleteTable(); + } else if (tagNameObj.value == "td") { + if (countObj.value >= 1) { + if (countObj.value > 1) { + // Assume user wants to replace a block of + // contiguous cells with a table, so + // join the selected cells + gActiveEditor.joinTableCells(false); + + // Get the cell everything was merged into + element = gActiveEditor.getSelectedCells()[0]; + + // Collapse selection into just that cell + gActiveEditor.selection.collapse(element, 0); + } + + if (element) { + // Empty just the contents of the cell + gActiveEditor.deleteTableCellContents(); + + // Collapse selection to start of empty cell... + gActiveEditor.selection.collapse(element, 0); + // ...but it will contain a <br> placeholder + deletePlaceholder = true; + } + } + } + + // true means delete selection when inserting + gActiveEditor.insertElementAtSelection(gTableElement, true); + + if ( + deletePlaceholder && + gTableElement && + gTableElement.nextElementSibling + ) { + // Delete the placeholder <br> + gActiveEditor.deleteNode(gTableElement.nextElementSibling); + } + } catch (e) {} + + gActiveEditor.endTransaction(); + + SaveWindowLocation(); + return; + } + event.preventDefault(); +} diff --git a/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml b/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml new file mode 100644 index 0000000000..b114e09d44 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdInsertTable.xhtml @@ -0,0 +1,126 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % edInsertTable SYSTEM "chrome://messenger/locale/messengercompose/EditorInsertTable.dtd"> +%edInsertTable; +<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd"> +%edDialogOverlay; ]> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup()" +> + <dialog> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdInsertTable.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + <html:table> + <html:tr> + <html:th> + <label + control="rowsInput" + value="&numRowsEditField.label;" + accesskey="&numRowsEditField.accessKey;" + /> + </html:th> + <html:td> + <html:input + id="rowsInput" + type="number" + class="narrow input-inline" + oninput="ChangeRowOrColumn(this.id)" + /> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label + control="columnsInput" + value="&numColumnsEditField.label;" + accesskey="&numColumnsEditField.accessKey;" + /> + </html:th> + <html:td> + <html:input + id="columnsInput" + type="number" + class="narrow input-inline" + oninput="ChangeRowOrColumn(this.id)" + /> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label + control="widthInput" + value="&widthEditField.label;" + accesskey="&widthEditField.accessKey;" + /> + </html:th> + <html:td> + <html:input + id="widthInput" + type="number" + class="narrow input-inline" + oninput="forceInteger(this.id)" + /> + </html:td> + <html:td> + <menulist id="widthPixelOrPercentMenulist" class="menulist-narrow" /> + </html:td> + </html:tr> + <html:tr> + <html:th> + <label + control="borderInput" + value="&borderEditField.label;" + accesskey="&borderEditField.accessKey;" + tooltiptext="&borderEditField.tooltip;" + /> + </html:th> + <html:td> + <html:input + id="borderInput" + type="number" + class="narrow input-inline" + oninput="forceInteger(this.id)" + /> + </html:td> + <html:td> + <label value="&pixels.label;" /> + </html:td> + </html:tr> + </html:table> + <vbox id="AdvancedEdit"> + <hbox flex="1" style="margin-top: 0.2em" align="center"> + <!-- This will right-align the button --> + <spacer flex="1" /> + <button + id="AdvancedEditButton1" + oncommand="onAdvancedEdit()" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + <separator id="advancedSeparator" class="groove" /> + </vbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdLinkProps.js b/comm/mail/components/compose/content/dialogs/EdLinkProps.js new file mode 100644 index 0000000000..903a4d3099 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdLinkProps.js @@ -0,0 +1,323 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var gActiveEditor; +var anchorElement = null; +var imageElement = null; +var insertNew = false; +var replaceExistingLink = false; +var insertLinkAtCaret; +var needLinkText = false; +var href; +var newLinkText; +var gHNodeArray = {}; +var gHaveNamedAnchors = false; +var gHaveHeadings = false; +var gCanChangeHeadingSelected = true; +var gCanChangeAnchorSelected = true; + +// NOTE: Use "href" instead of "a" to distinguish from Named Anchor +// The returned node is has an "a" tagName +var tagName = "href"; + +// dialog initialization code + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + gActiveEditor = GetCurrentEditor(); + if (!gActiveEditor) { + dump("Failed to get active editor!\n"); + window.close(); + return; + } + // Message was wrapped in a <label> or <div>, so actual text is a child text node + gDialog.linkTextCaption = document.getElementById("linkTextCaption"); + gDialog.linkTextMessage = document.getElementById("linkTextMessage"); + gDialog.linkTextInput = document.getElementById("linkTextInput"); + gDialog.hrefInput = document.getElementById("hrefInput"); + gDialog.makeRelativeLink = document.getElementById("MakeRelativeLink"); + gDialog.AdvancedEditSection = document.getElementById("AdvancedEdit"); + + // See if we have a single selected image + imageElement = gActiveEditor.getSelectedElement("img"); + + if (imageElement) { + // Get the parent link if it exists -- more efficient than GetSelectedElement() + anchorElement = gActiveEditor.getElementOrParentByTagName( + "href", + imageElement + ); + if (anchorElement) { + if (anchorElement.children.length > 1) { + // If there are other children, then we want to break + // this image away by inserting a new link around it, + // so make a new node and copy existing attributes + anchorElement = anchorElement.cloneNode(false); + // insertNew = true; + replaceExistingLink = true; + } + } + } else { + // Get an anchor element if caret or + // entire selection is within the link. + anchorElement = gActiveEditor.getSelectedElement(tagName); + + if (anchorElement) { + // Select the entire link + gActiveEditor.selectElement(anchorElement); + } else { + // If selection starts in a link, but extends beyond it, + // the user probably wants to extend existing link to new selection, + // so check if either end of selection is within a link + // POTENTIAL PROBLEM: This prevents user from selecting text in an existing + // link and making 2 links. + // Note that this isn't a problem with images, handled above + + anchorElement = gActiveEditor.getElementOrParentByTagName( + "href", + gActiveEditor.selection.anchorNode + ); + if (!anchorElement) { + anchorElement = gActiveEditor.getElementOrParentByTagName( + "href", + gActiveEditor.selection.focusNode + ); + } + + if (anchorElement) { + // But clone it for reinserting/merging around existing + // link that only partially overlaps the selection + anchorElement = anchorElement.cloneNode(false); + // insertNew = true; + replaceExistingLink = true; + } + } + } + + if (!anchorElement) { + // No existing link -- create a new one + anchorElement = gActiveEditor.createElementWithDefaults(tagName); + insertNew = true; + // Hide message about removing existing link + // document.getElementById("RemoveLinkMsg").hidden = true; + } + if (!anchorElement) { + dump("Failed to get selected element or create a new one!\n"); + window.close(); + return; + } + + // We insert at caret only when nothing is selected + insertLinkAtCaret = gActiveEditor.selection.isCollapsed; + + var selectedText; + if (insertLinkAtCaret) { + // Groupbox caption: + gDialog.linkTextCaption.setAttribute("label", GetString("LinkText")); + + // Message above input field: + gDialog.linkTextMessage.setAttribute("value", GetString("EnterLinkText")); + gDialog.linkTextMessage.setAttribute( + "accesskey", + GetString("EnterLinkTextAccessKey") + ); + } else { + if (!imageElement) { + // We get here if selection is exactly around a link node + // Check if selection has some text - use that first + selectedText = GetSelectionAsText(); + if (!selectedText) { + // No text, look for first image in the selection + imageElement = anchorElement.querySelector("img"); + } + } + // Set "caption" for link source and the source text or image URL + if (imageElement) { + gDialog.linkTextCaption.setAttribute("label", GetString("LinkImage")); + // Link source string is the source URL of image + // TODO: THIS DOESN'T HANDLE MULTIPLE SELECTED IMAGES! + gDialog.linkTextMessage.setAttribute("value", imageElement.src); + } else { + gDialog.linkTextCaption.setAttribute("label", GetString("LinkText")); + if (selectedText) { + // Use just the first 60 characters and add "..." + gDialog.linkTextMessage.setAttribute( + "value", + TruncateStringAtWordEnd( + ReplaceWhitespace(selectedText, " "), + 60, + true + ) + ); + } else { + gDialog.linkTextMessage.setAttribute( + "value", + GetString("MixedSelection") + ); + } + } + } + + // Make a copy to use for AdvancedEdit and onSaveDefault + globalElement = anchorElement.cloneNode(false); + + // Get the list of existing named anchors and headings + FillLinkMenulist(gDialog.hrefInput, gHNodeArray); + + // We only need to test for this once per dialog load + gHaveDocumentUrl = GetDocumentBaseUrl(); + + // Set data for the dialog controls + InitDialog(); + + // Search for a URI pattern in the selected text + // as candidate href + selectedText = TrimString(selectedText); + if (!gDialog.hrefInput.value && TextIsURI(selectedText)) { + gDialog.hrefInput.value = selectedText; + } + + // Set initial focus + if (insertLinkAtCaret) { + // We will be using the HREF inputbox, so text message + gDialog.linkTextInput.focus(); + } else { + gDialog.hrefInput.select(); + gDialog.hrefInput.focus(); + + // We will not insert a new link at caret, so remove link text input field + gDialog.linkTextInput.hidden = true; + gDialog.linkTextInput = null; + } + + // This sets enable state on OK button + doEnabling(); + + SetWindowLocation(); +} + +// Set dialog widgets with attribute data +// We get them from globalElement copy so this can be used +// by AdvancedEdit(), which is shared by all property dialogs +function InitDialog() { + // Must use getAttribute, not "globalElement.href", + // or foreign chars aren't converted correctly! + gDialog.hrefInput.value = globalElement.getAttribute("href"); + + // Set "Relativize" checkbox according to current URL state + SetRelativeCheckbox(gDialog.makeRelativeLink); +} + +function doEnabling() { + // We disable Ok button when there's no href text only if inserting a new link + var enable = insertNew + ? TrimString(gDialog.hrefInput.value).length > 0 + : true; + + // anon. content, so can't use SetElementEnabledById here + var dialogNode = document.getElementById("linkDlg"); + dialogNode.getButton("accept").disabled = !enable; + + SetElementEnabledById("AdvancedEditButton1", enable); +} + +function ChangeLinkLocation() { + SetRelativeCheckbox(gDialog.makeRelativeLink); + // Set OK button enable state + doEnabling(); +} + +// Get and validate data from widgets. +// Set attributes on globalElement so they can be accessed by AdvancedEdit() +function ValidateData() { + href = TrimString(gDialog.hrefInput.value); + if (href) { + // Set the HREF directly on the editor document's anchor node + // or on the newly-created node if insertNew is true + globalElement.setAttribute("href", href); + } else if (insertNew) { + // We must have a URL to insert a new link + // NOTE: We accept an empty HREF on existing link to indicate removing the link + ShowInputErrorMessage(GetString("EmptyHREFError")); + return false; + } + if (gDialog.linkTextInput) { + // The text we will insert isn't really an attribute, + // but it makes sense to validate it + newLinkText = TrimString(gDialog.linkTextInput.value); + if (!newLinkText) { + if (href) { + newLinkText = href; + } else { + ShowInputErrorMessage(GetString("EmptyLinkTextError")); + SetTextboxFocus(gDialog.linkTextInput); + return false; + } + } + } + return true; +} + +function onAccept(event) { + if (ValidateData()) { + if (href.length > 0) { + // Copy attributes to element we are changing or inserting + gActiveEditor.cloneAttributes(anchorElement, globalElement); + + // Coalesce into one undo transaction + gActiveEditor.beginTransaction(); + + // Get text to use for a new link + if (insertLinkAtCaret) { + // Append the link text as the last child node + // of the anchor node + var textNode = gActiveEditor.document.createTextNode(newLinkText); + if (textNode) { + anchorElement.appendChild(textNode); + } + try { + gActiveEditor.insertElementAtSelection(anchorElement, false); + } catch (e) { + dump("Exception occurred in InsertElementAtSelection\n"); + return; + } + } else if (insertNew || replaceExistingLink) { + // Link source was supplied by the selection, + // so insert a link node as parent of this + // (may be text, image, or other inline content) + try { + gActiveEditor.insertLinkAroundSelection(anchorElement); + } catch (e) { + dump("Exception occurred in InsertElementAtSelection\n"); + return; + } + } + // Check if the link was to a heading + if (href in gHNodeArray) { + var anchorNode = gActiveEditor.createElementWithDefaults("a"); + if (anchorNode) { + anchorNode.name = href.substr(1); + + // Insert the anchor into the document, + // but don't let the transaction change the selection + gActiveEditor.setShouldTxnSetSelection(false); + gActiveEditor.insertNode(anchorNode, gHNodeArray[href], 0); + gActiveEditor.setShouldTxnSetSelection(true); + } + } + gActiveEditor.endTransaction(); + } else if (!insertNew) { + // We already had a link, but empty HREF means remove it + EditorRemoveTextProperty("href", ""); + } + SaveWindowLocation(); + return; + } + event.preventDefault(); +} diff --git a/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml b/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml new file mode 100644 index 0000000000..7c550a7a45 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdLinkProps.xhtml @@ -0,0 +1,112 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % linkPropertiesDTD SYSTEM "chrome://messenger/locale/messengercompose/EditorLinkProperties.dtd"> +%linkPropertiesDTD; +<!ENTITY % composeEditorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/mailComposeEditorOverlay.dtd"> +%composeEditorOverlayDTD; +<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd"> +%edDialogOverlay; ]> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup()" + style="min-height: 26em" +> + <dialog id="linkDlg" style="width: 50ch"> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdLinkProps.js" /> + <script src="chrome://messenger/content/messengercompose/EdImageLinkLoader.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <vbox> + <html:fieldset> + <html:legend><label id="linkTextCaption" /></html:legend> + <vbox> + <label id="linkTextMessage" control="linkTextInput" /> + <html:input + id="linkTextInput" + type="text" + class="input-inline" + aria-labelledby="linkTextMessage" + /> + </vbox> + </html:fieldset> + + <html:fieldset id="LinkURLBox"> + <html:legend>&LinkURLBox.label;</html:legend> + <vbox id="LinkLocationBox"> + <label + id="hrefLabel" + control="hrefInput" + accesskey="&LinkURLEditField2.accessKey;" + width="1" + >&LinkURLEditField2.label;</label + > + <html:input + id="hrefInput" + type="text" + class="input-inline uri-element padded" + oninput="ChangeLinkLocation();" + aria-labelledby="hrefLabel" + /> + <hbox align="center"> + <checkbox + id="MakeRelativeLink" + for="hrefInput" + label="&makeUrlRelative.label;" + accesskey="&makeUrlRelative.accessKey;" + oncommand="MakeInputValueRelativeOrAbsolute(this);" + tooltiptext="&makeUrlRelative.tooltip;" + /> + <spacer flex="1" /> + <button + label="&chooseFileLinkButton.label;" + accesskey="&chooseFileLinkButton.accessKey;" + oncommand="chooseLinkFile();" + /> + </hbox> + </vbox> + <checkbox + id="AttachSourceToMail" + hidden="true" + label="&attachLinkSource.label;" + accesskey="&attachLinkSource.accesskey;" + oncommand="DoAttachSourceCheckbox()" + /> + </html:fieldset> + </vbox> + <vbox id="AdvancedEdit"> + <hbox flex="1" style="margin-top: 0.2em" align="center"> + <!-- This will right-align the button --> + <spacer flex="1" /> + <button + id="AdvancedEditButton1" + oncommand="onAdvancedEdit()" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + <separator id="advancedSeparator" class="groove" /> + </vbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdListProps.js b/comm/mail/components/compose/content/dialogs/EdListProps.js new file mode 100644 index 0000000000..c33efc9bb1 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdListProps.js @@ -0,0 +1,455 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +// Cancel() is in EdDialogCommon.js +var gBulletStyleType = ""; +var gNumberStyleType = ""; +var gListElement; +var gOriginalListType = ""; +var gListType = ""; +var gMixedListSelection = false; +var gStyleType = ""; +var gOriginalStyleType = ""; +const gOnesArray = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; +const gTensArray = ["", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"]; +const gHundredsArray = [ + "", + "C", + "CC", + "CCC", + "CD", + "D", + "DC", + "DCC", + "DCCC", + "CM", +]; +const gThousandsArray = [ + "", + "M", + "MM", + "MMM", + "MMMM", + "MMMMM", + "MMMMMM", + "MMMMMMM", + "MMMMMMMM", + "MMMMMMMMM", +]; +const gRomanDigits = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 }; +const A = "A".charCodeAt(0); +const gArabic = "1"; +const gUpperRoman = "I"; +const gLowerRoman = "i"; +const gUpperLetters = "A"; +const gLowerLetters = "a"; +const gDecimalCSS = "decimal"; +const gUpperRomanCSS = "upper-roman"; +const gLowerRomanCSS = "lower-roman"; +const gUpperAlphaCSS = "upper-alpha"; +const gLowerAlphaCSS = "lower-alpha"; + +// dialog initialization code + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + var editor = GetCurrentEditor(); + if (!editor) { + window.close(); + return; + } + gDialog.ListTypeList = document.getElementById("ListType"); + gDialog.BulletStyleList = document.getElementById("BulletStyle"); + gDialog.BulletStyleLabel = document.getElementById("BulletStyleLabel"); + gDialog.StartingNumberInput = document.getElementById("StartingNumber"); + gDialog.StartingNumberLabel = document.getElementById("StartingNumberLabel"); + gDialog.AdvancedEditButton = document.getElementById("AdvancedEditButton1"); + gDialog.RadioGroup = document.getElementById("RadioGroup"); + gDialog.ChangeAllRadio = document.getElementById("ChangeAll"); + gDialog.ChangeSelectedRadio = document.getElementById("ChangeSelected"); + + // Try to get an existing list(s) + var mixedObj = { value: null }; + try { + gListType = editor.getListState(mixedObj, {}, {}, {}); + + // We may have mixed list and non-list, or > 1 list type in selection + gMixedListSelection = mixedObj.value; + + // Get the list element at the anchor node + gListElement = editor.getElementOrParentByTagName("list", null); + } catch (e) {} + + // The copy to use in AdvancedEdit + if (gListElement) { + globalElement = gListElement.cloneNode(false); + } + + // Show extra options for changing entire list if we have one already. + gDialog.RadioGroup.collapsed = !gListElement; + if (gListElement) { + // Radio button index is persistent + if (gDialog.RadioGroup.getAttribute("index") == "1") { + gDialog.RadioGroup.selectedItem = gDialog.ChangeSelectedRadio; + } else { + gDialog.RadioGroup.selectedItem = gDialog.ChangeAllRadio; + } + } + + InitDialog(); + + gOriginalListType = gListType; + + gDialog.ListTypeList.focus(); + + SetWindowLocation(); +} + +function InitDialog() { + // Note that if mixed, we we pay attention + // only to the anchor node's list type + // (i.e., don't confuse user with "mixed" designation) + if (gListElement) { + gListType = gListElement.nodeName.toLowerCase(); + } else { + gListType = ""; + } + + gDialog.ListTypeList.value = gListType; + gDialog.StartingNumberInput.value = ""; + + // Last param = true means attribute value is case-sensitive + var type = globalElement + ? GetHTMLOrCSSStyleValue(globalElement, "type", "list-style-type") + : null; + + if (gListType == "ul") { + if (type) { + type = type.toLowerCase(); + gBulletStyleType = type; + gOriginalStyleType = type; + } + } else if (gListType == "ol") { + // Translate CSS property strings + switch (type.toLowerCase()) { + case gDecimalCSS: + type = gArabic; + break; + case gUpperRomanCSS: + type = gUpperRoman; + break; + case gLowerRomanCSS: + type = gLowerRoman; + break; + case gUpperAlphaCSS: + type = gUpperLetters; + break; + case gLowerAlphaCSS: + type = gLowerLetters; + break; + } + if (type) { + gNumberStyleType = type; + gOriginalStyleType = type; + } + + // Convert attribute number to appropriate letter or roman numeral + gDialog.StartingNumberInput.value = ConvertStartAttrToUserString( + globalElement.getAttribute("start"), + type + ); + } + BuildBulletStyleList(); +} + +// Convert attribute number to appropriate letter or roman numeral +function ConvertStartAttrToUserString(startAttr, type) { + switch (type) { + case gUpperRoman: + startAttr = ConvertArabicToRoman(startAttr); + break; + case gLowerRoman: + startAttr = ConvertArabicToRoman(startAttr).toLowerCase(); + break; + case gUpperLetters: + startAttr = ConvertArabicToLetters(startAttr); + break; + case gLowerLetters: + startAttr = ConvertArabicToLetters(startAttr).toLowerCase(); + break; + } + return startAttr; +} + +function BuildBulletStyleList() { + gDialog.BulletStyleList.removeAllItems(); + var label; + + if (gListType == "ul") { + gDialog.BulletStyleList.removeAttribute("disabled"); + gDialog.BulletStyleLabel.removeAttribute("disabled"); + gDialog.StartingNumberInput.setAttribute("disabled", "true"); + gDialog.StartingNumberLabel.setAttribute("disabled", "true"); + + label = GetString("BulletStyle"); + + gDialog.BulletStyleList.appendItem(GetString("Automatic"), ""); + gDialog.BulletStyleList.appendItem(GetString("SolidCircle"), "disc"); + gDialog.BulletStyleList.appendItem(GetString("OpenCircle"), "circle"); + gDialog.BulletStyleList.appendItem(GetString("SolidSquare"), "square"); + + gDialog.BulletStyleList.value = gBulletStyleType; + } else if (gListType == "ol") { + gDialog.BulletStyleList.removeAttribute("disabled"); + gDialog.BulletStyleLabel.removeAttribute("disabled"); + gDialog.StartingNumberInput.removeAttribute("disabled"); + gDialog.StartingNumberLabel.removeAttribute("disabled"); + label = GetString("NumberStyle"); + + gDialog.BulletStyleList.appendItem(GetString("Automatic"), ""); + gDialog.BulletStyleList.appendItem(GetString("Style_1"), gArabic); + gDialog.BulletStyleList.appendItem(GetString("Style_I"), gUpperRoman); + gDialog.BulletStyleList.appendItem(GetString("Style_i"), gLowerRoman); + gDialog.BulletStyleList.appendItem(GetString("Style_A"), gUpperLetters); + gDialog.BulletStyleList.appendItem(GetString("Style_a"), gLowerLetters); + + gDialog.BulletStyleList.value = gNumberStyleType; + } else { + gDialog.BulletStyleList.setAttribute("disabled", "true"); + gDialog.BulletStyleLabel.setAttribute("disabled", "true"); + gDialog.StartingNumberInput.setAttribute("disabled", "true"); + gDialog.StartingNumberLabel.setAttribute("disabled", "true"); + } + + // Disable advanced edit button if changing to "normal" + if (gListType) { + gDialog.AdvancedEditButton.removeAttribute("disabled"); + } else { + gDialog.AdvancedEditButton.setAttribute("disabled", "true"); + } + + if (label) { + gDialog.BulletStyleLabel.textContent = label; + } +} + +function SelectListType() { + // Each list type is stored in the "value" of each menuitem + var NewType = gDialog.ListTypeList.value; + + if (NewType == "ol") { + SetTextboxFocus(gDialog.StartingNumberInput); + } + + if (gListType != NewType) { + gListType = NewType; + + // Create a newlist object for Advanced Editing + try { + if (gListType) { + globalElement = GetCurrentEditor().createElementWithDefaults(gListType); + } + } catch (e) {} + + BuildBulletStyleList(); + } +} + +function SelectBulletStyle() { + // Save the selected index so when user changes + // list style, restore index to associated list + // Each bullet or number type is stored in the "value" of each menuitem + if (gListType == "ul") { + gBulletStyleType = gDialog.BulletStyleList.value; + } else if (gListType == "ol") { + var type = gDialog.BulletStyleList.value; + if (gNumberStyleType != type) { + // Convert existing input value to attr number first, + // then convert to the appropriate format for the newly-selected + gDialog.StartingNumberInput.value = ConvertStartAttrToUserString( + ConvertUserStringToStartAttr(gNumberStyleType), + type + ); + + gNumberStyleType = type; + SetTextboxFocus(gDialog.StartingNumberInput); + } + } +} + +function ValidateData() { + gBulletStyleType = gDialog.BulletStyleList.value; + // globalElement should already be of the correct type + + if (globalElement) { + var editor = GetCurrentEditor(); + if (gListType == "ul") { + if (gBulletStyleType && gDialog.ChangeAllRadio.selected) { + globalElement.setAttribute("type", gBulletStyleType); + } else { + try { + editor.removeAttributeOrEquivalent(globalElement, "type", true); + } catch (e) {} + } + } else if (gListType == "ol") { + if (gBulletStyleType) { + globalElement.setAttribute("type", gBulletStyleType); + } else { + try { + editor.removeAttributeOrEquivalent(globalElement, "type", true); + } catch (e) {} + } + + var startingNumber = ConvertUserStringToStartAttr(gBulletStyleType); + if (startingNumber) { + globalElement.setAttribute("start", startingNumber); + } else { + globalElement.removeAttribute("start"); + } + } + } + return true; +} + +function ConvertUserStringToStartAttr(type) { + var startingNumber = TrimString(gDialog.StartingNumberInput.value); + + switch (type) { + case gUpperRoman: + case gLowerRoman: + // If the input isn't an integer, assume it's a roman numeral. Convert it. + if (!Number(startingNumber)) { + startingNumber = ConvertRomanToArabic(startingNumber); + } + break; + case gUpperLetters: + case gLowerLetters: + // Get the number equivalent of the letters + if (!Number(startingNumber)) { + startingNumber = ConvertLettersToArabic(startingNumber); + } + break; + } + return startingNumber; +} + +function ConvertRomanToArabic(num) { + num = num.toUpperCase(); + if (num && !/[^MDCLXVI]/i.test(num)) { + var Arabic = 0; + var last_digit = 1000; + for (var i = 0; i < num.length; i++) { + var digit = gRomanDigits[num.charAt(i)]; + if (last_digit < digit) { + Arabic -= 2 * last_digit; + } + + last_digit = digit; + Arabic += last_digit; + } + return Arabic; + } + + return ""; +} + +function ConvertArabicToRoman(num) { + if (/^\d{1,4}$/.test(num)) { + var digits = ("000" + num).substr(-4); + return ( + gThousandsArray[digits.charAt(0)] + + gHundredsArray[digits.charAt(1)] + + gTensArray[digits.charAt(2)] + + gOnesArray[digits.charAt(3)] + ); + } + return ""; +} + +function ConvertLettersToArabic(letters) { + letters = letters.toUpperCase(); + if (!letters || /[^A-Z]/.test(letters)) { + return ""; + } + + var num = 0; + for (var i = 0; i < letters.length; i++) { + num = num * 26 + letters.charCodeAt(i) - A + 1; + } + return num; +} + +function ConvertArabicToLetters(num) { + var letters = ""; + while (num) { + num--; + letters = String.fromCharCode(A + (num % 26)) + letters; + num = Math.floor(num / 26); + } + return letters; +} + +function onAccept(event) { + if (ValidateData()) { + // Coalesce into one undo transaction + var editor = GetCurrentEditor(); + + editor.beginTransaction(); + + var changeEntireList = + gDialog.RadioGroup.selectedItem == gDialog.ChangeAllRadio; + + // Remember which radio button was selected + if (gListElement) { + gDialog.RadioGroup.setAttribute("index", changeEntireList ? "0" : "1"); + } + + var changeList; + if (gListElement && gDialog.ChangeAllRadio.selected) { + changeList = true; + } else { + changeList = + gMixedListSelection || + gListType != gOriginalListType || + gBulletStyleType != gOriginalStyleType; + } + if (changeList) { + try { + if (gListType) { + editor.makeOrChangeList( + gListType, + changeEntireList, + gBulletStyleType != gOriginalStyleType ? gBulletStyleType : null + ); + + // Get the new list created: + gListElement = editor.getElementOrParentByTagName(gListType, null); + + editor.cloneAttributes(gListElement, globalElement); + } else { + // Remove all existing lists + if (gListElement && changeEntireList) { + editor.selectElement(gListElement); + } + + editor.removeList("ol"); + editor.removeList("ul"); + editor.removeList("dl"); + } + } catch (e) {} + } + + editor.endTransaction(); + + SaveWindowLocation(); + + return; + } + event.preventDefault(); +} diff --git a/comm/mail/components/compose/content/dialogs/EdListProps.xhtml b/comm/mail/components/compose/content/dialogs/EdListProps.xhtml new file mode 100644 index 0000000000..b8d7c40cb2 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdListProps.xhtml @@ -0,0 +1,101 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % edListProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorListProperties.dtd"> +%edListProperties; +<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd"> +%edDialogOverlay; ]> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup()" +> + <dialog> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdListProps.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <html:fieldset> + <html:legend>&ListType.label;</html:legend> + <menulist id="ListType" oncommand="SelectListType()"> + <menupopup> + <menuitem label="&none.value;" /> + <menuitem value="ul" label="&bulletList.value;" /> + <menuitem value="ol" label="&numberList.value;" /> + <menuitem value="dl" label="&definitionList.value;" /> + </menupopup> + </menulist> + </html:fieldset> + <spacer class="spacer" /> + + <!-- message text and list items are set in JS + text value should be identical to string with id=BulletStyle in editor.properties + --> + <html:fieldset> + <html:legend id="BulletStyleLabel">&bulletStyle.label;</html:legend> + <menulist id="BulletStyle" oncommand="SelectBulletStyle()"> + <menupopup /> + </menulist> + <spacer class="spacer" /> + <hbox align="center"> + <label + id="StartingNumberLabel" + control="StartingNumber" + value="&startingNumber.label;" + accesskey="&startingNumber.accessKey;" + /> + <html:input + id="StartingNumber" + type="number" + class="narrow input-inline" + aria-labelledby="StartingNumberLabel" + /> + <spacer /> + </hbox> + </html:fieldset> + <radiogroup id="RadioGroup" index="0" persist="index"> + <radio + id="ChangeAll" + label="&changeEntireListRadio.label;" + accesskey="&changeEntireListRadio.accessKey;" + /> + <radio + id="ChangeSelected" + label="&changeSelectedRadio.label;" + accesskey="&changeSelectedRadio.accessKey;" + /> + </radiogroup> + <vbox id="AdvancedEdit"> + <hbox flex="1" style="margin-top: 0.2em" align="center"> + <!-- This will right-align the button --> + <spacer flex="1" /> + <button + id="AdvancedEditButton1" + oncommand="onAdvancedEdit()" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + <separator id="advancedSeparator" class="groove" /> + </vbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js new file mode 100644 index 0000000000..c943cc2833 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.js @@ -0,0 +1,159 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var gInsertNew = true; +var gAnchorElement = null; +var gOriginalName = ""; +const kTagName = "anchor"; + +// dialog initialization code + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + var editor = GetCurrentEditor(); + if (!editor) { + window.close(); + return; + } + + gDialog.OkButton = document.querySelector("dialog").getButton("accept"); + gDialog.NameInput = document.getElementById("nameInput"); + + // Get a single selected element of the desired type + gAnchorElement = editor.getSelectedElement(kTagName); + + if (gAnchorElement) { + // We found an element and don't need to insert one + gInsertNew = false; + + // Make a copy to use for AdvancedEdit + globalElement = gAnchorElement.cloneNode(false); + gOriginalName = ConvertToCDATAString(gAnchorElement.name); + } else { + gInsertNew = true; + // We don't have an element selected, + // so create one with default attributes + gAnchorElement = editor.createElementWithDefaults(kTagName); + if (gAnchorElement) { + // Use the current selection as suggested name + var name = GetSelectionAsText(); + // Get 40 characters of the selected text and don't add "...", + // replace whitespace with "_" and strip non-word characters + name = ConvertToCDATAString(TruncateStringAtWordEnd(name, 40, false)); + // Be sure the name is unique to the document + if (AnchorNameExists(name)) { + name += "_"; + } + + // Make a copy to use for AdvancedEdit + globalElement = gAnchorElement.cloneNode(false); + globalElement.setAttribute("name", name); + } + } + if (!gAnchorElement) { + dump("Failed to get selected element or create a new one!\n"); + window.close(); + return; + } + + InitDialog(); + + DoEnabling(); + SetTextboxFocus(gDialog.NameInput); + SetWindowLocation(); +} + +function InitDialog() { + gDialog.NameInput.value = globalElement.getAttribute("name"); +} + +function ChangeName() { + if (gDialog.NameInput.value.length > 0) { + // Replace spaces with "_" and strip other non-URL characters + // Note: we could use ConvertAndEscape, but then we'd + // have to UnEscapeAndConvert beforehand - too messy! + gDialog.NameInput.value = ConvertToCDATAString(gDialog.NameInput.value); + } + DoEnabling(); +} + +function DoEnabling() { + var enable = gDialog.NameInput.value.length > 0; + SetElementEnabled(gDialog.OkButton, enable); + SetElementEnabledById("AdvancedEditButton1", enable); +} + +function AnchorNameExists(name) { + var anchorList; + try { + anchorList = GetCurrentEditor().document.anchors; + } catch (e) {} + + if (anchorList) { + for (var i = 0; i < anchorList.length; i++) { + if (anchorList[i].name == name) { + return true; + } + } + } + return false; +} + +// Get and validate data from widgets. +// Set attributes on globalElement so they can be accessed by AdvancedEdit() +function ValidateData() { + var name = TrimString(gDialog.NameInput.value); + if (!name) { + ShowInputErrorMessage(GetString("MissingAnchorNameError")); + SetTextboxFocus(gDialog.NameInput); + return false; + } + // Replace spaces with "_" and strip other characters + // Note: we could use ConvertAndEscape, but then we'd + // have to UnConverAndEscape beforehand - too messy! + name = ConvertToCDATAString(name); + + if (gOriginalName != name && AnchorNameExists(name)) { + ShowInputErrorMessage( + GetString("DuplicateAnchorNameError").replace(/%name%/, name) + ); + SetTextboxFocus(gDialog.NameInput); + return false; + } + globalElement.name = name; + + return true; +} + +function onAccept(event) { + if (ValidateData()) { + if (gOriginalName != globalElement.name) { + var editor = GetCurrentEditor(); + editor.beginTransaction(); + + try { + // "false" = don't delete selected text when inserting + if (gInsertNew) { + // We must insert element before copying CSS style attribute, + // but we must set the name else it won't insert at all + gAnchorElement.name = globalElement.name; + editor.insertElementAtSelection(gAnchorElement, false); + } + + // Copy attributes to element we are changing or inserting + editor.cloneAttributes(gAnchorElement, globalElement); + } catch (e) {} + + editor.endTransaction(); + } + SaveWindowLocation(); + return; + } + event.preventDefault(); +} diff --git a/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml new file mode 100644 index 0000000000..d26f4d73b4 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdNamedAnchorProps.xhtml @@ -0,0 +1,67 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % edNamedAnchorProperties SYSTEM "chrome://messenger/locale/messengercompose/EdNamedAnchorProperties.dtd"> +%edNamedAnchorProperties; +<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd"> +%edDialogOverlay; ]> + +<window + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup()" +> + <dialog> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdNamedAnchorProps.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <label + id="nameLabel" + control="nameInput" + value="&anchorNameEditField.label;" + accesskey="&anchorNameEditField.accessKey;" + /> + <html:input + id="nameInput" + type="text" + class="MinWidth20em input-inline" + oninput="ChangeName()" + title="&nameInput.tooltip;" + aria-labelledby="nameLabel" + /> + <spacer class="spacer" /> + <vbox id="AdvancedEdit"> + <hbox flex="1" style="margin-top: 0.2em" align="center"> + <!-- This will right-align the button --> + <spacer flex="1" /> + <button + id="AdvancedEditButton1" + oncommand="onAdvancedEdit()" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + <separator id="advancedSeparator" class="groove" /> + </vbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdReplace.js b/comm/mail/components/compose/content/dialogs/EdReplace.js new file mode 100644 index 0000000000..c937702416 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdReplace.js @@ -0,0 +1,380 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var gReplaceDialog; // Quick access to document/form elements. +var gFindInst; // nsIWebBrowserFind that we're going to use +var gFindService; // Global service which remembers find params +var gEditor; // the editor we're using + +document.addEventListener("dialogaccept", event => { + onFindNext(); + event.preventDefault(); +}); + +function initDialogObject() { + // Create gReplaceDialog object and initialize. + gReplaceDialog = {}; + gReplaceDialog.findInput = document.getElementById("dialog.findInput"); + gReplaceDialog.replaceInput = document.getElementById("dialog.replaceInput"); + gReplaceDialog.caseSensitive = document.getElementById( + "dialog.caseSensitive" + ); + gReplaceDialog.wrap = document.getElementById("dialog.wrap"); + gReplaceDialog.searchBackwards = document.getElementById( + "dialog.searchBackwards" + ); + gReplaceDialog.findNext = document.getElementById("findNext"); + gReplaceDialog.replace = document.getElementById("replace"); + gReplaceDialog.replaceAndFind = document.getElementById("replaceAndFind"); + gReplaceDialog.replaceAll = document.getElementById("replaceAll"); +} + +function loadDialog() { + // Set initial dialog field contents. + // Set initial dialog field contents. Use the gFindInst attributes first, + // this is necessary for window.find() + gReplaceDialog.findInput.value = gFindInst.searchString + ? gFindInst.searchString + : gFindService.searchString; + gReplaceDialog.replaceInput.value = gFindService.replaceString; + gReplaceDialog.caseSensitive.checked = gFindInst.matchCase + ? gFindInst.matchCase + : gFindService.matchCase; + gReplaceDialog.wrap.checked = gFindInst.wrapFind + ? gFindInst.wrapFind + : gFindService.wrapFind; + gReplaceDialog.searchBackwards.checked = gFindInst.findBackwards + ? gFindInst.findBackwards + : gFindService.findBackwards; + + doEnabling(); +} + +function onLoad() { + // Get the xul <editor> element: + var editorElement = window.arguments[0]; + + // If we don't get the editor, then we won't allow replacing. + gEditor = editorElement.getEditor(editorElement.contentWindow); + if (!gEditor) { + window.close(); + return; + } + + // Get the nsIWebBrowserFind service: + gFindInst = editorElement.webBrowserFind; + + try { + // get the find service, which stores global find state + gFindService = Cc["@mozilla.org/find/find_service;1"].getService( + Ci.nsIFindService + ); + } catch (e) { + dump("No find service!\n"); + gFindService = 0; + } + + // Init gReplaceDialog. + initDialogObject(); + + // Change "OK" to "Find". + // dialog.find.label = document.getElementById("fBLT").getAttribute("label"); + + // Fill dialog. + loadDialog(); + + if (gReplaceDialog.findInput.value) { + gReplaceDialog.findInput.select(); + } else { + gReplaceDialog.findInput.focus(); + } +} + +function saveFindData() { + // Set data attributes per user input. + if (gFindService) { + gFindService.searchString = gReplaceDialog.findInput.value; + gFindService.matchCase = gReplaceDialog.caseSensitive.checked; + gFindService.wrapFind = gReplaceDialog.wrap.checked; + gFindService.findBackwards = gReplaceDialog.searchBackwards.checked; + } +} + +function setUpFindInst() { + gFindInst.searchString = gReplaceDialog.findInput.value; + gFindInst.matchCase = gReplaceDialog.caseSensitive.checked; + gFindInst.wrapFind = gReplaceDialog.wrap.checked; + gFindInst.findBackwards = gReplaceDialog.searchBackwards.checked; +} + +function onFindNext() { + // Transfer dialog contents to the find service. + saveFindData(); + // set up the find instance + setUpFindInst(); + + // Search. + var result = gFindInst.findNext(); + + if (!result) { + var bundle = document.getElementById("findBundle"); + Services.prompt.alert( + window, + GetString("Alert"), + bundle.getString("notFoundWarning") + ); + SetTextboxFocus(gReplaceDialog.findInput); + gReplaceDialog.findInput.select(); + gReplaceDialog.findInput.focus(); + return false; + } + return true; +} + +function onReplace() { + if (!gEditor) { + return false; + } + + // Does the current selection match the find string? + var selection = gEditor.selection; + + var selStr = selection.toString(); + var specStr = gReplaceDialog.findInput.value; + if (!gReplaceDialog.caseSensitive.checked) { + selStr = selStr.toLowerCase(); + specStr = specStr.toLowerCase(); + } + // Unfortunately, because of whitespace we can't just check + // whether (selStr == specStr), but have to loop ourselves. + // N chars of whitespace in specStr can match any M >= N in selStr. + var matches = true; + var specLen = specStr.length; + var selLen = selStr.length; + if (selLen < specLen) { + matches = false; + } else { + var specArray = specStr.match(/\S+|\s+/g); + var selArray = selStr.match(/\S+|\s+/g); + if (specArray.length != selArray.length) { + matches = false; + } else { + for (var i = 0; i < selArray.length; i++) { + if (selArray[i] != specArray[i]) { + if (/\S/.test(selArray[i][0]) || /\S/.test(specArray[i][0])) { + // not a space chunk -- match fails + matches = false; + break; + } else if (selArray[i].length < specArray[i].length) { + // if it's a space chunk then we only care that sel be + // at least as long as spec + matches = false; + break; + } + } + } + } + } + + // If the current selection doesn't match the pattern, + // then we want to find the next match, but not do the replace. + // That's what most other apps seem to do. + // So here, just return. + if (!matches) { + return false; + } + + // Transfer dialog contents to the find service. + saveFindData(); + + // For reverse finds, need to remember the caret position + // before current selection + var newRange; + if (gReplaceDialog.searchBackwards.checked && selection.rangeCount > 0) { + newRange = selection.getRangeAt(0).cloneRange(); + newRange.collapse(true); + } + + // nsPlaintextEditor::InsertText fails if the string is empty, + // so make that a special case: + var replStr = gReplaceDialog.replaceInput.value; + if (replStr == "") { + gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip); + } else { + gEditor.insertText(replStr); + } + + // For reverse finds, need to move caret just before the replaced text + if (gReplaceDialog.searchBackwards.checked && newRange) { + gEditor.selection.removeAllRanges(); + gEditor.selection.addRange(newRange); + } + + return true; +} + +function onReplaceAll() { + if (!gEditor) { + return; + } + + var findStr = gReplaceDialog.findInput.value; + var repStr = gReplaceDialog.replaceInput.value; + + // Transfer dialog contents to the find service. + saveFindData(); + + var finder = Cc["@mozilla.org/embedcomp/rangefind;1"] + .createInstance() + .QueryInterface(Ci.nsIFind); + + finder.caseSensitive = gReplaceDialog.caseSensitive.checked; + finder.findBackwards = gReplaceDialog.searchBackwards.checked; + + // We want the whole operation to be undoable in one swell foop, + // so start a transaction: + gEditor.beginTransaction(); + + // and to make sure we close the transaction, guard against exceptions: + try { + // Make a range containing the current selection, + // so we don't go past it when we wrap. + var selection = gEditor.selection; + var selecRange; + if (selection.rangeCount > 0) { + selecRange = selection.getRangeAt(0); + } + var origRange = selecRange.cloneRange(); + + // We'll need a range for the whole document: + var wholeDocRange = gEditor.document.createRange(); + var rootNode = gEditor.rootElement; + wholeDocRange.selectNodeContents(rootNode); + + // And start and end points: + var endPt = gEditor.document.createRange(); + + if (gReplaceDialog.searchBackwards.checked) { + endPt.setStart(wholeDocRange.startContainer, wholeDocRange.startOffset); + endPt.setEnd(wholeDocRange.startContainer, wholeDocRange.startOffset); + } else { + endPt.setStart(wholeDocRange.endContainer, wholeDocRange.endOffset); + endPt.setEnd(wholeDocRange.endContainer, wholeDocRange.endOffset); + } + + // Find and replace from here to end (start) of document: + var foundRange; + var searchRange = wholeDocRange.cloneRange(); + while ( + (foundRange = finder.Find(findStr, searchRange, selecRange, endPt)) != + null + ) { + gEditor.selection.removeAllRanges(); + gEditor.selection.addRange(foundRange); + + // The editor will leave the caret at the end of the replaced text. + // For reverse finds, we need it at the beginning, + // so save the next position now. + if (gReplaceDialog.searchBackwards.checked) { + selecRange = foundRange.cloneRange(); + selecRange.setEnd(selecRange.startContainer, selecRange.startOffset); + } + + // nsPlaintextEditor::InsertText fails if the string is empty, + // so make that a special case: + if (repStr == "") { + gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip); + } else { + gEditor.insertText(repStr); + } + + // If we're going forward, we didn't save selecRange before, so do it now: + if (!gReplaceDialog.searchBackwards.checked) { + selection = gEditor.selection; + if (selection.rangeCount <= 0) { + gEditor.endTransaction(); + return; + } + selecRange = selection.getRangeAt(0).cloneRange(); + } + } + + // If no wrapping, then we're done + if (!gReplaceDialog.wrap.checked) { + gEditor.endTransaction(); + return; + } + + // If wrapping, find from start/end of document back to start point. + if (gReplaceDialog.searchBackwards.checked) { + // Collapse origRange to end + origRange.setStart(origRange.endContainer, origRange.endOffset); + // Set current position to document end + selecRange.setEnd(wholeDocRange.endContainer, wholeDocRange.endOffset); + selecRange.setStart(wholeDocRange.endContainer, wholeDocRange.endOffset); + } else { + // Collapse origRange to start + origRange.setEnd(origRange.startContainer, origRange.startOffset); + // Set current position to document start + selecRange.setStart( + wholeDocRange.startContainer, + wholeDocRange.startOffset + ); + selecRange.setEnd( + wholeDocRange.startContainer, + wholeDocRange.startOffset + ); + } + + while ( + (foundRange = finder.Find( + findStr, + wholeDocRange, + selecRange, + origRange + )) != null + ) { + gEditor.selection.removeAllRanges(); + gEditor.selection.addRange(foundRange); + + // Save insert point for backward case + if (gReplaceDialog.searchBackwards.checked) { + selecRange = foundRange.cloneRange(); + selecRange.setEnd(selecRange.startContainer, selecRange.startOffset); + } + + // nsPlaintextEditor::InsertText fails if the string is empty, + // so make that a special case: + if (repStr == "") { + gEditor.deleteSelection(gEditor.eNone, gEditor.eStrip); + } else { + gEditor.insertText(repStr); + } + + // Get insert point for forward case + if (!gReplaceDialog.searchBackwards.checked) { + selection = gEditor.selection; + if (selection.rangeCount <= 0) { + gEditor.endTransaction(); + return; + } + selecRange = selection.getRangeAt(0); + } + } + } catch (e) {} + + gEditor.endTransaction(); +} + +function doEnabling() { + var findStr = gReplaceDialog.findInput.value; + gReplaceDialog.enabled = findStr; + gReplaceDialog.findNext.disabled = !findStr; + gReplaceDialog.replace.disabled = !findStr; + gReplaceDialog.replaceAndFind.disabled = !findStr; + gReplaceDialog.replaceAll.disabled = !findStr; +} diff --git a/comm/mail/components/compose/content/dialogs/EdReplace.xhtml b/comm/mail/components/compose/content/dialogs/EdReplace.xhtml new file mode 100644 index 0000000000..62ce5a67e2 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdReplace.xhtml @@ -0,0 +1,126 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorReplace.dtd"> + +<window + id="replaceDlg" + title="&replaceDialog.title;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + persist="screenX screenY" + lightweightthemes="true" + onload="onLoad()" +> + <dialog buttons="cancel"> + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdReplace.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <stringbundle + id="findBundle" + src="chrome://global/locale/finddialog.properties" + /> + + <hbox> + <vbox> + <spacer class="spacer" /> + <html:div class="grid-two-column"> + <html:div class="flex-items-center"> + <label + value="&findField.label;" + accesskey="&findField.accesskey;" + control="dialog.findInput" + /> + </html:div> + <html:div> + <html:input + id="dialog.findInput" + class="input-inline" + oninput="doEnabling();" + /> + </html:div> + <html:div class="flex-items-center"> + <label + value="&replaceField.label;" + accesskey="&replaceField.accesskey;" + control="dialog.replaceInput" + /> + </html:div> + <html:div> + <html:input + id="dialog.replaceInput" + class="input-inline" + oninput="doEnabling();" + /> + </html:div> + <html:div class="grid-item-col2"> + <vbox align="start"> + <checkbox + id="dialog.caseSensitive" + label="&caseSensitiveCheckbox.label;" + accesskey="&caseSensitiveCheckbox.accesskey;" + /> + <checkbox + id="dialog.wrap" + label="&wrapCheckbox.label;" + accesskey="&wrapCheckbox.accesskey;" + /> + <checkbox + id="dialog.searchBackwards" + label="&backwardsCheckbox.label;" + accesskey="&backwardsCheckbox.accesskey;" + /> + </vbox> + </html:div> + </html:div> + </vbox> + <spacer class="spacer" /> + <vbox> + <button + id="findNext" + label="&findNextButton.label;" + accesskey="&findNextButton.accesskey;" + oncommand="onFindNext();" + default="true" + /> + <button + id="replace" + label="&replaceButton.label;" + accesskey="&replaceButton.accesskey;" + oncommand="onReplace();" + /> + <button + id="replaceAndFind" + label="&replaceAndFindButton.label;" + accesskey="&replaceAndFindButton.accesskey;" + oncommand="onReplace(); onFindNext();" + /> + <button + id="replaceAll" + label="&replaceAllButton.label;" + accesskey="&replaceAllButton.accesskey;" + oncommand="onReplaceAll();" + /> + <button + dlgtype="cancel" + label="&closeButton.label;" + accesskey="&closeButton.accesskey;" + /> + </vbox> + </hbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdSpellCheck.js b/comm/mail/components/compose/content/dialogs/EdSpellCheck.js new file mode 100644 index 0000000000..5b54205bc3 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdSpellCheck.js @@ -0,0 +1,496 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../../../../base/content/utilityOverlay.js */ +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +var { InlineSpellChecker } = ChromeUtils.importESModule( + "resource://gre/modules/InlineSpellChecker.sys.mjs" +); + +var gMisspelledWord; +var gSpellChecker = null; +var gAllowSelectWord = true; +var gPreviousReplaceWord = ""; +var gFirstTime = true; +var gDictCount = 0; + +document.addEventListener("dialogaccept", doDefault); +document.addEventListener("dialogcancel", CancelSpellCheck); + +function Startup() { + var editor = GetCurrentEditor(); + if (!editor) { + window.close(); + return; + } + + // Get the spellChecker shell + gSpellChecker = Cu.createSpellChecker(); + if (!gSpellChecker) { + dump("SpellChecker not found!!!\n"); + window.close(); + return; + } + + // Start the spell checker module. + try { + var skipBlockQuotes = window.arguments[1]; + var enableSelectionChecking = window.arguments[2]; + + gSpellChecker.setFilterType( + skipBlockQuotes + ? Ci.nsIEditorSpellCheck.FILTERTYPE_MAIL + : Ci.nsIEditorSpellCheck.FILTERTYPE_NORMAL + ); + gSpellChecker.InitSpellChecker( + editor, + enableSelectionChecking, + spellCheckStarted + ); + } catch (ex) { + dump("*** Exception error: InitSpellChecker\n"); + window.close(); + } +} + +function spellCheckStarted() { + gDialog.MisspelledWordLabel = document.getElementById("MisspelledWordLabel"); + gDialog.MisspelledWord = document.getElementById("MisspelledWord"); + gDialog.ReplaceButton = document.getElementById("Replace"); + gDialog.IgnoreButton = document.getElementById("Ignore"); + gDialog.StopButton = document.getElementById("Stop"); + gDialog.CloseButton = document.getElementById("Close"); + gDialog.ReplaceWordInput = document.getElementById("ReplaceWordInput"); + gDialog.SuggestedList = document.getElementById("SuggestedList"); + gDialog.LanguageMenulist = document.getElementById("LanguageMenulist"); + + // Fill in the language menulist and sync it up + // with the spellchecker's current language. + + var curLangs; + + try { + curLangs = new Set(gSpellChecker.getCurrentDictionaries()); + } catch (ex) { + curLangs = new Set(); + } + + InitLanguageMenu(curLangs); + + // Get the first misspelled word and setup all UI + NextWord(); + + // When startup param is true, setup different UI when spell checking + // just before sending mail message + if (window.arguments[0]) { + // If no misspelled words found, simply close dialog and send message + if (!gMisspelledWord) { + onClose(); + return; + } + + // Hide "Close" button and use "Send" instead + gDialog.CloseButton.hidden = true; + gDialog.CloseButton = document.getElementById("Send"); + gDialog.CloseButton.hidden = false; + } else { + // Normal spell checking - hide the "Stop" button + // (Note that this button is the "Cancel" button for + // Esc keybinding and related window close actions) + gDialog.StopButton.hidden = true; + } + + // Clear flag that determines message when + // no misspelled word is found + // (different message when used for the first time) + gFirstTime = false; + + window.sizeToContent(); +} + +/** + * Populate the dictionary language selector menu. + * + * @param {Set<string>} activeDictionaries - Currently active dictionaries. + */ +function InitLanguageMenu(activeDictionaries) { + // Get the list of dictionaries from + // the spellchecker. + + var dictList; + try { + dictList = gSpellChecker.GetDictionaryList(); + } catch (ex) { + dump("Failed to get DictionaryList!\n"); + return; + } + + // If we're not just starting up and dictionary count + // hasn't changed then no need to update the menu. + if (gDictCount == dictList.length) { + return; + } + + // Store current dictionary count. + gDictCount = dictList.length; + + var inlineSpellChecker = new InlineSpellChecker(); + var sortedList = inlineSpellChecker.sortDictionaryList(dictList); + + // Remove any languages from the list. + let list = document.getElementById("dictionary-list"); + let template = document.getElementById("language-item"); + + list.replaceChildren( + ...sortedList.map(({ displayName, localeCode }) => { + let item = template.content.cloneNode(true); + item.querySelector(".checkbox-label").textContent = displayName; + let input = item.querySelector("input"); + input.addEventListener("input", () => { + SelectLanguage(localeCode); + }); + input.checked = activeDictionaries.has(localeCode); + return item; + }) + ); +} + +function DoEnabling() { + if (!gMisspelledWord) { + // No more misspelled words + gDialog.MisspelledWord.setAttribute( + "value", + GetString(gFirstTime ? "NoMisspelledWord" : "CheckSpellingDone") + ); + + gDialog.ReplaceButton.removeAttribute("default"); + gDialog.IgnoreButton.removeAttribute("default"); + + gDialog.CloseButton.setAttribute("default", "true"); + // Shouldn't have to do this if "default" is true? + gDialog.CloseButton.focus(); + + SetElementEnabledById("MisspelledWordLabel", false); + SetElementEnabledById("ReplaceWordLabel", false); + SetElementEnabledById("ReplaceWordInput", false); + SetElementEnabledById("CheckWord", false); + SetElementEnabledById("SuggestedListLabel", false); + SetElementEnabledById("SuggestedList", false); + SetElementEnabledById("Ignore", false); + SetElementEnabledById("IgnoreAll", false); + SetElementEnabledById("Replace", false); + SetElementEnabledById("ReplaceAll", false); + SetElementEnabledById("AddToDictionary", false); + } else { + SetElementEnabledById("MisspelledWordLabel", true); + SetElementEnabledById("ReplaceWordLabel", true); + SetElementEnabledById("ReplaceWordInput", true); + SetElementEnabledById("CheckWord", true); + SetElementEnabledById("SuggestedListLabel", true); + SetElementEnabledById("SuggestedList", true); + SetElementEnabledById("Ignore", true); + SetElementEnabledById("IgnoreAll", true); + SetElementEnabledById("AddToDictionary", true); + + gDialog.CloseButton.removeAttribute("default"); + SetReplaceEnable(); + } +} + +function NextWord() { + gMisspelledWord = gSpellChecker.GetNextMisspelledWord(); + SetWidgetsForMisspelledWord(); +} + +function SetWidgetsForMisspelledWord() { + gDialog.MisspelledWord.setAttribute("value", gMisspelledWord); + + // Initial replace word is misspelled word + gDialog.ReplaceWordInput.value = gMisspelledWord; + gPreviousReplaceWord = gMisspelledWord; + + // This sets gDialog.ReplaceWordInput to first suggested word in list + FillSuggestedList(gMisspelledWord); + + DoEnabling(); + + if (gMisspelledWord) { + SetTextboxFocus(gDialog.ReplaceWordInput); + } +} + +function CheckWord() { + var word = gDialog.ReplaceWordInput.value; + if (word) { + if (gSpellChecker.CheckCurrentWord(word)) { + FillSuggestedList(word); + SetReplaceEnable(); + } else { + ClearListbox(gDialog.SuggestedList); + var item = gDialog.SuggestedList.appendItem( + GetString("CorrectSpelling"), + "" + ); + if (item) { + item.setAttribute("disabled", "true"); + } + // Suppress being able to select the message text + gAllowSelectWord = false; + } + } +} + +function SelectSuggestedWord() { + if (gAllowSelectWord) { + if (gDialog.SuggestedList.selectedItem) { + var selValue = gDialog.SuggestedList.selectedItem.label; + gDialog.ReplaceWordInput.value = selValue; + gPreviousReplaceWord = selValue; + } else { + gDialog.ReplaceWordInput.value = gPreviousReplaceWord; + } + SetReplaceEnable(); + } +} + +function ChangeReplaceWord() { + // Calling this triggers SelectSuggestedWord(), + // so temporarily suppress the effect of that + var saveAllow = gAllowSelectWord; + gAllowSelectWord = false; + + // Select matching word in list + var newSelectedItem; + var replaceWord = TrimString(gDialog.ReplaceWordInput.value); + if (replaceWord) { + for (var i = 0; i < gDialog.SuggestedList.getRowCount(); i++) { + var item = gDialog.SuggestedList.getItemAtIndex(i); + if (item.label == replaceWord) { + newSelectedItem = item; + break; + } + } + } + gDialog.SuggestedList.selectedItem = newSelectedItem; + + gAllowSelectWord = saveAllow; + + // Remember the new word + gPreviousReplaceWord = gDialog.ReplaceWordInput.value; + + SetReplaceEnable(); +} + +function Ignore() { + NextWord(); +} + +function IgnoreAll() { + if (gMisspelledWord) { + gSpellChecker.IgnoreWordAllOccurrences(gMisspelledWord); + } + NextWord(); +} + +function Replace(newWord) { + if (!newWord) { + return; + } + + if (gMisspelledWord && gMisspelledWord != newWord) { + var editor = GetCurrentEditor(); + editor.beginTransaction(); + try { + gSpellChecker.ReplaceWord(gMisspelledWord, newWord, false); + } catch (e) {} + editor.endTransaction(); + } + NextWord(); +} + +function ReplaceAll() { + var newWord = gDialog.ReplaceWordInput.value; + if (gMisspelledWord && gMisspelledWord != newWord) { + var editor = GetCurrentEditor(); + editor.beginTransaction(); + try { + gSpellChecker.ReplaceWord(gMisspelledWord, newWord, true); + } catch (e) {} + editor.endTransaction(); + } + NextWord(); +} + +function AddToDictionary() { + if (gMisspelledWord) { + gSpellChecker.AddWordToDictionary(gMisspelledWord); + } + NextWord(); +} + +function EditDictionary() { + window.openDialog( + "chrome://messenger/content/messengercompose/EdDictionary.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "", + gMisspelledWord + ); +} + +/** + * Change the selection state of the given dictionary language. + * + * @param {string} language + */ +function SelectLanguage(language) { + let activeDictionaries = new Set(gSpellChecker.getCurrentDictionaries()); + if (activeDictionaries.has(language)) { + activeDictionaries.delete(language); + } else { + activeDictionaries.add(language); + } + let activeDictionariesArray = Array.from(activeDictionaries); + gSpellChecker.setCurrentDictionaries(activeDictionariesArray); + // For compose windows we need to set the "lang" attribute so the + // core editor uses the correct dictionary for the inline spell check. + if (window.arguments[1]) { + if ("ComposeChangeLanguage" in window.opener) { + // We came here from a compose window. + window.opener.ComposeChangeLanguage(activeDictionariesArray); + } else if (activeDictionaries.size === 1) { + window.opener.document.documentElement.setAttribute( + "lang", + activeDictionariesArray[0] + ); + } else { + window.opener.document.documentElement.setAttribute("lang", ""); + } + } +} + +function Recheck() { + var recheckLanguages; + + function finishRecheck() { + gSpellChecker.setCurrentDictionaries(recheckLanguages); + gMisspelledWord = gSpellChecker.GetNextMisspelledWord(); + SetWidgetsForMisspelledWord(); + } + + // TODO: Should we bother to add a "Recheck" method to interface? + try { + recheckLanguages = gSpellChecker.getCurrentDictionaries(); + gSpellChecker.UninitSpellChecker(); + // Clear the ignore all list. + Cc["@mozilla.org/spellchecker/personaldictionary;1"] + .getService(Ci.mozIPersonalDictionary) + .endSession(); + gSpellChecker.InitSpellChecker(GetCurrentEditor(), false, finishRecheck); + } catch (ex) { + console.error(ex); + } +} + +function FillSuggestedList(misspelledWord) { + var list = gDialog.SuggestedList; + + // Clear the current contents of the list + gAllowSelectWord = false; + ClearListbox(list); + var item; + + if (misspelledWord.length > 0) { + // Get suggested words until an empty string is returned + var count = 0; + do { + var word = gSpellChecker.GetSuggestedWord(); + if (word.length > 0) { + list.appendItem(word, ""); + count++; + } + } while (word.length > 0); + + if (count == 0) { + // No suggestions - show a message but don't let user select it + item = list.appendItem(GetString("NoSuggestedWords")); + if (item) { + item.setAttribute("disabled", "true"); + } + gAllowSelectWord = false; + } else { + gAllowSelectWord = true; + // Initialize with first suggested list by selecting it + gDialog.SuggestedList.selectedIndex = 0; + } + } else { + item = list.appendItem("", ""); + if (item) { + item.setAttribute("disabled", "true"); + } + } +} + +function SetReplaceEnable() { + // Enable "Change..." buttons only if new word is different than misspelled + var newWord = gDialog.ReplaceWordInput.value; + var enable = newWord.length > 0 && newWord != gMisspelledWord; + SetElementEnabledById("Replace", enable); + SetElementEnabledById("ReplaceAll", enable); + if (enable) { + gDialog.ReplaceButton.setAttribute("default", "true"); + gDialog.IgnoreButton.removeAttribute("default"); + } else { + gDialog.IgnoreButton.setAttribute("default", "true"); + gDialog.ReplaceButton.removeAttribute("default"); + } +} + +function doDefault(event) { + if (gDialog.ReplaceButton.getAttribute("default") == "true") { + Replace(gDialog.ReplaceWordInput.value); + } else if (gDialog.IgnoreButton.getAttribute("default") == "true") { + Ignore(); + } else if (gDialog.CloseButton.getAttribute("default") == "true") { + onClose(); + } + + event.preventDefault(); +} + +function ExitSpellChecker() { + if (gSpellChecker) { + try { + gSpellChecker.UninitSpellChecker(); + // now check the document over again with the new dictionary + // if we have an inline spellchecker + if ( + "InlineSpellCheckerUI" in window.opener && + window.opener.InlineSpellCheckerUI.enabled + ) { + window.opener.InlineSpellCheckerUI.mInlineSpellChecker.spellCheckRange( + null + ); + } + } finally { + gSpellChecker = null; + } + } +} + +function CancelSpellCheck() { + ExitSpellChecker(); + + // Signal to calling window that we canceled + window.opener.cancelSendMessage = true; +} + +function onClose() { + ExitSpellChecker(); + + window.opener.cancelSendMessage = false; + window.close(); +} diff --git a/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml b/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml new file mode 100644 index 0000000000..fcff0e1703 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdSpellCheck.xhtml @@ -0,0 +1,209 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window SYSTEM "chrome://messenger/locale/messengercompose/EditorSpellCheck.dtd"> + +<!-- dialog containing a control requiring initial setup --> +<window + id="spellCheckDlg" + title="&windowTitle.label;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + persist="screenX screenY" + lightweightthemes="true" + onload="Startup()" +> + <dialog buttons="cancel"> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://communicator/content/utilityOverlay.js" /> + <script src="chrome://messenger/content/messengercompose/EdSpellCheck.js" /> + <script src="chrome://global/content/contentAreaUtils.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <stringbundle + id="languageBundle" + src="chrome://global/locale/languageNames.properties" + /> + <stringbundle + id="regionBundle" + src="chrome://global/locale/regionNames.properties" + /> + + <html:div class="grid-three-column-auto-x-auto"> + <html:div class="flex-items-center"> + <label id="MisspelledWordLabel" value="&misspelledWord.label;" /> + </html:div> + <html:div class="flex-items-center"> + <label id="MisspelledWord" class="bold" crop="end" /> + </html:div> + <html:div class="flex-items-center"> + <button + class="spell-check" + label="&recheckButton2.label;" + oncommand="Recheck();" + accesskey="&recheckButton2.accessKey;" + /> + </html:div> + <html:div class="flex-items-center"> + <label + id="ReplaceWordLabel" + value="&wordEditField.label;" + control="ReplaceWordInput" + accesskey="&wordEditField.accessKey;" + /> + </html:div> + <html:div> + <hbox flex="1" class="input-container"> + <html:input + id="ReplaceWordInput" + type="text" + class="input-inline" + onchange="ChangeReplaceWord()" + aria-labelledby="ReplaceWordLabel" + /> + </hbox> + </html:div> + <html:div class="flex-items-center"> + <button + id="CheckWord" + class="spell-check" + oncommand="CheckWord()" + label="&checkwordButton.label;" + accesskey="&checkwordButton.accessKey;" + /> + </html:div> + </html:div> + <label + id="SuggestedListLabel" + value="&suggestions.label;" + control="SuggestedList" + accesskey="&suggestions.accessKey;" + /> + <hbox flex="1" class="display-flex"> + <html:div class="grid-two-column-x-auto flex-1"> + <html:div class="display-flex"> + <richlistbox + id="SuggestedList" + class="display-flex flex-1" + onselect="SelectSuggestedWord()" + ondblclick="if (gAllowSelectWord) { Replace(event.target.value); }" + /> + </html:div> + <html:div> + <vbox> + <html:div class="grid-two-column-equalsize"> + <button + id="Replace" + class="spell-check" + label="&replaceButton.label;" + oncommand="Replace(gDialog.ReplaceWordInput.value);" + accesskey="&replaceButton.accessKey;" + /> + <button + id="Ignore" + class="spell-check" + oncommand="Ignore();" + label="&ignoreButton.label;" + accesskey="&ignoreButton.accessKey;" + /> + <button + id="ReplaceAll" + class="spell-check" + oncommand="ReplaceAll();" + label="&replaceAllButton.label;" + accesskey="&replaceAllButton.accessKey;" + /> + <button + id="IgnoreAll" + class="spell-check" + oncommand="IgnoreAll();" + label="&ignoreAllButton.label;" + accesskey="&ignoreAllButton.accessKey;" + /> + </html:div> + <separator /> + <label value="&userDictionary.label;" /> + <hbox align="start"> + <button + id="AddToDictionary" + class="spell-check" + oncommand="AddToDictionary()" + label="&addToUserDictionaryButton.label;" + accesskey="&addToUserDictionaryButton.accessKey;" + /> + <button + id="EditDictionary" + class="spell-check" + oncommand="EditDictionary()" + label="&editUserDictionaryButton.label;" + accesskey="&editUserDictionaryButton.accessKey;" + /> + </hbox> + </vbox> + </html:div> + <html:div class="grid-item-span-row"> + <label + value="&languagePopup.label;" + control="LanguageMenulist" + accesskey="&languagePopup.accessKey;" + /> + </html:div> + <html:div> + <html:ul id="dictionary-list"> </html:ul> + <html:template id="language-item" + ><html:li> + <html:label + ><html:input type="checkbox"></html:input> + <html:span class="checkbox-label"></html:span + ></html:label> </html:li + ></html:template> + <html:a onclick="openDictionaryList()" href="" + >&moreDictionaries.label;</html:a + > + </html:div> + <html:div> + <hbox class="display-flex"> + <button + id="Stop" + class="spell-check" + dlgtype="cancel" + label="&stopButton.label;" + oncommand="CancelSpellCheck();" + accesskey="&stopButton.accessKey;" + /> + <spacer class="flex-1" /> + <button + id="Close" + class="spell-check" + label="&closeButton.label;" + oncommand="onClose();" + accesskey="&closeButton.accessKey;" + /> + <button + id="Send" + class="spell-check" + label="&sendButton.label;" + oncommand="onClose();" + accesskey="&sendButton.accessKey;" + hidden="true" + /> + </hbox> + </html:div> + </html:div> + </hbox> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/dialogs/EdTableProps.js b/comm/mail/components/compose/content/dialogs/EdTableProps.js new file mode 100644 index 0000000000..fd4ab40f3a --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdTableProps.js @@ -0,0 +1,1426 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../editorUtilities.js */ +/* import-globals-from EdDialogCommon.js */ + +// Cancel() is in EdDialogCommon.js + +var gTableElement; +var gCellElement; +var gTableCaptionElement; +var globalCellElement; +var globalTableElement; +var gValidateTab; +const defHAlign = "left"; +const centerStr = "center"; // Index=1 +const rightStr = "right"; // 2 +const justifyStr = "justify"; // 3 +const charStr = "char"; // 4 +const defVAlign = "middle"; +const topStr = "top"; +const bottomStr = "bottom"; +const bgcolor = "bgcolor"; +var gTableColor; +var gCellColor; + +const cssBackgroundColorStr = "background-color"; + +var gRowCount = 1; +var gColCount = 1; +var gLastRowIndex; +var gLastColIndex; +var gNewRowCount; +var gNewColCount; +var gCurRowIndex; +var gCurColIndex; +var gCurColSpan; +var gSelectedCellsType = 1; +const SELECT_CELL = 1; +const SELECT_ROW = 2; +const SELECT_COLUMN = 3; +const RESET_SELECTION = 0; +var gCellData = { + value: null, + startRowIndex: 0, + startColIndex: 0, + rowSpan: 0, + colSpan: 0, + actualRowSpan: 0, + actualColSpan: 0, + isSelected: false, +}; +var gAdvancedEditUsed; +var gAlignWasChar = false; + +/* +From C++: + 0 TABLESELECTION_TABLE + 1 TABLESELECTION_CELL There are 1 or more cells selected + but complete rows or columns are not selected + 2 TABLESELECTION_ROW All cells are in 1 or more rows + and in each row, all cells selected + Note: This is the value if all rows (thus all cells) are selected + 3 TABLESELECTION_COLUMN All cells are in 1 or more columns +*/ + +var gSelectedCellCount = 0; +var gApplyUsed = false; +var gSelection; +var gCellDataChanged = false; +var gCanDelete = false; +var gUseCSS = true; +var gActiveEditor; + +// dialog initialization code + +document.addEventListener("dialogaccept", onAccept); +document.addEventListener("dialogextra1", Apply); +document.addEventListener("dialogcancel", onCancel); + +function Startup() { + gActiveEditor = GetCurrentTableEditor(); + if (!gActiveEditor) { + window.close(); + return; + } + + try { + gSelection = gActiveEditor.selection; + } catch (e) {} + if (!gSelection) { + return; + } + + // Get dialog widgets - Table Panel + gDialog.TableRowsInput = document.getElementById("TableRowsInput"); + gDialog.TableColumnsInput = document.getElementById("TableColumnsInput"); + gDialog.TableWidthInput = document.getElementById("TableWidthInput"); + gDialog.TableWidthUnits = document.getElementById("TableWidthUnits"); + gDialog.TableHeightInput = document.getElementById("TableHeightInput"); + gDialog.TableHeightUnits = document.getElementById("TableHeightUnits"); + try { + if ( + !Services.prefs.getBoolPref("editor.use_css") || + gActiveEditor.flags & 1 + ) { + gUseCSS = false; + var tableHeightLabel = document.getElementById("TableHeightLabel"); + tableHeightLabel.remove(); + gDialog.TableHeightInput.remove(); + gDialog.TableHeightUnits.remove(); + } + } catch (e) {} + gDialog.BorderWidthInput = document.getElementById("BorderWidthInput"); + gDialog.SpacingInput = document.getElementById("SpacingInput"); + gDialog.PaddingInput = document.getElementById("PaddingInput"); + gDialog.TableAlignList = document.getElementById("TableAlignList"); + gDialog.TableCaptionList = document.getElementById("TableCaptionList"); + gDialog.TableInheritColor = document.getElementById("TableInheritColor"); + gDialog.TabBox = document.getElementById("TabBox"); + + // Cell Panel + gDialog.SelectionList = document.getElementById("SelectionList"); + gDialog.PreviousButton = document.getElementById("PreviousButton"); + gDialog.NextButton = document.getElementById("NextButton"); + // Currently, we always apply changes and load new attributes when changing selection + // (Let's keep this for possible future use) + // gDialog.ApplyBeforeMove = document.getElementById("ApplyBeforeMove"); + // gDialog.KeepCurrentData = document.getElementById("KeepCurrentData"); + + gDialog.CellHeightInput = document.getElementById("CellHeightInput"); + gDialog.CellHeightUnits = document.getElementById("CellHeightUnits"); + gDialog.CellWidthInput = document.getElementById("CellWidthInput"); + gDialog.CellWidthUnits = document.getElementById("CellWidthUnits"); + gDialog.CellHAlignList = document.getElementById("CellHAlignList"); + gDialog.CellVAlignList = document.getElementById("CellVAlignList"); + gDialog.CellInheritColor = document.getElementById("CellInheritColor"); + gDialog.CellStyleList = document.getElementById("CellStyleList"); + gDialog.TextWrapList = document.getElementById("TextWrapList"); + + // In cell panel, user must tell us which attributes to apply via checkboxes, + // else we would apply values from one cell to ALL in selection + // and that's probably not what they expect! + gDialog.CellHeightCheckbox = document.getElementById("CellHeightCheckbox"); + gDialog.CellWidthCheckbox = document.getElementById("CellWidthCheckbox"); + gDialog.CellHAlignCheckbox = document.getElementById("CellHAlignCheckbox"); + gDialog.CellVAlignCheckbox = document.getElementById("CellVAlignCheckbox"); + gDialog.CellStyleCheckbox = document.getElementById("CellStyleCheckbox"); + gDialog.TextWrapCheckbox = document.getElementById("TextWrapCheckbox"); + gDialog.CellColorCheckbox = document.getElementById("CellColorCheckbox"); + gDialog.TableTab = document.getElementById("TableTab"); + gDialog.CellTab = document.getElementById("CellTab"); + gDialog.AdvancedEditCell = document.getElementById("AdvancedEditButton2"); + // Save "normal" tooltip message for Advanced Edit button + gDialog.AdvancedEditCellToolTipText = + gDialog.AdvancedEditCell.getAttribute("tooltiptext"); + + try { + gTableElement = gActiveEditor.getElementOrParentByTagName("table", null); + } catch (e) {} + if (!gTableElement) { + dump("Failed to get table element!\n"); + window.close(); + return; + } + globalTableElement = gTableElement.cloneNode(false); + + var tagNameObj = { value: "" }; + var countObj = { value: 0 }; + var tableOrCellElement; + try { + tableOrCellElement = gActiveEditor.getSelectedOrParentTableElement( + tagNameObj, + countObj + ); + } catch (e) {} + + if (tagNameObj.value == "td") { + // We are in a cell + gSelectedCellCount = countObj.value; + gCellElement = tableOrCellElement; + globalCellElement = gCellElement.cloneNode(false); + + // Tells us whether cell, row, or column is selected + try { + gSelectedCellsType = gActiveEditor.getSelectedCellsType(gTableElement); + } catch (e) {} + + // Ignore types except Cell, Row, and Column + if ( + gSelectedCellsType < SELECT_CELL || + gSelectedCellsType > SELECT_COLUMN + ) { + gSelectedCellsType = SELECT_CELL; + } + + // Be sure at least 1 cell is selected. + // (If the count is 0, then we were inside the cell.) + if (gSelectedCellCount == 0) { + DoCellSelection(); + } + + // Get location in the cell map + var rowIndexObj = { value: 0 }; + var colIndexObj = { value: 0 }; + try { + gActiveEditor.getCellIndexes(gCellElement, rowIndexObj, colIndexObj); + } catch (e) {} + gCurRowIndex = rowIndexObj.value; + gCurColIndex = colIndexObj.value; + + // We save the current colspan to quickly + // move selection from from cell to cell + if (GetCellData(gCurRowIndex, gCurColIndex)) { + gCurColSpan = gCellData.colSpan; + } + + // Starting TabPanel name is passed in + if (window.arguments[1] == "CellPanel") { + gDialog.TabBox.selectedTab = gDialog.CellTab; + } + } + + if (gDialog.TabBox.selectedTab == gDialog.TableTab) { + // We may call this with table selected, but no cell, + // so disable the Cell Properties tab + if (!gCellElement) { + // XXX: Disabling of tabs is currently broken, so for + // now we'll just remove the tab completely. + // gDialog.CellTab.disabled = true; + gDialog.CellTab.remove(); + } + } + + // Note: we must use gTableElement, not globalTableElement for these, + // thus we should not put this in InitDialog. + // Instead, monitor desired counts with separate globals + var rowCountObj = { value: 0 }; + var colCountObj = { value: 0 }; + try { + gActiveEditor.getTableSize(gTableElement, rowCountObj, colCountObj); + } catch (e) {} + + gRowCount = rowCountObj.value; + gLastRowIndex = gRowCount - 1; + gColCount = colCountObj.value; + gLastColIndex = gColCount - 1; + + // Set appropriate icons and enable state for the Previous/Next buttons + SetSelectionButtons(); + + // If only one cell in table, disable change-selection widgets + if (gRowCount == 1 && gColCount == 1) { + gDialog.SelectionList.setAttribute("disabled", "true"); + } + + // User can change these via textboxes + gNewRowCount = gRowCount; + gNewColCount = gColCount; + + // This flag is used to control whether set check state + // on "set attribute" checkboxes + // (Advanced Edit dialog use calls InitDialog when done) + gAdvancedEditUsed = false; + InitDialog(); + gAdvancedEditUsed = true; + + // If first initializing, we really aren't changing anything + gCellDataChanged = false; + + SetWindowLocation(); +} + +function InitDialog() { + // Get Table attributes + gDialog.TableRowsInput.value = gRowCount; + gDialog.TableColumnsInput.value = gColCount; + gDialog.TableWidthInput.value = InitPixelOrPercentMenulist( + globalTableElement, + gTableElement, + "width", + "TableWidthUnits", + gPercent + ); + if (gUseCSS) { + gDialog.TableHeightInput.value = InitPixelOrPercentMenulist( + globalTableElement, + gTableElement, + "height", + "TableHeightUnits", + gPercent + ); + } + gDialog.BorderWidthInput.value = globalTableElement.border; + gDialog.SpacingInput.value = globalTableElement.cellSpacing; + gDialog.PaddingInput.value = globalTableElement.cellPadding; + + var marginLeft = GetHTMLOrCSSStyleValue( + globalTableElement, + "align", + "margin-left" + ); + var marginRight = GetHTMLOrCSSStyleValue( + globalTableElement, + "align", + "margin-right" + ); + var halign = marginLeft.toLowerCase() + " " + marginRight.toLowerCase(); + if (halign == "center center" || halign == "auto auto") { + gDialog.TableAlignList.value = "center"; + } else if (halign == "right right" || halign == "auto 0px") { + gDialog.TableAlignList.value = "right"; + } else { + // Default is left. + gDialog.TableAlignList.value = "left"; + } + + // Be sure to get caption from table in doc, not the copied "globalTableElement" + gTableCaptionElement = gTableElement.caption; + if (gTableCaptionElement) { + var align = GetHTMLOrCSSStyleValue( + gTableCaptionElement, + "align", + "caption-side" + ); + if (align != "bottom" && align != "left" && align != "right") { + align = "top"; + } + gDialog.TableCaptionList.value = align; + } + + gTableColor = GetHTMLOrCSSStyleValue( + globalTableElement, + bgcolor, + cssBackgroundColorStr + ); + gTableColor = ConvertRGBColorIntoHEXColor(gTableColor); + SetColor("tableBackgroundCW", gTableColor); + + InitCellPanel(); +} + +function InitCellPanel() { + // Get cell attributes + if (globalCellElement) { + // This assumes order of items is Cell, Row, Column + gDialog.SelectionList.value = gSelectedCellsType; + + var previousValue = gDialog.CellHeightInput.value; + gDialog.CellHeightInput.value = InitPixelOrPercentMenulist( + globalCellElement, + gCellElement, + "height", + "CellHeightUnits", + gPixel + ); + gDialog.CellHeightCheckbox.checked = + gAdvancedEditUsed && previousValue != gDialog.CellHeightInput.value; + + previousValue = gDialog.CellWidthInput.value; + gDialog.CellWidthInput.value = InitPixelOrPercentMenulist( + globalCellElement, + gCellElement, + "width", + "CellWidthUnits", + gPixel + ); + gDialog.CellWidthCheckbox.checked = + gAdvancedEditUsed && previousValue != gDialog.CellWidthInput.value; + + var previousIndex = gDialog.CellVAlignList.selectedIndex; + var valign = GetHTMLOrCSSStyleValue( + globalCellElement, + "valign", + "vertical-align" + ).toLowerCase(); + if (valign == topStr || valign == bottomStr) { + gDialog.CellVAlignList.value = valign; + } else { + // Default is middle. + gDialog.CellVAlignList.value = defVAlign; + } + + gDialog.CellVAlignCheckbox.checked = + gAdvancedEditUsed && + previousIndex != gDialog.CellVAlignList.selectedIndex; + + previousIndex = gDialog.CellHAlignList.selectedIndex; + + gAlignWasChar = false; + + var halign = GetHTMLOrCSSStyleValue( + globalCellElement, + "align", + "text-align" + ).toLowerCase(); + switch (halign) { + case centerStr: + case rightStr: + case justifyStr: + gDialog.CellHAlignList.value = halign; + break; + case charStr: + // We don't support UI for this because layout doesn't work: bug 2212. + // Remember that's what they had so we don't change it + // unless they change the alignment by using the menulist + gAlignWasChar = true; + // Fall through to use show default alignment in menu + default: + // Default depends on cell type (TH is "center", TD is "left") + gDialog.CellHAlignList.value = + globalCellElement.nodeName.toLowerCase() == "th" ? "center" : "left"; + break; + } + + gDialog.CellHAlignCheckbox.checked = + gAdvancedEditUsed && + previousIndex != gDialog.CellHAlignList.selectedIndex; + + previousIndex = gDialog.CellStyleList.selectedIndex; + gDialog.CellStyleList.value = globalCellElement.nodeName.toLowerCase(); + gDialog.CellStyleCheckbox.checked = + gAdvancedEditUsed && previousIndex != gDialog.CellStyleList.selectedIndex; + + previousIndex = gDialog.TextWrapList.selectedIndex; + if ( + GetHTMLOrCSSStyleValue(globalCellElement, "nowrap", "white-space") == + "nowrap" + ) { + gDialog.TextWrapList.value = "nowrap"; + } else { + gDialog.TextWrapList.value = "wrap"; + } + gDialog.TextWrapCheckbox.checked = + gAdvancedEditUsed && previousIndex != gDialog.TextWrapList.selectedIndex; + + previousValue = gCellColor; + gCellColor = GetHTMLOrCSSStyleValue( + globalCellElement, + bgcolor, + cssBackgroundColorStr + ); + gCellColor = ConvertRGBColorIntoHEXColor(gCellColor); + SetColor("cellBackgroundCW", gCellColor); + gDialog.CellColorCheckbox.checked = + gAdvancedEditUsed && previousValue != gCellColor; + + // We want to set this true in case changes came + // from Advanced Edit dialog session (must assume something changed) + gCellDataChanged = true; + } +} + +function GetCellData(rowIndex, colIndex) { + // Get actual rowspan and colspan + var startRowIndexObj = { value: 0 }; + var startColIndexObj = { value: 0 }; + var rowSpanObj = { value: 0 }; + var colSpanObj = { value: 0 }; + var actualRowSpanObj = { value: 0 }; + var actualColSpanObj = { value: 0 }; + var isSelectedObj = { value: false }; + + try { + gActiveEditor.getCellDataAt( + gTableElement, + rowIndex, + colIndex, + gCellData, + startRowIndexObj, + startColIndexObj, + rowSpanObj, + colSpanObj, + actualRowSpanObj, + actualColSpanObj, + isSelectedObj + ); + // We didn't find a cell + if (!gCellData.value) { + return false; + } + } catch (ex) { + return false; + } + + gCellData.startRowIndex = startRowIndexObj.value; + gCellData.startColIndex = startColIndexObj.value; + gCellData.rowSpan = rowSpanObj.value; + gCellData.colSpan = colSpanObj.value; + gCellData.actualRowSpan = actualRowSpanObj.value; + gCellData.actualColSpan = actualColSpanObj.value; + gCellData.isSelected = isSelectedObj.value; + return true; +} + +function SelectCellHAlign() { + SetCheckbox("CellHAlignCheckbox"); + // Once user changes the alignment, + // we lose their original "CharAt" alignment" + gAlignWasChar = false; +} + +function GetColorAndUpdate(ColorWellID) { + var colorWell = document.getElementById(ColorWellID); + if (!colorWell) { + return; + } + + var colorObj = { + Type: "", + TableColor: 0, + CellColor: 0, + NoDefault: false, + Cancel: false, + BackgroundColor: 0, + }; + + switch (ColorWellID) { + case "tableBackgroundCW": + colorObj.Type = "Table"; + colorObj.TableColor = gTableColor; + break; + case "cellBackgroundCW": + colorObj.Type = "Cell"; + colorObj.CellColor = gCellColor; + break; + } + window.openDialog( + "chrome://messenger/content/messengercompose/EdColorPicker.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "", + colorObj + ); + + // User canceled the dialog + if (colorObj.Cancel) { + return; + } + + switch (ColorWellID) { + case "tableBackgroundCW": + gTableColor = colorObj.BackgroundColor; + SetColor(ColorWellID, gTableColor); + break; + case "cellBackgroundCW": + gCellColor = colorObj.BackgroundColor; + SetColor(ColorWellID, gCellColor); + SetCheckbox("CellColorCheckbox"); + break; + } +} + +function SetColor(ColorWellID, color) { + // Save the color + if (ColorWellID == "cellBackgroundCW") { + if (color) { + try { + gActiveEditor.setAttributeOrEquivalent( + globalCellElement, + bgcolor, + color, + true + ); + } catch (e) {} + gDialog.CellInheritColor.collapsed = true; + } else { + try { + gActiveEditor.removeAttributeOrEquivalent( + globalCellElement, + bgcolor, + true + ); + } catch (e) {} + // Reveal addition message explaining "default" color + gDialog.CellInheritColor.collapsed = false; + } + } else { + if (color) { + try { + gActiveEditor.setAttributeOrEquivalent( + globalTableElement, + bgcolor, + color, + true + ); + } catch (e) {} + gDialog.TableInheritColor.collapsed = true; + } else { + try { + gActiveEditor.removeAttributeOrEquivalent( + globalTableElement, + bgcolor, + true + ); + } catch (e) {} + gDialog.TableInheritColor.collapsed = false; + } + SetCheckbox("CellColorCheckbox"); + } + + setColorWell(ColorWellID, color); +} + +function ChangeSelectionToFirstCell() { + if (!GetCellData(0, 0)) { + dump("Can't find first cell in table!\n"); + return; + } + gCellElement = gCellData.value; + globalCellElement = gCellElement; + + gCurRowIndex = 0; + gCurColIndex = 0; + ChangeSelection(RESET_SELECTION); +} + +function ChangeSelection(newType) { + newType = Number(newType); + + if (gSelectedCellsType == newType) { + return; + } + + if (newType == RESET_SELECTION) { + // Restore selection to existing focus cell + gSelection.collapse(gCellElement, 0); + } else { + gSelectedCellsType = newType; + } + + // Keep the same focus gCellElement, just change the type + DoCellSelection(); + SetSelectionButtons(); + + // Note: globalCellElement should still be a clone of gCellElement +} + +function MoveSelection(forward) { + var newRowIndex = gCurRowIndex; + var newColIndex = gCurColIndex; + var inRow = false; + + if (gSelectedCellsType == SELECT_ROW) { + newRowIndex += forward ? 1 : -1; + + // Wrap around if before first or after last row + if (newRowIndex < 0) { + newRowIndex = gLastRowIndex; + } else if (newRowIndex > gLastRowIndex) { + newRowIndex = 0; + } + inRow = true; + + // Use first cell in row for focus cell + newColIndex = 0; + } else { + // Cell or column: + if (!forward) { + newColIndex--; + } + + if (gSelectedCellsType == SELECT_CELL) { + // Skip to next cell + if (forward) { + newColIndex += gCurColSpan; + } + } else { + // SELECT_COLUMN + // Use first cell in column for focus cell + newRowIndex = 0; + + // Don't skip by colspan, + // but find first cell in next cellmap column + if (forward) { + newColIndex++; + } + } + + if (newColIndex < 0) { + // Request is before the first cell in column + + // Wrap to last cell in column + newColIndex = gLastColIndex; + + if (gSelectedCellsType == SELECT_CELL) { + // If moving by cell, also wrap to previous... + if (newRowIndex > 0) { + newRowIndex -= 1; + } else { + // ...or the last row. + newRowIndex = gLastRowIndex; + } + + inRow = true; + } + } else if (newColIndex > gLastColIndex) { + // Request is after the last cell in column + + // Wrap to first cell in column + newColIndex = 0; + + if (gSelectedCellsType == SELECT_CELL) { + // If moving by cell, also wrap to next... + if (newRowIndex < gLastRowIndex) { + newRowIndex++; + } else { + // ...or the first row. + newRowIndex = 0; + } + + inRow = true; + } + } + } + + // Get the cell at the new location + do { + if (!GetCellData(newRowIndex, newColIndex)) { + dump("MoveSelection: CELL NOT FOUND\n"); + return; + } + if (inRow) { + if (gCellData.startRowIndex == newRowIndex) { + break; + } else { + // Cell spans from a row above, look for the next cell in row. + newRowIndex += gCellData.actualRowSpan; + } + } else if (gCellData.startColIndex == newColIndex) { + break; + } else { + // Cell spans from a Col above, look for the next cell in column + newColIndex += gCellData.actualColSpan; + } + } while (true); + + // Save data for current selection before changing + if (gCellDataChanged) { + // && gDialog.ApplyBeforeMove.checked) + if (!ValidateCellData()) { + return; + } + + gActiveEditor.beginTransaction(); + // Apply changes to all selected cells + ApplyCellAttributes(); + gActiveEditor.endTransaction(); + + SetCloseButton(); + } + + // Set cell and other data for new selection + gCellElement = gCellData.value; + + // Save globals for new current cell + gCurRowIndex = gCellData.startRowIndex; + gCurColIndex = gCellData.startColIndex; + gCurColSpan = gCellData.actualColSpan; + + // Copy for new global cell + globalCellElement = gCellElement.cloneNode(false); + + // Change the selection + DoCellSelection(); + + // Scroll page so new selection is visible + // Using SELECTION_ANCHOR_REGION makes the upper-left corner of first selected cell + // the point to bring into view. + try { + var selectionController = gActiveEditor.selectionController; + selectionController.scrollSelectionIntoView( + selectionController.SELECTION_NORMAL, + selectionController.SELECTION_ANCHOR_REGION, + true + ); + } catch (e) {} + + // Reinitialize dialog using new cell + // if (!gDialog.KeepCurrentData.checked) + // Setting this false unchecks all "set attributes" checkboxes + gAdvancedEditUsed = false; + InitCellPanel(); + gAdvancedEditUsed = true; +} + +function DoCellSelection() { + // Collapse selection into to the focus cell + // so editor uses that as start cell + gSelection.collapse(gCellElement, 0); + + var tagNameObj = { value: "" }; + var countObj = { value: 0 }; + try { + switch (gSelectedCellsType) { + case SELECT_CELL: + gActiveEditor.selectTableCell(); + break; + case SELECT_ROW: + gActiveEditor.selectTableRow(); + break; + default: + gActiveEditor.selectTableColumn(); + break; + } + // Get number of cells selected + gActiveEditor.getSelectedOrParentTableElement(tagNameObj, countObj); + } catch (e) {} + + if (tagNameObj.value == "td") { + gSelectedCellCount = countObj.value; + } else { + gSelectedCellCount = 0; + } + + // Currently, we can only allow advanced editing on ONE cell element at a time + // else we ignore CSS, JS, and HTML attributes not already in dialog + SetElementEnabled(gDialog.AdvancedEditCell, gSelectedCellCount == 1); + + gDialog.AdvancedEditCell.setAttribute( + "tooltiptext", + gSelectedCellCount > 1 + ? GetString("AdvancedEditForCellMsg") + : gDialog.AdvancedEditCellToolTipText + ); +} + +function SetSelectionButtons() { + if (gSelectedCellsType == SELECT_ROW) { + // Trigger CSS to set images of up and down arrows + gDialog.PreviousButton.setAttribute("type", "row"); + gDialog.NextButton.setAttribute("type", "row"); + } else { + // or images of left and right arrows + gDialog.PreviousButton.setAttribute("type", "col"); + gDialog.NextButton.setAttribute("type", "col"); + } + DisableSelectionButtons( + (gSelectedCellsType == SELECT_ROW && gRowCount == 1) || + (gSelectedCellsType == SELECT_COLUMN && gColCount == 1) || + (gRowCount == 1 && gColCount == 1) + ); +} + +function DisableSelectionButtons(disable) { + gDialog.PreviousButton.setAttribute("disabled", disable ? "true" : "false"); + gDialog.NextButton.setAttribute("disabled", disable ? "true" : "false"); +} + +function SwitchToValidatePanel() { + if (gDialog.TabBox.selectedTab != gValidateTab) { + gDialog.TabBox.selectedTab = gValidateTab; + } +} + +function SetAlign(listID, defaultValue, element, attName) { + var value = document.getElementById(listID).value; + if (value == defaultValue) { + try { + gActiveEditor.removeAttributeOrEquivalent(element, attName, true); + } catch (e) {} + } else { + try { + gActiveEditor.setAttributeOrEquivalent(element, attName, value, true); + } catch (e) {} + } +} + +function ValidateTableData() { + gValidateTab = gDialog.TableTab; + gNewRowCount = Number( + ValidateNumber(gDialog.TableRowsInput, null, 1, gMaxRows, null, true, true) + ); + if (gValidationError) { + return false; + } + + gNewColCount = Number( + ValidateNumber( + gDialog.TableColumnsInput, + null, + 1, + gMaxColumns, + null, + true, + true + ) + ); + if (gValidationError) { + return false; + } + + // If user is deleting any cells, get confirmation + // (This is a global to the dialog and we ask only once per dialog session) + if (!gCanDelete && (gNewRowCount < gRowCount || gNewColCount < gColCount)) { + if ( + ConfirmWithTitle( + GetString("DeleteTableTitle"), + GetString("DeleteTableMsg"), + GetString("DeleteCells") + ) + ) { + gCanDelete = true; + } else { + SetTextboxFocus( + gNewRowCount < gRowCount + ? gDialog.TableRowsInput + : gDialog.TableColumnsInput + ); + return false; + } + } + + ValidateNumber( + gDialog.TableWidthInput, + gDialog.TableWidthUnits, + 1, + gMaxTableSize, + globalTableElement, + "width" + ); + if (gValidationError) { + return false; + } + + if (gUseCSS) { + ValidateNumber( + gDialog.TableHeightInput, + gDialog.TableHeightUnits, + 1, + gMaxTableSize, + globalTableElement, + "height" + ); + if (gValidationError) { + return false; + } + } + + ValidateNumber( + gDialog.BorderWidthInput, + null, + 0, + gMaxPixels, + globalTableElement, + "border" + ); + // TODO: Deal with "BORDER" without value issue + if (gValidationError) { + return false; + } + + ValidateNumber( + gDialog.SpacingInput, + null, + 0, + gMaxPixels, + globalTableElement, + "cellspacing" + ); + if (gValidationError) { + return false; + } + + ValidateNumber( + gDialog.PaddingInput, + null, + 0, + gMaxPixels, + globalTableElement, + "cellpadding" + ); + if (gValidationError) { + return false; + } + + SetAlign("TableAlignList", defHAlign, globalTableElement, "align"); + + // Color is set on globalCellElement immediately + return true; +} + +function ValidateCellData() { + gValidateTab = gDialog.CellTab; + + if (gDialog.CellHeightCheckbox.checked) { + ValidateNumber( + gDialog.CellHeightInput, + gDialog.CellHeightUnits, + 1, + gMaxTableSize, + globalCellElement, + "height" + ); + if (gValidationError) { + return false; + } + } + + if (gDialog.CellWidthCheckbox.checked) { + ValidateNumber( + gDialog.CellWidthInput, + gDialog.CellWidthUnits, + 1, + gMaxTableSize, + globalCellElement, + "width" + ); + if (gValidationError) { + return false; + } + } + + if (gDialog.CellHAlignCheckbox.checked) { + var hAlign = gDialog.CellHAlignList.value; + + // Horizontal alignment is complicated by "char" type + // We don't change current values if user didn't edit alignment + if (!gAlignWasChar) { + globalCellElement.removeAttribute(charStr); + + // Always set "align" attribute, + // so the default "left" is effective in a cell + // when parent row has align set. + globalCellElement.setAttribute("align", hAlign); + } + } + + if (gDialog.CellVAlignCheckbox.checked) { + // Always set valign (no default in 2nd param) so + // the default "middle" is effective in a cell + // when parent row has valign set. + SetAlign("CellVAlignList", "", globalCellElement, "valign"); + } + + if (gDialog.TextWrapCheckbox.checked) { + if (gDialog.TextWrapList.value == "nowrap") { + try { + gActiveEditor.setAttributeOrEquivalent( + globalCellElement, + "nowrap", + "nowrap", + true + ); + } catch (e) {} + } else { + try { + gActiveEditor.removeAttributeOrEquivalent( + globalCellElement, + "nowrap", + true + ); + } catch (e) {} + } + } + + return true; +} + +function ValidateData() { + var result; + + // Validate current panel first + if (gDialog.TabBox.selectedTab == gDialog.TableTab) { + result = ValidateTableData(); + if (result) { + result = ValidateCellData(); + } + } else { + result = ValidateCellData(); + if (result) { + result = ValidateTableData(); + } + } + if (!result) { + return false; + } + + // Set global element for AdvancedEdit + if (gDialog.TabBox.selectedTab == gDialog.TableTab) { + globalElement = globalTableElement; + } else { + globalElement = globalCellElement; + } + + return true; +} + +function ChangeCellTextbox(textboxID) { + // Filter input for just integers + forceInteger(textboxID); + + if (gDialog.TabBox.selectedTab == gDialog.CellTab) { + gCellDataChanged = true; + } +} + +// Call this when a textbox or menulist is changed +// so the checkbox is automatically set +function SetCheckbox(checkboxID) { + if (checkboxID && checkboxID.length > 0) { + // Set associated checkbox + document.getElementById(checkboxID).checked = true; + } + gCellDataChanged = true; +} + +function ChangeIntTextbox(checkboxID) { + // Set associated checkbox + SetCheckbox(checkboxID); +} + +function CloneAttribute(destElement, srcElement, attr) { + var value = srcElement.getAttribute(attr); + // Use editor methods since we are always + // modifying a table in the document and + // we need transaction system for undo + try { + if (!value || value.length == 0) { + gActiveEditor.removeAttributeOrEquivalent(destElement, attr, false); + } else { + gActiveEditor.setAttributeOrEquivalent(destElement, attr, value, false); + } + } catch (e) {} +} + +/* eslint-disable complexity */ +function ApplyTableAttributes() { + var newAlign = gDialog.TableCaptionList.value; + if (!newAlign) { + newAlign = ""; + } + + if (gTableCaptionElement) { + // Get current alignment + var align = GetHTMLOrCSSStyleValue( + gTableCaptionElement, + "align", + "caption-side" + ).toLowerCase(); + // This is the default + if (!align) { + align = "top"; + } + + if (newAlign == "") { + // Remove existing caption + try { + gActiveEditor.deleteNode(gTableCaptionElement); + } catch (e) {} + gTableCaptionElement = null; + } else if (newAlign != align) { + try { + if (newAlign == "top") { + // This is default, so don't explicitly set it + gActiveEditor.removeAttributeOrEquivalent( + gTableCaptionElement, + "align", + false + ); + } else { + gActiveEditor.setAttributeOrEquivalent( + gTableCaptionElement, + "align", + newAlign, + false + ); + } + } catch (e) {} + } + } else if (newAlign != "") { + // Create and insert a caption: + try { + gTableCaptionElement = gActiveEditor.createElementWithDefaults("caption"); + } catch (e) {} + if (gTableCaptionElement) { + if (newAlign != "top") { + gTableCaptionElement.setAttribute("align", newAlign); + } + + // Insert it into the table - caption is always inserted as first child + try { + gActiveEditor.insertNode(gTableCaptionElement, gTableElement, 0); + } catch (e) {} + + // Put selection back where it was + ChangeSelection(RESET_SELECTION); + } + } + + var countDelta; + var foundCell; + var i; + + if (gNewRowCount != gRowCount) { + countDelta = gNewRowCount - gRowCount; + if (gNewRowCount > gRowCount) { + // Append new rows + // Find first cell in last row + if (GetCellData(gLastRowIndex, 0)) { + try { + // Move selection to the last cell + gSelection.collapse(gCellData.value, 0); + // Insert new rows after it + gActiveEditor.insertTableRow(countDelta, true); + gRowCount = gNewRowCount; + gLastRowIndex = gRowCount - 1; + // Put selection back where it was + ChangeSelection(RESET_SELECTION); + } catch (ex) { + dump("FAILED TO FIND FIRST CELL IN LAST ROW\n"); + } + } + } else if (gCanDelete) { + // Delete rows + // Find first cell starting in first row we delete + var firstDeleteRow = gRowCount + countDelta; + foundCell = false; + for (i = 0; i <= gLastColIndex; i++) { + if (!GetCellData(firstDeleteRow, i)) { + // We failed to find a cell. + break; + } + + if (gCellData.startRowIndex == firstDeleteRow) { + foundCell = true; + break; + } + } + if (foundCell) { + try { + // Move selection to the cell we found + gSelection.collapse(gCellData.value, 0); + gActiveEditor.deleteTableRow(-countDelta); + gRowCount = gNewRowCount; + gLastRowIndex = gRowCount - 1; + if (gCurRowIndex > gLastRowIndex) { + // We are deleting our selection + // move it to start of table + ChangeSelectionToFirstCell(); + } else { + // Put selection back where it was. + ChangeSelection(RESET_SELECTION); + } + } catch (ex) { + dump("FAILED TO FIND FIRST CELL IN LAST ROW\n"); + } + } + } + } + + if (gNewColCount != gColCount) { + countDelta = gNewColCount - gColCount; + + if (gNewColCount > gColCount) { + // Append new columns + // Find last cell in first column + if (GetCellData(0, gLastColIndex)) { + try { + // Move selection to the last cell + gSelection.collapse(gCellData.value, 0); + gActiveEditor.insertTableColumn(countDelta, true); + gColCount = gNewColCount; + gLastColIndex = gColCount - 1; + // Restore selection + ChangeSelection(RESET_SELECTION); + } catch (ex) { + dump("FAILED TO FIND FIRST CELL IN LAST COLUMN\n"); + } + } + } else if (gCanDelete) { + // Delete columns + var firstDeleteCol = gColCount + countDelta; + foundCell = false; + for (i = 0; i <= gLastRowIndex; i++) { + // Find first cell starting in first column we delete + if (!GetCellData(i, firstDeleteCol)) { + // We failed to find a cell. + break; + } + + if (gCellData.startColIndex == firstDeleteCol) { + foundCell = true; + break; + } + } + if (foundCell) { + try { + // Move selection to the cell we found + gSelection.collapse(gCellData.value, 0); + gActiveEditor.deleteTableColumn(-countDelta); + gColCount = gNewColCount; + gLastColIndex = gColCount - 1; + if (gCurColIndex > gLastColIndex) { + ChangeSelectionToFirstCell(); + } else { + ChangeSelection(RESET_SELECTION); + } + } catch (ex) { + dump("FAILED TO FIND FIRST CELL IN LAST ROW\n"); + } + } + } + } + + // Clone all remaining attributes to pick up + // anything changed by Advanced Edit Dialog + try { + gActiveEditor.cloneAttributes(gTableElement, globalTableElement); + } catch (e) {} +} +/* eslint-enable complexity */ + +function ApplyCellAttributes() { + let selectedCells = gActiveEditor.getSelectedCells(); + if (selectedCells.length == 0) { + return; + } + + if (selectedCells.length == 1) { + let cell = selectedCells[0]; + // When only one cell is selected, simply clone entire element, + // thus CSS and JS from Advanced edit is copied + + gActiveEditor.cloneAttributes(cell, globalCellElement); + + if (gDialog.CellStyleCheckbox.checked) { + let currentStyleIndex = cell.nodeName.toLowerCase() == "th" ? 1 : 0; + if (gDialog.CellStyleList.selectedIndex != currentStyleIndex) { + // Switch cell types + // (replaces with new cell and copies attributes and contents) + gActiveEditor.switchTableCellHeaderType(cell); + } + } + } else { + // Apply changes to all selected cells + // XXX THIS DOESN'T COPY ADVANCED EDIT CHANGES! + for (let cell of selectedCells) { + ApplyAttributesToOneCell(cell); + } + } + gCellDataChanged = false; +} + +function ApplyAttributesToOneCell(destElement) { + if (gDialog.CellHeightCheckbox.checked) { + CloneAttribute(destElement, globalCellElement, "height"); + } + + if (gDialog.CellWidthCheckbox.checked) { + CloneAttribute(destElement, globalCellElement, "width"); + } + + if (gDialog.CellHAlignCheckbox.checked) { + CloneAttribute(destElement, globalCellElement, "align"); + CloneAttribute(destElement, globalCellElement, charStr); + } + + if (gDialog.CellVAlignCheckbox.checked) { + CloneAttribute(destElement, globalCellElement, "valign"); + } + + if (gDialog.TextWrapCheckbox.checked) { + CloneAttribute(destElement, globalCellElement, "nowrap"); + } + + if (gDialog.CellStyleCheckbox.checked) { + var newStyleIndex = gDialog.CellStyleList.selectedIndex; + var currentStyleIndex = destElement.nodeName.toLowerCase() == "th" ? 1 : 0; + + if (newStyleIndex != currentStyleIndex) { + // Switch cell types + // (replaces with new cell and copies attributes and contents) + try { + destElement = gActiveEditor.switchTableCellHeaderType(destElement); + } catch (e) {} + } + } + + if (gDialog.CellColorCheckbox.checked) { + CloneAttribute(destElement, globalCellElement, "bgcolor"); + } +} + +function SetCloseButton() { + // Change text on "Cancel" button after Apply is used + if (!gApplyUsed) { + document + .querySelector("dialog") + .setAttribute( + "buttonlabelcancel", + document.querySelector("dialog").getAttribute("buttonlabelclose") + ); + gApplyUsed = true; + } +} + +function Apply() { + if (ValidateData()) { + gActiveEditor.beginTransaction(); + + ApplyTableAttributes(); + + // We may have just a table, so check for cell element + if (globalCellElement) { + ApplyCellAttributes(); + } + + gActiveEditor.endTransaction(); + + SetCloseButton(); + return true; + } + return false; +} + +function onAccept(event) { + // Do same as Apply and close window if ValidateData succeeded + var retVal = Apply(); + if (retVal) { + SaveWindowLocation(); + } else { + event.preventDefault(); + } +} diff --git a/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml b/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml new file mode 100644 index 0000000000..a82d5e18c5 --- /dev/null +++ b/comm/mail/components/compose/content/dialogs/EdTableProps.xhtml @@ -0,0 +1,472 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://editor/skin/EditorDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % edTableProperties SYSTEM "chrome://messenger/locale/messengercompose/EditorTableProperties.dtd"> +%edTableProperties; +<!ENTITY % edDialogOverlay SYSTEM "chrome://messenger/locale/messengercompose/EdDialogOverlay.dtd"> +%edDialogOverlay; ]> + +<window + title="&tableWindow.title;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + onload="Startup()" +> + <dialog + id="tableDlg" + buttons="accept,extra1,cancel" + buttonlabelclose="&closeButton.label;" + buttonlabelextra1="&applyButton.label;" + buttonaccesskeyextra1="&applyButton.accesskey;" + > + <!-- Methods common to all editor dialogs --> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://messenger/content/messengercompose/editorUtilities.js" /> + <script src="chrome://messenger/content/messengercompose/EdDialogCommon.js" /> + <script src="chrome://messenger/content/messengercompose/EdTableProps.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <spacer id="location" offsetY="50" persist="offsetX offsetY" /> + + <tabbox id="TabBox"> + <tabs flex="1"> + <tab id="TableTab" label="&tableTab.label;" /> + <tab id="CellTab" label="&cellTab.label;" /> + </tabs> + <tabpanels> + <!-- TABLE PANEL --> + <vbox> + <html:fieldset orient="horizontal"> + <html:legend>&size.label;</html:legend> + <hbox> + <vbox> + <hbox> + <vbox> + <hbox align="center" flex="1"> + <label + id="TableRowsLabel" + value="&tableRows.label;" + accesskey="&tableRows.accessKey;" + control="TableRowsInput" + /> + </hbox> + <hbox align="center" flex="1"> + <label + id="TableColumnsLabel" + value="&tableColumns.label;" + accesskey="&tableColumns.accessKey;" + control="TableColumnsInput" + /> + </hbox> + </vbox> + <vbox> + <html:input + id="TableRowsInput" + type="number" + class="narrow input-inline" + aria-labelledby="TableRowsLabel" + /> + <html:input + id="TableColumnsInput" + type="number" + class="narrow input-inline" + aria-labelledby="TableColumnsLabel" + /> + </vbox> + </hbox> + </vbox> + <vbox> + <html:div class="grid-three-column"> + <html:div class="flex-items-center"> + <label + id="TableHeightLabel" + value="&tableHeight.label;" + accesskey="&tableHeight.accessKey;" + control="TableHeightInput" + /> + </html:div> + <html:div> + <html:input + id="TableHeightInput" + type="number" + class="narrow input-inline" + aria-labelledby="TableHeightLabel" + /> + </html:div> + <html:div class="flex-items-center"> + <menulist id="TableHeightUnits" /> + </html:div> + <html:div class="flex-items-center"> + <label + id="TableWidthLabel" + value="&tableWidth.label;" + accesskey="&tableWidth.accessKey;" + control="TableWidthInput" + /> + </html:div> + <html:div class="flex-items-center"> + <html:input + id="TableWidthInput" + type="number" + class="narrow input-inline" + aria-labelledby="TableWidthLabel" + /> + </html:div> + <html:div class="flex-items-center"> + <menulist id="TableWidthUnits" /> + </html:div> + </html:div> + </vbox> + </hbox> + </html:fieldset> + <html:fieldset> + <html:legend>&tableBorderSpacing.label;</html:legend> + <hbox> + <vbox> + <hbox flex="1" align="center"> + <label + id="BorderWidthLabel" + control="BorderWidthInput" + value="&tableBorderWidth.label;" + accesskey="&tableBorderWidth.accessKey;" + /> + </hbox> + <hbox flex="1" align="center"> + <label + id="SpacingLabel" + control="SpacingInput" + value="&tableSpacing.label;" + accesskey="&tableSpacing.accessKey;" + /> + </hbox> + <hbox flex="1" align="center"> + <label + id="PaddingLabel" + control="PaddingInput" + value="&tablePadding.label;" + accesskey="&tablePadding.accessKey;" + /> + </hbox> + </vbox> + <vbox> + <html:input + id="BorderWidthInput" + type="number" + class="narrow input-inline" + aria-labelledby="BorderWidthLabel" + /> + <html:input + id="SpacingInput" + type="number" + class="narrow input-inline" + aria-labelledby="SpacingLabel" + /> + <html:input + id="PaddingInput" + type="number" + class="narrow input-inline" + aria-labelledby="PaddingLabel" + /> + </vbox> + <vbox> + <hbox flex="1" align="center"> + <label align="start" value="&pixels.label;" /> + </hbox> + <hbox flex="1" align="center"> + <label value="&tablePxBetwCells.label;" /> + </hbox> + <hbox flex="1" align="center"> + <label value="&tablePxBetwBrdrCellContent.label;" /> + </hbox> + </vbox> + </hbox> + </html:fieldset> + <!-- Table Alignment and Caption --> + <hbox flex="1" align="center"> + <label + control="TableAlignList" + value="&tableAlignment.label;" + accesskey="&tableAlignment.accessKey;" + /> + <menulist id="TableAlignList"> + <menupopup> + <menuitem label="&AlignLeft.label;" value="left" /> + <menuitem label="&AlignCenter.label;" value="center" /> + <menuitem label="&AlignRight.label;" value="right" /> + </menupopup> + </menulist> + <spacer class="spacer" /> + <label + control="TableCaptionList" + value="&tableCaption.label;" + accesskey="&tableCaption.accessKey;" + /> + <menulist id="TableCaptionList"> + <menupopup> + <menuitem label="&tableCaptionNone.label;" value="" /> + <menuitem label="&tableCaptionAbove.label;" value="top" /> + <menuitem label="&tableCaptionBelow.label;" value="bottom" /> + <menuitem label="&tableCaptionLeft.label;" value="left" /> + <menuitem label="&tableCaptionRight.label;" value="right" /> + </menupopup> + </menulist> + </hbox> + <separator class="groove" /> + <hbox align="center"> + <label value="&backgroundColor.label;" /> + <button + id="tableBackground" + class="color-button" + oncommand="GetColorAndUpdate('tableBackgroundCW');" + > + <spacer id="tableBackgroundCW" class="color-well" /> + </button> + <spacer class="spacer" /> + <label + id="TableInheritColor" + value="&tableInheritColor.label;" + collapsed="true" + /> + </hbox> + <separator class="groove" /> + <hbox flex="1" align="center"> + <spacer flex="1" /> + <button + id="AdvancedEditButton" + oncommand="onAdvancedEdit();" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + <spacer flex="1" /> </vbox + ><!-- Table Panel --> + + <!-- CELL PANEL --> + <vbox> + <html:fieldset> + <html:legend>&cellSelection.label;</html:legend> + <vbox> + <menulist + id="SelectionList" + oncommand="ChangeSelection(event.target.value)" + > + <menupopup> + <!-- JS code assumes order is Cell, Row, Column --> + <menuitem label="&cellSelectCell.label;" value="1" /> + <menuitem label="&cellSelectRow.label;" value="2" /> + <menuitem label="&cellSelectColumn.label;" value="3" /> + </menupopup> + </menulist> + <hbox> + <button + id="PreviousButton" + label="&cellSelectPrevious.label;" + accesskey="&cellSelectPrevious.accessKey;" + oncommand="MoveSelection(0)" + /> + <button + id="NextButton" + label="&cellSelectNext.label;" + accesskey="&cellSelectNext.accessKey;" + oncommand="MoveSelection(1)" + /> + </hbox> + <hbox flex="1"> &applyBeforeChange.label; </hbox> + </vbox> + </html:fieldset> + + <separator class="groove" /> + + <hbox align="center"> + <html:fieldset> + <html:legend>&size.label;</html:legend> + <hbox> + <vbox> + <hbox flex="1" align="center"> + <checkbox + id="CellHeightCheckbox" + label="&tableHeight.label;" + accesskey="&tableHeight.accessKey;" + /> + </hbox> + <hbox flex="1" align="center"> + <checkbox + id="CellWidthCheckbox" + label="&tableWidth.label;" + accesskey="&tableWidth.accessKey;" + /> + </hbox> + </vbox> + <vbox flex="1"> + <hbox flex="1" align="center"> + <html:input + id="CellHeightInput" + type="number" + class="narrow input-inline" + onchange="ChangeIntTextbox('CellHeightCheckbox');" + aria-labelledby="CellHeightCheckbox" + /> + </hbox> + <hbox flex="1" align="center"> + <html:input + id="CellWidthInput" + type="number" + class="narrow input-inline" + onchange="ChangeIntTextbox('CellWidthCheckbox');" + aria-labelledby="CellWidthCheckbox" + /> + </hbox> + </vbox> + <vbox> + <hbox flex="1" align="center"> + <menulist + id="CellHeightUnits" + oncommand="SetCheckbox('CellHeightCheckbox');" + /> + </hbox> + <hbox flex="1" align="center"> + <menulist + id="CellWidthUnits" + oncommand="SetCheckbox('CellWidthCheckbox');" + /> + </hbox> + </vbox> + </hbox> + </html:fieldset> + <html:fieldset> + <html:legend>&cellContentAlignment.label;</html:legend> + <hbox> + <vbox> + <hbox align="center" flex="1"> + <checkbox + id="CellVAlignCheckbox" + label="&cellVertical.label;" + accesskey="&cellVertical.accessKey;" + /> + </hbox> + <hbox align="center" flex="1"> + <checkbox + id="CellHAlignCheckbox" + label="&cellHorizontal.label;" + accesskey="&cellHorizontal.accessKey;" + /> + </hbox> + </vbox> + <vbox flex="1"> + <menulist + id="CellVAlignList" + oncommand="SetCheckbox('CellVAlignCheckbox');" + > + <menupopup> + <menuitem label="&cellAlignTop.label;" value="top" /> + <menuitem + label="&cellAlignMiddle.label;" + value="middle" + /> + <menuitem + label="&cellAlignBottom.label;" + value="bottom" + /> + </menupopup> + </menulist> + <menulist id="CellHAlignList" oncommand="SelectCellHAlign()"> + <menupopup> + <menuitem label="&AlignLeft.label;" value="left" /> + <menuitem label="&AlignCenter.label;" value="center" /> + <menuitem label="&AlignRight.label;" value="right" /> + <menuitem + label="&cellAlignJustify.label;" + value="justify" + /> + </menupopup> + </menulist> + </vbox> + </hbox> + </html:fieldset> + </hbox> + <spacer class="spacer" /> + <hbox align="center"> + <checkbox + id="CellStyleCheckbox" + label="&cellStyle.label;" + accesskey="&cellStyle.accessKey;" + /> + <menulist + id="CellStyleList" + oncommand="SetCheckbox('CellStyleCheckbox');" + > + <menupopup> + <menuitem label="&cellNormal.label;" value="td" /> + <menuitem label="&cellHeader.label;" value="th" /> + </menupopup> + </menulist> + <spacer flex="1" /> + <checkbox + id="TextWrapCheckbox" + label="&cellTextWrap.label;" + accesskey="&cellTextWrap.accessKey;" + /> + <menulist + id="TextWrapList" + oncommand="SetCheckbox('TextWrapCheckbox');" + > + <menupopup> + <menuitem label="&cellWrap.label;" value="wrap" /> + <menuitem label="&cellNoWrap.label;" value="nowrap" /> + </menupopup> + </menulist> + </hbox> + <separator class="groove" /> + <hbox align="center"> + <checkbox + id="CellColorCheckbox" + label="&backgroundColor.label;" + accesskey="&backgroundColor.accessKey;" + /> + <button + class="color-button" + oncommand="GetColorAndUpdate('cellBackgroundCW');" + > + <spacer id="cellBackgroundCW" class="color-well" /> + </button> + <spacer class="spacer" /> + <label + id="CellInheritColor" + value="&cellInheritColor.label;" + collapsed="true" + /> + </hbox> + <separator class="groove" /> + <hbox align="center"> + <description class="wrap" flex="1" style="width: 1em" + >&cellUseCheckboxHelp.label;</description + > + <button + id="AdvancedEditButton2" + oncommand="onAdvancedEdit()" + label="&AdvancedEditButton.label;" + accesskey="&AdvancedEditButton.accessKey;" + tooltiptext="&AdvancedEditButton.tooltip;" + /> + </hbox> + <spacer flex="1" /> </vbox + ><!-- Cell Panel --> + </tabpanels> + </tabbox> + <spacer class="spacer" /> + </dialog> +</window> diff --git a/comm/mail/components/compose/content/editFormatButtons.inc.xhtml b/comm/mail/components/compose/content/editFormatButtons.inc.xhtml new file mode 100644 index 0000000000..f84b2610e6 --- /dev/null +++ b/comm/mail/components/compose/content/editFormatButtons.inc.xhtml @@ -0,0 +1,282 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + <!-- Formatting toolbar items. "value" are HTML tagnames, don't translate --> + <menulist id="ParagraphSelect" + class="toolbar-focustarget" + oncommand="setParagraphState(event);" + crop="end" + tooltiptext="&ParagraphSelect.tooltip;" + observes="cmd_renderedHTMLEnabler"> + <menupopup id="ParagraphPopup"> + <menuitem id="toolbarmenu_bodyText" label="&bodyTextCmd.label;" value=""/> + <menuitem id="toolbarmenu_paragraph" label="¶graphParagraphCmd.label;" value="p"/> + <menuitem id="toolbarmenu_h1" label="&heading1Cmd.label;" value="h1"/> + <menuitem id="toolbarmenu_h2" label="&heading2Cmd.label;" value="h2"/> + <menuitem id="toolbarmenu_h3" label="&heading3Cmd.label;" value="h3"/> + <menuitem id="toolbarmenu_h4" label="&heading4Cmd.label;" value="h4"/> + <menuitem id="toolbarmenu_h5" label="&heading5Cmd.label;" value="h5"/> + <menuitem id="toolbarmenu_h6" label="&heading6Cmd.label;" value="h6"/> + <menuitem id="toolbarmenu_address" label="¶graphAddressCmd.label;" value="address"/> + <menuitem id="toolbarmenu_pre" label="¶graphPreformatCmd.label;" value="pre"/> + </menupopup> + </menulist> + + <!-- "value" are HTML tagnames, don't translate --> + <menulist id="FontFaceSelect" + class="toolbar-focustarget" + oncommand="doStatefulCommand('cmd_fontFace', event.target.value)" + crop="center" + sizetopopup="pref" + tooltiptext="&FontFaceSelect.tooltip;" + observes="cmd_renderedHTMLEnabler"> + <menupopup id="FontFacePopup"> + <menuitem id="toolbarmenu_fontVarWidth" label="&fontVarWidth.label;" value=""/> + <menuitem id="toolbarmenu_fontFixedWidth" label="&fontFixedWidth.label;" value="monospace"/> + <menuseparator id="toolbarmenuAfterGenericFontsSeparator"/> + <menuitem id="toolbarmenu_fontHelvetica" label="&fontHelvetica.label;" + value="Helvetica, Arial, sans-serif" + value_parsed="helvetica,arial,sans-serif"/> + <menuitem id="toolbarmenu_fontTimes" label="&fontTimes.label;" + value="Times New Roman, Times, serif" + value_parsed="times new roman,times,serif"/> + <menuitem id="toolbarmenu_fontCourier" label="&fontCourier.label;" + value="Courier New, Courier, monospace" + value_parsed="courier new,courier,monospace"/> + <menuseparator id="toolbarmenuAfterDefaultFontsSeparator" + class="fontFaceMenuAfterDefaultFonts"/> + <menuseparator id="toolbarmenuAfterUsedFontsSeparator" + class="fontFaceMenuAfterUsedFonts" + hidden="true"/> + <!-- Local font face items added here by initLocalFontFaceMenu() --> + </menupopup> + </menulist> + + <toolbaritem id="color-buttons-container" + class="formatting-button" + align="center"> + <stack id="ColorButtons"> + <box class="color-button" id="BackgroundColorButton" + onclick="if (!this.hasAttribute('disabled') || this.getAttribute('disabled') != 'true') { EditorSelectColor('', event); }" + tooltiptext="&BackgroundColorButton.tooltip;" + observes="cmd_backgroundColor" + oncommand="/* See MsgComposeCommands.js::updateAllItems for why this attribute is needed here. */"/> + <box class="color-button" id="TextColorButton" + onclick="if (!this.hasAttribute('disabled') || this.getAttribute('disabled') != 'true') { EditorSelectColor('Text', event); }" + tooltiptext="&TextColorButton.tooltip;" + observes="cmd_fontColor" + oncommand="/* See MsgComposeCommands.js::updateAllItems for why this attribute is needed here. */"/> + </stack> + </toolbaritem> + + <toolbarbutton id="AbsoluteFontSizeButton" + class="formatting-button" + tooltiptext="&absoluteFontSizeToolbarCmd.tooltip;" + type="menu" + observes="cmd_renderedHTMLEnabler"> + <menupopup id="AbsoluteFontSizeButtonPopup" + onpopupshowing="initFontSizeMenu(this);" + oncommand="setFontSize(event)"> + <menuitem id="toobarmenu_fontSize_x-small" + label="&size-tinyCmd.label;" + type="radio" name="fontSize" + value="1"/> + <menuitem id="toobarmenu_fontSize_small" + label="&size-smallCmd.label;" + type="radio" name="fontSize" + value="2"/> + <menuitem id="toobarmenu_fontSize_medium" + label="&size-mediumCmd.label;" + type="radio" name="fontSize" + value="3"/> + <menuitem id="toobarmenu_fontSize_large" + label="&size-largeCmd.label;" + type="radio" name="fontSize" + value="4"/> + <menuitem id="toobarmenu_fontSize_x-large" + label="&size-extraLargeCmd.label;" + type="radio" name="fontSize" + value="5"/> + <menuitem id="toobarmenu_fontSize_xx-large" + label="&size-hugeCmd.label;" + type="radio" name="fontSize" + value="6"/> + </menupopup> + </toolbarbutton> + + <toolbarbutton id="DecreaseFontSizeButton" + class="formatting-button" + tooltiptext="&decreaseFontSizeToolbarCmd.tooltip;" + observes="cmd_decreaseFontStep"/> + + <toolbarbutton id="IncreaseFontSizeButton" + class="formatting-button" + tooltiptext="&increaseFontSizeToolbarCmd.tooltip;" + observes="cmd_increaseFontStep"/> + + <toolbarseparator class="toolbarseparator-standard"/> + + <toolbarbutton id="boldButton" + class="formatting-button" + tooltiptext="&boldToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_bold"/> + + <toolbarbutton id="italicButton" + class="formatting-button" + tooltiptext="&italicToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_italic"/> + + <toolbarbutton id="underlineButton" + class="formatting-button" + tooltiptext="&underlineToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_underline"/> + + <toolbarseparator class="toolbarseparator-standard"/> + + <toolbarbutton id="removeStylingButton" + class="formatting-button" + data-l10n-id="compose-tool-button-remove-text-styling" + observes="cmd_removeStyles"/> + + <toolbarseparator class="toolbarseparator-standard"/> + + <toolbarbutton id="ulButton" + class="formatting-button" + tooltiptext="&bulletListToolbarCmd.tooltip;" + type="radio" + group="lists" + autoCheck="false" + observes="cmd_ul"/> + + <toolbarbutton id="olButton" + class="formatting-button" + tooltiptext="&numberListToolbarCmd.tooltip;" + type="radio" + group="lists" + autoCheck="false" + observes="cmd_ol"/> + + <toolbarbutton id="outdentButton" + class="formatting-button" + tooltiptext="&outdentToolbarCmd.tooltip;" + observes="cmd_outdent"/> + + <toolbarbutton id="indentButton" + class="formatting-button" + tooltiptext="&indentToolbarCmd.tooltip;" + observes="cmd_indent"/> + + <toolbarseparator class="toolbarseparator-standard"/> + + <toolbarbutton id="AlignPopupButton" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&AlignPopupButton.tooltip;" + observes="cmd_align"> + <menupopup id="AlignPopup"> + <menuitem id="AlignLeftItem" class="menuitem-iconic" label="&alignLeft.label;" + oncommand="doStatefulCommand('cmd_align', 'left')" + tooltiptext="&alignLeftButton.tooltip;" /> + <menuitem id="AlignCenterItem" class="menuitem-iconic" label="&alignCenter.label;" + oncommand="doStatefulCommand('cmd_align', 'center')" + tooltiptext="&alignCenterButton.tooltip;" /> + <menuitem id="AlignRightItem" class="menuitem-iconic" label="&alignRight.label;" + oncommand="doStatefulCommand('cmd_align', 'right')" + tooltiptext="&alignRightButton.tooltip;" /> + <menuitem id="AlignJustifyItem" class="menuitem-iconic" label="&alignJustify.label;" + oncommand="doStatefulCommand('cmd_align', 'justify')" + tooltiptext="&alignJustifyButton.tooltip;" /> + </menupopup> + </toolbarbutton> + + <!-- InsertPopupButton is used by messengercompose.xhtml --> + <toolbarbutton id="InsertPopupButton" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&InsertPopupButton.tooltip;" + observes="cmd_renderedHTMLEnabler"> + <menupopup id="InsertPopup"> + <menuitem id="InsertLinkItem" class="menuitem-iconic" observes="cmd_link" + oncommand="goDoCommand('cmd_link')" label="&linkToolbarCmd.label;" + tooltiptext="&linkToolbarCmd.tooltip;" /> + <menuitem id="InsertAnchorItem" class="menuitem-iconic" observes="cmd_anchor" + oncommand="goDoCommand('cmd_anchor')" label="&anchorToolbarCmd.label;" + tooltiptext="&anchorToolbarCmd.tooltip;" /> + <menuitem id="InsertImageItem" class="menuitem-iconic" observes="cmd_image" + oncommand="goDoCommand('cmd_image')" label="&imageToolbarCmd.label;" + tooltiptext="&imageToolbarCmd.tooltip;" /> + <menuitem id="InsertHRuleItem" class="menuitem-iconic" observes="cmd_hline" + oncommand="goDoCommand('cmd_hline')" label="&hruleToolbarCmd.label;" + tooltiptext="&hruleToolbarCmd.tooltip;" /> + <menuitem id="InsertTableItem" class="menuitem-iconic" observes="cmd_table" + oncommand="goDoCommand('cmd_table')" label="&tableToolbarCmd.label;" + tooltiptext="&tableToolbarCmd.tooltip;" /> + </menupopup> + </toolbarbutton> + + <toolbarbutton id="smileButtonMenu" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&SmileButton.tooltip;" + observes="cmd_smiley"> + <menupopup id="smileyPopup" class="no-icon-menupopup"> + <menuitem id="smileySmile" class="menuitem-iconic" + label="🙂 &smiley1Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🙂')"/> + <menuitem id="smileyFrown" class="menuitem-iconic" + label="🙁 &smiley2Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🙁')"/> + <menuitem id="smileyWink" class="menuitem-iconic" + label="😉 &smiley3Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😉')"/> + <menuitem id="smileyTongue" class="menuitem-iconic" + label="😛 &smiley4Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😛')"/> + <menuitem id="smileyLaughing" class="menuitem-iconic" + label="😂 &smiley5Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😂')"/> + <menuitem id="smileyEmbarassed" class="menuitem-iconic" + label="😳 &smiley6Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😳')"/> + <menuitem id="smileyUndecided" class="menuitem-iconic" + label="😕 &smiley7Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😕')"/> + <menuitem id="smileySurprise" class="menuitem-iconic" + label="😮 &smiley8Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😮')"/> + <menuitem id="smileyKiss" class="menuitem-iconic" + label="😘 &smiley9Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😘')"/> + <menuitem id="smileyYell" class="menuitem-iconic" + label="😠 &smiley10Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😠')"/> + <menuitem id="smileyCool" class="menuitem-iconic" + label="😎 &smiley11Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😎')"/> + <menuitem id="smileyMoney" class="menuitem-iconic" + label="🤑 &smiley12Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🤑')"/> + <menuitem id="smileyFoot" class="menuitem-iconic" + label="😬 &smiley13Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😬')"/> + <menuitem id="smileyInnocent" class="menuitem-iconic" + label="😇 &smiley14Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😇')"/> + <menuitem id="smileyCry" class="menuitem-iconic" + label="😭 &smiley15Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😭')"/> + <menuitem id="smileySealed" class="menuitem-iconic" + label="🤐 &smiley16Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🤐')"/> + </menupopup> + </toolbarbutton> diff --git a/comm/mail/components/compose/content/editor.js b/comm/mail/components/compose/content/editor.js new file mode 100644 index 0000000000..7535ecb0d1 --- /dev/null +++ b/comm/mail/components/compose/content/editor.js @@ -0,0 +1,2392 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../../../../../toolkit/content/viewZoomOverlay.js */ +/* import-globals-from ../../../base/content/globalOverlay.js */ +/* import-globals-from ComposerCommands.js */ +/* import-globals-from editorUtilities.js */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +/* Main Composer window UI control */ + +var gComposerWindowControllerID = 0; +var prefAuthorString = ""; + +var kDisplayModeNormal = 0; +var kDisplayModeAllTags = 1; +var kDisplayModeSource = 2; +var kDisplayModePreview = 3; + +const kDisplayModeMenuIDs = [ + "viewNormalMode", + "viewAllTagsMode", + "viewSourceMode", + "viewPreviewMode", +]; +const kDisplayModeTabIDS = [ + "NormalModeButton", + "TagModeButton", + "SourceModeButton", + "PreviewModeButton", +]; +const kNormalStyleSheet = "chrome://messenger/skin/shared/editorContent.css"; +const kContentEditableStyleSheet = "resource://gre/res/contenteditable.css"; + +var kTextMimeType = "text/plain"; +var kHTMLMimeType = "text/html"; +var kXHTMLMimeType = "application/xhtml+xml"; + +var gPreviousNonSourceDisplayMode = 1; +var gEditorDisplayMode = -1; +var gDocWasModified = false; // Check if clean document, if clean then unload when user "Opens" +var gContentWindow = 0; +var gSourceContentWindow = 0; +var gSourceTextEditor = null; +var gContentWindowDeck; +var gFormatToolbar; +var gFormatToolbarHidden = false; +var gViewFormatToolbar; +var gChromeState; +var gColorObj = { + LastTextColor: "", + LastBackgroundColor: "", + LastHighlightColor: "", + Type: "", + SelectedType: "", + NoDefault: false, + Cancel: false, + HighlightColor: "", + BackgroundColor: "", + PageColor: "", + TextColor: "", + TableColor: "", + CellColor: "", +}; +var gDefaultTextColor = ""; +var gDefaultBackgroundColor = ""; +var gCSSPrefListener; +var gEditorToolbarPrefListener; +var gReturnInParagraphPrefListener; +var gLocalFonts = null; + +var gLastFocusNode = null; +var gLastFocusNodeWasSelected = false; + +// These must be kept in synch with the XUL <options> lists +var gFontSizeNames = [ + "xx-small", + "x-small", + "small", + "medium", + "large", + "x-large", + "xx-large", +]; + +var kUseCssPref = "editor.use_css"; +var kCRInParagraphsPref = "editor.CR_creates_new_p"; + +// This should be called by all editor users when they close their window. +function EditorCleanup() { + SwitchInsertCharToAnotherEditorOrClose(); +} + +/** @implements {nsIDocumentStateListener} */ +var DocumentReloadListener = { + NotifyDocumentWillBeDestroyed() {}, + + NotifyDocumentStateChanged(isNowDirty) { + var editor = GetCurrentEditor(); + try { + // unregister the listener to prevent multiple callbacks + editor.removeDocumentStateListener(DocumentReloadListener); + + var charset = editor.documentCharacterSet; + + // update the META charset with the current presentation charset + editor.documentCharacterSet = charset; + } catch (e) {} + }, +}; + +// implements nsIObserver +var gEditorDocumentObserver = { + observe(aSubject, aTopic, aData) { + // Should we allow this even if NOT the focused editor? + var commandManager = GetCurrentCommandManager(); + if (commandManager != aSubject) { + return; + } + + var editor = GetCurrentEditor(); + switch (aTopic) { + case "obs_documentCreated": + // Just for convenience + gContentWindow = window.content; + + // Get state to see if document creation succeeded + var params = newCommandParams(); + if (!params) { + return; + } + + try { + commandManager.getCommandState(aTopic, gContentWindow, params); + var errorStringId = 0; + var editorStatus = params.getLongValue("state_data"); + if (!editor && editorStatus == Ci.nsIEditingSession.eEditorOK) { + dump( + "\n ****** NO EDITOR BUT NO EDITOR ERROR REPORTED ******* \n\n" + ); + editorStatus = Ci.nsIEditingSession.eEditorErrorUnknown; + } + + switch (editorStatus) { + case Ci.nsIEditingSession.eEditorErrorCantEditFramesets: + errorStringId = "CantEditFramesetMsg"; + break; + case Ci.nsIEditingSession.eEditorErrorCantEditMimeType: + errorStringId = "CantEditMimeTypeMsg"; + break; + case Ci.nsIEditingSession.eEditorErrorUnknown: + errorStringId = "CantEditDocumentMsg"; + break; + // Note that for "eEditorErrorFileNotFound, + // network code popped up an alert dialog, so we don't need to + } + if (errorStringId) { + Services.prompt.alert(window, "", GetString(errorStringId)); + } + } catch (e) { + dump("EXCEPTION GETTING obs_documentCreated state " + e + "\n"); + } + + // We have a bad editor -- nsIEditingSession will rebuild an editor + // with a blank page, so simply abort here + if (editorStatus) { + return; + } + + if (!("InsertCharWindow" in window)) { + window.InsertCharWindow = null; + } + + let domWindowUtils = + GetCurrentEditorElement().contentWindow.windowUtils; + // And extra styles for showing anchors, table borders, smileys, etc. + domWindowUtils.loadSheetUsingURIString( + kNormalStyleSheet, + domWindowUtils.AGENT_SHEET + ); + + // Remove contenteditable stylesheets if they were applied by the + // editingSession. + domWindowUtils.removeSheetUsingURIString( + kContentEditableStyleSheet, + domWindowUtils.AGENT_SHEET + ); + + // Add mouse click watcher if right type of editor + if (IsHTMLEditor()) { + // Force color widgets to update + onFontColorChange(); + onBackgroundColorChange(); + } + break; + + case "cmd_setDocumentModified": + window.updateCommands("save"); + break; + + case "obs_documentWillBeDestroyed": + dump("obs_documentWillBeDestroyed notification\n"); + break; + + case "obs_documentLocationChanged": + // Ignore this when editor doesn't exist, + // which happens once when page load starts + if (editor) { + try { + editor.updateBaseURL(); + } catch (e) { + dump(e); + } + } + break; + + case "cmd_bold": + // Update all style items + // cmd_bold is a proxy; see EditorSharedStartup (above) for details + window.updateCommands("style"); + window.updateCommands("undo"); + break; + } + }, +}; + +function SetFocusOnStartup() { + gContentWindow.focus(); +} + +function EditorLoadUrl(url) { + try { + if (url) { + let loadURIOptions = { + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }; + GetCurrentEditorElement().webNavigation.fixupAndLoadURIString( + url, + loadURIOptions + ); + } + } catch (e) { + dump(" EditorLoadUrl failed: " + e + "\n"); + } +} + +// This should be called by all Composer types +function EditorSharedStartup() { + // Just for convenience + gContentWindow = window.content; + + // Disable DNS Prefetching on the docshell - we don't need it for composer + // type windows. + GetCurrentEditorElement().docShell.allowDNSPrefetch = false; + + let messageEditorBrowser = GetCurrentEditorElement(); + messageEditorBrowser.addEventListener( + "DoZoomEnlargeBy10", + () => { + ZoomManager.scrollZoomEnlarge(messageEditorBrowser); + }, + true + ); + messageEditorBrowser.addEventListener( + "DoZoomReduceBy10", + () => { + ZoomManager.scrollReduceEnlarge(messageEditorBrowser); + }, + true + ); + + // Set up the mime type and register the commands. + if (IsHTMLEditor()) { + SetupHTMLEditorCommands(); + } else { + SetupTextEditorCommands(); + } + + // add observer to be called when document is really done loading + // and is modified + // Note: We're really screwed if we fail to install this observer! + try { + var commandManager = GetCurrentCommandManager(); + commandManager.addCommandObserver( + gEditorDocumentObserver, + "obs_documentCreated" + ); + commandManager.addCommandObserver( + gEditorDocumentObserver, + "cmd_setDocumentModified" + ); + commandManager.addCommandObserver( + gEditorDocumentObserver, + "obs_documentWillBeDestroyed" + ); + commandManager.addCommandObserver( + gEditorDocumentObserver, + "obs_documentLocationChanged" + ); + + // Until nsIControllerCommandGroup-based code is implemented, + // we will observe just the bold command to trigger update of + // all toolbar style items + commandManager.addCommandObserver(gEditorDocumentObserver, "cmd_bold"); + } catch (e) { + dump(e); + } + + var isMac = AppConstants.platform == "macosx"; + + // Set platform-specific hints for how to select cells + // Mac uses "Cmd", all others use "Ctrl" + var tableKey = GetString(isMac ? "XulKeyMac" : "TableSelectKey"); + var dragStr = tableKey + GetString("Drag"); + var clickStr = tableKey + GetString("Click"); + + var delStr = GetString(isMac ? "Clear" : "Del"); + + SafeSetAttribute("menu_SelectCell", "acceltext", clickStr); + SafeSetAttribute("menu_SelectRow", "acceltext", dragStr); + SafeSetAttribute("menu_SelectColumn", "acceltext", dragStr); + SafeSetAttribute("menu_SelectAllCells", "acceltext", dragStr); + // And add "Del" or "Clear" + SafeSetAttribute("menu_DeleteCellContents", "acceltext", delStr); + + // Set text for indent, outdent keybinding + + // hide UI that we don't have components for + RemoveInapplicableUIElements(); + + // Use browser colors as initial values for editor's default colors + var BrowserColors = GetDefaultBrowserColors(); + if (BrowserColors) { + gDefaultTextColor = BrowserColors.TextColor; + gDefaultBackgroundColor = BrowserColors.BackgroundColor; + } + + // For new window, no default last-picked colors + gColorObj.LastTextColor = ""; + gColorObj.LastBackgroundColor = ""; + gColorObj.LastHighlightColor = ""; +} + +function SafeSetAttribute(nodeID, attributeName, attributeValue) { + var theNode = document.getElementById(nodeID); + if (theNode) { + theNode.setAttribute(attributeName, attributeValue); + } +} + +async function CheckAndSaveDocument(command, allowDontSave) { + var document; + try { + // if we don't have an editor or an document, bail + var editor = GetCurrentEditor(); + document = editor.document; + if (!document) { + return true; + } + } catch (e) { + return true; + } + + if (!IsDocumentModified() && !IsHTMLSourceChanged()) { + return true; + } + + // call window.focus, since we need to pop up a dialog + // and therefore need to be visible (to prevent user confusion) + top.document.commandDispatcher.focusedWindow.focus(); + + var strID; + switch (command) { + case "cmd_close": + strID = "BeforeClosing"; + break; + } + + var reasonToSave = strID ? GetString(strID) : ""; + + var title = document.title || GetString("untitledDefaultFilename"); + + var dialogTitle = GetString("SaveDocument"); + var dialogMsg = GetString("SaveFilePrompt"); + dialogMsg = dialogMsg + .replace(/%title%/, title) + .replace(/%reason%/, reasonToSave); + + let result = { value: 0 }; + let promptFlags = + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1; + let button1Title = null; + let button3Title = null; + + promptFlags += + Services.prompt.BUTTON_TITLE_SAVE * Services.prompt.BUTTON_POS_0; + + // If allowing "Don't..." button, add that + if (allowDontSave) { + promptFlags += + Services.prompt.BUTTON_TITLE_DONT_SAVE * Services.prompt.BUTTON_POS_2; + } + + result = Services.prompt.confirmEx( + window, + dialogTitle, + dialogMsg, + promptFlags, + button1Title, + null, + button3Title, + null, + { value: 0 } + ); + + if (result == 0) { + // Save to local disk + return SaveDocument(false, false, editor.contentsMIMEType); + } + + if (result == 2) { + // "Don't Save" + return true; + } + + // Default or result == 1 (Cancel) + return false; +} + +// --------------------------- Text style --------------------------- + +function editorSetParagraphState(state) { + if (state === "") { + // Corresponds to body text. Has no corresponding formatBlock value. + goDoCommandParams("cmd_paragraphState", ""); + } else { + GetCurrentEditor().document.execCommand("formatBlock", false, state); + } + document.getElementById("cmd_paragraphState").setAttribute("state", state); + onParagraphFormatChange(); +} + +function onParagraphFormatChange() { + let paraMenuList = document.getElementById("ParagraphSelect"); + if (!paraMenuList) { + return; + } + + var commandNode = document.getElementById("cmd_paragraphState"); + var state = commandNode.getAttribute("state"); + + // force match with "normal" + if (state == "body") { + state = ""; + } + + if (state == "mixed") { + // Selection is the "mixed" ( > 1 style) state + paraMenuList.selectedItem = null; + paraMenuList.setAttribute("label", GetString("Mixed")); + } else { + var menuPopup = document.getElementById("ParagraphPopup"); + for (let menuItem of menuPopup.children) { + if (menuItem.value === state) { + paraMenuList.selectedItem = menuItem; + break; + } + } + } +} + +function editorRemoveTextStyling() { + GetCurrentEditor().document.execCommand("removeFormat", false, null); + // After removing the formatting, update the full styling command set. + window.updateCommands("style"); +} + +/** + * Selects the current font face in the menulist. + */ +function onFontFaceChange() { + let fontFaceMenuList = document.getElementById("FontFaceSelect"); + var commandNode = document.getElementById("cmd_fontFace"); + var editorFont = commandNode.getAttribute("state"); + + // Strip quotes in font names. Experiments have shown that we only + // ever get double quotes around the font name, never single quotes, + // even if they were in the HTML source. Also single or double + // quotes within the font name are never returned. + editorFont = editorFont.replace(/"/g, ""); + + switch (editorFont) { + case "mixed": + // Selection is the "mixed" ( > 1 style) state. + fontFaceMenuList.selectedItem = null; + fontFaceMenuList.setAttribute("label", GetString("Mixed")); + return; + case "": + case "serif": + case "sans-serif": + // Generic variable width. + fontFaceMenuList.selectedIndex = 0; + return; + case "tt": + case "monospace": + // Generic fixed width. + fontFaceMenuList.selectedIndex = 1; + return; + default: + } + + let menuPopup = fontFaceMenuList.menupopup; + let menuItems = menuPopup.children; + + const genericFamilies = [ + "serif", + "sans-serif", + "monospace", + "fantasy", + "cursive", + ]; + // Bug 1139524: Normalise before we compare: Make it lower case + // and replace ", " with "," so that entries like + // "Helvetica, Arial, sans-serif" are always recognised correctly + let editorFontToLower = editorFont.toLowerCase().replace(/, /g, ","); + let foundFont = null; + let exactMatch = false; + let usedFontsSep = menuPopup.querySelector( + "menuseparator.fontFaceMenuAfterUsedFonts" + ); + let editorFontOptions = editorFontToLower.split(","); + let editorOptionsCount = editorFontOptions.length; + let matchedFontIndex = editorOptionsCount; // initialise to high invalid value + + // The font menu has this structure: + // 0: Variable Width + // 1: Fixed Width + // 2: Separator + // 3: Helvetica, Arial (stored as Helvetica, Arial, sans-serif) + // 4: Times (stored as Times New Roman, Times, serif) + // 5: Courier (stored as Courier New, Courier, monospace) + // 6: Separator, "menuseparator.fontFaceMenuAfterDefaultFonts" + // from 7: Used Font Section (for quick selection) + // followed by separator, "menuseparator.fontFaceMenuAfterUsedFonts" + // followed by all other available fonts. + // The following variable keeps track of where we are when we loop over the menu. + let afterUsedFontSection = false; + + // The menu items not only have "label" and "value", but also some other attributes: + // "value_parsed": Is the toLowerCase() and space-stripped value. + // "value_cache": Is a concatenation of all editor fonts that were ever mapped + // onto this menu item. This is done for optimization. + // "used": This item is in the used font section. + + for (let i = 0; i < menuItems.length; i++) { + let menuItem = menuItems.item(i); + if ( + menuItem.hasAttribute("label") && + menuItem.hasAttribute("value_parsed") + ) { + // The element seems to represent a font <menuitem>. + let fontMenuValue = menuItem.getAttribute("value_parsed"); + if ( + fontMenuValue == editorFontToLower || + (menuItem.hasAttribute("value_cache") && + menuItem + .getAttribute("value_cache") + .split("|") + .includes(editorFontToLower)) + ) { + // This menuitem contains the font we are looking for. + foundFont = menuItem; + exactMatch = true; + break; + } else if (editorOptionsCount > 1 && afterUsedFontSection) { + // Once we are in the list of all other available fonts, + // we will find the one that best matches one of the options. + let matchPos = editorFontOptions.indexOf(fontMenuValue); + if (matchPos >= 0 && matchPos < matchedFontIndex) { + // This menu font comes earlier in the list of options, + // so prefer it. + matchedFontIndex = matchPos; + foundFont = menuItem; + // If we matched the first option, we don't need to look for + // a better match. + if (matchPos == 0) { + break; + } + } + } + } else if (menuItem == usedFontsSep) { + // Some other element type. + // We have now passed the section of used fonts and are now in the list of all. + afterUsedFontSection = true; + } + } + + if (foundFont) { + let defaultFontsSep = menuPopup.querySelector( + "menuseparator.fontFaceMenuAfterDefaultFonts" + ); + if (exactMatch) { + if (afterUsedFontSection) { + // Copy the matched font into the section of used fonts. + // We insert after the separator following the default fonts, + // so right at the beginning of the used fonts section. + let copyItem = foundFont.cloneNode(true); + menuPopup.insertBefore(copyItem, defaultFontsSep.nextElementSibling); + usedFontsSep.hidden = false; + foundFont = copyItem; + foundFont.setAttribute("used", "true"); + } + } else { + // Keep only the found font and generic families in the font string. + editorFont = editorFont + .replace(/, /g, ",") + .split(",") + .filter( + font => + font.toLowerCase() == foundFont.getAttribute("value_parsed") || + genericFamilies.includes(font) + ) + .join(","); + + // Check if such an item is already in the used font section. + if (afterUsedFontSection) { + foundFont = menuPopup.querySelector( + 'menuitem[used="true"][value_parsed="' + + editorFont.toLowerCase() + + '"]' + ); + } + // If not, create a new entry which will be inserted into that section. + if (!foundFont) { + foundFont = createFontFaceMenuitem(editorFont, editorFont, menuPopup); + } + + // Add the editor font string into the 'cache' attribute in the element + // so we can later find it quickly without building the reduced string again. + let fontCache = ""; + if (foundFont.hasAttribute("value_cache")) { + fontCache = foundFont.getAttribute("value_cache"); + } + foundFont.setAttribute( + "value_cache", + fontCache + "|" + editorFontToLower + ); + + // If we created a new item, set it up and insert. + if (!foundFont.hasAttribute("used")) { + foundFont.setAttribute("used", "true"); + usedFontsSep.hidden = false; + menuPopup.insertBefore(foundFont, defaultFontsSep.nextElementSibling); + } + } + } else { + // The editor encountered a font that is not installed on this system. + // Add it to the font menu now, in the used-fonts section right at the + // bottom before the separator of the section. + let fontLabel = GetFormattedString("NotInstalled", editorFont); + foundFont = createFontFaceMenuitem(fontLabel, editorFont, menuPopup); + foundFont.setAttribute("used", "true"); + usedFontsSep.hidden = false; + menuPopup.insertBefore(foundFont, usedFontsSep); + } + fontFaceMenuList.selectedItem = foundFont; +} + +/** + * Changes the font size for the selection or at the insertion point. This + * requires an integer from 1-7 as a value argument (x-small - xxx-large) + * + * @param {"1"|"2"|"3"|"4"|"5"|"6"|"7"} size - The font size. + */ +function EditorSetFontSize(size) { + // For normal/medium size (that is 3), we clear size. + if (size == "3") { + EditorRemoveTextProperty("font", "size"); + // Also remove big and small, + // else it will seem like size isn't changing correctly + EditorRemoveTextProperty("small", ""); + EditorRemoveTextProperty("big", ""); + } else { + GetCurrentEditor().document.execCommand("fontSize", false, size); + } + // Enable or Disable the toolbar buttons according to the font size. + goUpdateCommand("cmd_decreaseFontStep"); + goUpdateCommand("cmd_increaseFontStep"); + gContentWindow.focus(); +} + +function initFontFaceMenu(menuPopup) { + initLocalFontFaceMenu(menuPopup); + + if (menuPopup) { + var children = menuPopup.children; + if (!children) { + return; + } + + var mixed = { value: false }; + var editorFont = GetCurrentEditor().getFontFaceState(mixed); + + // Strip quotes in font names. Experiments have shown that we only + // ever get double quotes around the font name, never single quotes, + // even if they were in the HTML source. Also single or double + // quotes within the font name are never returned. + editorFont = editorFont.replace(/"/g, ""); + + if (!mixed.value) { + switch (editorFont) { + case "": + case "serif": + case "sans-serif": + // Generic variable width. + editorFont = ""; + break; + case "tt": + case "monospace": + // Generic fixed width. + editorFont = "monospace"; + break; + default: + editorFont = editorFont.toLowerCase().replace(/, /g, ","); // bug 1139524 + } + } + + var editorFontOptions = editorFont.split(","); + var matchedOption = editorFontOptions.length; // initialise to high invalid value + for (var i = 0; i < children.length; i++) { + var menuItem = children[i]; + if (menuItem.localName == "menuitem") { + var matchFound = false; + if (!mixed.value) { + var menuFont = menuItem + .getAttribute("value") + .toLowerCase() + .replace(/, /g, ","); + + // First compare the entire font string to match items that contain commas. + if (menuFont == editorFont) { + menuItem.setAttribute("checked", "true"); + break; + } else if (editorFontOptions.length > 1) { + // Next compare the individual options. + var matchPos = editorFontOptions.indexOf(menuFont); + if (matchPos >= 0 && matchPos < matchedOption) { + // This menu font comes earlier in the list of options, + // so prefer it. + menuItem.setAttribute("checked", "true"); + + // If we matched the first option, we don't need to look for + // a better match. + if (matchPos == 0) { + break; + } + + matchedOption = matchPos; + matchFound = true; + } + } + } + + // In case this item doesn't match, make sure we've cleared the checkmark. + if (!matchFound) { + menuItem.removeAttribute("checked"); + } + } + } + } +} + +// Number of fixed font face menuitems, these are: +// Variable Width +// Fixed Width +// ==separator +// Helvetica, Arial +// Times +// Courier +// ==separator +// ==separator +const kFixedFontFaceMenuItems = 8; + +function initLocalFontFaceMenu(menuPopup) { + if (!gLocalFonts) { + // Build list of all local fonts once per editor + try { + var enumerator = Cc["@mozilla.org/gfx/fontenumerator;1"].getService( + Ci.nsIFontEnumerator + ); + gLocalFonts = enumerator.EnumerateAllFonts(); + } catch (e) {} + } + + // Don't use radios for menulists. + let useRadioMenuitems = menuPopup.parentNode.localName == "menu"; + menuPopup.setAttribute("useRadios", useRadioMenuitems); + if (menuPopup.children.length == kFixedFontFaceMenuItems) { + if (gLocalFonts.length == 0) { + menuPopup.querySelector(".fontFaceMenuAfterDefaultFonts").hidden = true; + } + for (let i = 0; i < gLocalFonts.length; ++i) { + // Remove Linux system generic fonts that collide with CSS generic fonts. + if ( + gLocalFonts[i] != "" && + gLocalFonts[i] != "serif" && + gLocalFonts[i] != "sans-serif" && + gLocalFonts[i] != "monospace" + ) { + let itemNode = createFontFaceMenuitem( + gLocalFonts[i], + gLocalFonts[i], + menuPopup + ); + menuPopup.appendChild(itemNode); + } + } + } +} + +/** + * Creates a menuitem element for the font faces menulist. Returns the menuitem + * but does not add it automatically to the menupopup. + * + * @param aFontLabel Label to be displayed for the item. + * @param aFontName The font face value to be used for the item. + * Will be used in <font face="value"> in the edited document. + * @param aMenuPopup The menupopup for which this menuitem is created. + */ +function createFontFaceMenuitem(aFontLabel, aFontName, aMenuPopup) { + let itemNode = document.createXULElement("menuitem"); + itemNode.setAttribute("label", aFontLabel); + itemNode.setAttribute("value", aFontName); + itemNode.setAttribute( + "value_parsed", + aFontName.toLowerCase().replace(/, /g, ",") + ); + itemNode.setAttribute("tooltiptext", aFontLabel); + if (aMenuPopup.getAttribute("useRadios") == "true") { + itemNode.setAttribute("type", "radio"); + itemNode.setAttribute("observes", "cmd_renderedHTMLEnabler"); + } + return itemNode; +} + +/** + * Helper function + * + * @see https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#legacy-font-size-for + */ +function getLegacyFontSize() { + let fontSize = GetCurrentEditor().document.queryCommandValue("fontSize"); + // If one selects all the texts in the editor and deletes it, the editor + // will return null fontSize. We will set it to default value then. + if (!fontSize) { + fontSize = Services.prefs.getCharPref("msgcompose.font_size", "3"); + } + return fontSize; +} + +function initFontSizeMenu(menuPopup) { + if (menuPopup) { + let fontSize = getLegacyFontSize(); + for (let menuitem of menuPopup.children) { + if (menuitem.getAttribute("value") == fontSize) { + menuitem.setAttribute("checked", true); + } + } + } +} + +function onFontColorChange() { + ChangeButtonColor("cmd_fontColor", "TextColorButton", gDefaultTextColor); +} + +function onBackgroundColorChange() { + ChangeButtonColor( + "cmd_backgroundColor", + "BackgroundColorButton", + gDefaultBackgroundColor + ); +} + +/* Helper function that changes the button color. + * commandID - The ID of the command element. + * id - The ID of the button needing to be changed. + * defaultColor - The default color the button gets set to. + */ +function ChangeButtonColor(commandID, id, defaultColor) { + var commandNode = document.getElementById(commandID); + if (commandNode) { + var color = commandNode.getAttribute("state"); + var button = document.getElementById(id); + if (button) { + button.setAttribute("color", color); + + // No color or a mixed color - get color set on page or other defaults. + if (!color || color == "mixed") { + color = defaultColor; + } + + button.style.backgroundColor = color; + } + } +} + +// Call this when user changes text and/or background colors of the page +function UpdateDefaultColors() { + var BrowserColors = GetDefaultBrowserColors(); + var bodyelement = GetBodyElement(); + var defTextColor = gDefaultTextColor; + var defBackColor = gDefaultBackgroundColor; + + if (bodyelement) { + var color = bodyelement.getAttribute("text"); + if (color) { + gDefaultTextColor = color; + } else if (BrowserColors) { + gDefaultTextColor = BrowserColors.TextColor; + } + + color = bodyelement.getAttribute("bgcolor"); + if (color) { + gDefaultBackgroundColor = color; + } else if (BrowserColors) { + gDefaultBackgroundColor = BrowserColors.BackgroundColor; + } + } + + // Trigger update on toolbar + if (defTextColor != gDefaultTextColor) { + goUpdateCommandState("cmd_fontColor"); + onFontColorChange(); + } + if (defBackColor != gDefaultBackgroundColor) { + goUpdateCommandState("cmd_backgroundColor"); + onBackgroundColorChange(); + } +} + +function GetBackgroundElementWithColor() { + var editor = GetCurrentTableEditor(); + if (!editor) { + return null; + } + + gColorObj.Type = ""; + gColorObj.PageColor = ""; + gColorObj.TableColor = ""; + gColorObj.CellColor = ""; + gColorObj.BackgroundColor = ""; + gColorObj.SelectedType = ""; + + var tagNameObj = { value: "" }; + var element; + try { + element = editor.getSelectedOrParentTableElement(tagNameObj, { value: 0 }); + } catch (e) {} + + if (element && tagNameObj && tagNameObj.value) { + gColorObj.BackgroundColor = GetHTMLOrCSSStyleValue( + element, + "bgcolor", + "background-color" + ); + gColorObj.BackgroundColor = ConvertRGBColorIntoHEXColor( + gColorObj.BackgroundColor + ); + if (tagNameObj.value.toLowerCase() == "td") { + gColorObj.Type = "Cell"; + gColorObj.CellColor = gColorObj.BackgroundColor; + + // Get any color that might be on parent table + var table = GetParentTable(element); + gColorObj.TableColor = GetHTMLOrCSSStyleValue( + table, + "bgcolor", + "background-color" + ); + gColorObj.TableColor = ConvertRGBColorIntoHEXColor(gColorObj.TableColor); + } else { + gColorObj.Type = "Table"; + gColorObj.TableColor = gColorObj.BackgroundColor; + } + gColorObj.SelectedType = gColorObj.Type; + } else { + let IsCSSPrefChecked = Services.prefs.getBoolPref(kUseCssPref); + if (IsCSSPrefChecked && IsHTMLEditor()) { + let selection = editor.selection; + if (selection) { + element = selection.focusNode; + while (!editor.nodeIsBlock(element)) { + element = element.parentNode; + } + } else { + element = GetBodyElement(); + } + } else { + element = GetBodyElement(); + } + if (element) { + gColorObj.Type = "Page"; + gColorObj.BackgroundColor = GetHTMLOrCSSStyleValue( + element, + "bgcolor", + "background-color" + ); + if (gColorObj.BackgroundColor == "") { + gColorObj.BackgroundColor = "transparent"; + } else { + gColorObj.BackgroundColor = ConvertRGBColorIntoHEXColor( + gColorObj.BackgroundColor + ); + } + gColorObj.PageColor = gColorObj.BackgroundColor; + } + } + return element; +} + +/* eslint-disable complexity */ +function EditorSelectColor(colorType, mouseEvent) { + var editor = GetCurrentEditor(); + if (!editor || !gColorObj) { + return; + } + + // Shift + mouse click automatically applies last color, if available + var useLastColor = mouseEvent + ? mouseEvent.button == 0 && mouseEvent.shiftKey + : false; + var element; + var table; + var currentColor = ""; + var commandNode; + + if (!colorType) { + colorType = ""; + } + + if (colorType == "Text") { + gColorObj.Type = colorType; + + // Get color from command node state + commandNode = document.getElementById("cmd_fontColor"); + currentColor = commandNode.getAttribute("state"); + currentColor = ConvertRGBColorIntoHEXColor(currentColor); + gColorObj.TextColor = currentColor; + + if (useLastColor && gColorObj.LastTextColor) { + gColorObj.TextColor = gColorObj.LastTextColor; + } else { + useLastColor = false; + } + } else if (colorType == "Highlight") { + gColorObj.Type = colorType; + + // Get color from command node state + commandNode = document.getElementById("cmd_highlight"); + currentColor = commandNode.getAttribute("state"); + currentColor = ConvertRGBColorIntoHEXColor(currentColor); + gColorObj.HighlightColor = currentColor; + + if (useLastColor && gColorObj.LastHighlightColor) { + gColorObj.HighlightColor = gColorObj.LastHighlightColor; + } else { + useLastColor = false; + } + } else { + element = GetBackgroundElementWithColor(); + if (!element) { + return; + } + + // Get the table if we found a cell + if (gColorObj.Type == "Table") { + table = element; + } else if (gColorObj.Type == "Cell") { + table = GetParentTable(element); + } + + // Save to avoid resetting if not necessary + currentColor = gColorObj.BackgroundColor; + + if (colorType == "TableOrCell" || colorType == "Cell") { + if (gColorObj.Type == "Cell") { + gColorObj.Type = colorType; + } else if (gColorObj.Type != "Table") { + return; + } + } else if (colorType == "Table" && gColorObj.Type == "Page") { + return; + } + + if (colorType == "" && gColorObj.Type == "Cell") { + // Using empty string for requested type means + // we can let user select cell or table + gColorObj.Type = "TableOrCell"; + } + + if (useLastColor && gColorObj.LastBackgroundColor) { + gColorObj.BackgroundColor = gColorObj.LastBackgroundColor; + } else { + useLastColor = false; + } + } + // Save the type we are really requesting + colorType = gColorObj.Type; + + if (!useLastColor) { + // Avoid the JS warning + gColorObj.NoDefault = false; + + // Launch the ColorPicker dialog + // TODO: Figure out how to position this under the color buttons on the toolbar + window.openDialog( + "chrome://messenger/content/messengercompose/EdColorPicker.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "", + gColorObj + ); + + // User canceled the dialog + if (gColorObj.Cancel) { + return; + } + } + + if (gColorObj.Type == "Text") { + if (currentColor != gColorObj.TextColor) { + if (gColorObj.TextColor) { + GetCurrentEditor().document.execCommand( + "foreColor", + false, + gColorObj.TextColor + ); + } else { + EditorRemoveTextProperty("font", "color"); + } + } + // Update the command state (this will trigger color button update) + goUpdateCommandState("cmd_fontColor"); + } else if (gColorObj.Type == "Highlight") { + if (currentColor != gColorObj.HighlightColor) { + if (gColorObj.HighlightColor) { + GetCurrentEditor().document.execCommand( + "backColor", + false, + gColorObj.HighlightColor + ); + } else { + EditorRemoveTextProperty("font", "bgcolor"); + } + } + // Update the command state (this will trigger color button update) + goUpdateCommandState("cmd_highlight"); + } else if (element) { + if (gColorObj.Type == "Table") { + // Set background on a table + // Note that we shouldn't trust "currentColor" because of "TableOrCell" behavior + if (table) { + var bgcolor = table.getAttribute("bgcolor"); + if (bgcolor != gColorObj.BackgroundColor) { + try { + if (gColorObj.BackgroundColor) { + editor.setAttributeOrEquivalent( + table, + "bgcolor", + gColorObj.BackgroundColor, + false + ); + } else { + editor.removeAttributeOrEquivalent(table, "bgcolor", false); + } + } catch (e) {} + } + } + } else if (currentColor != gColorObj.BackgroundColor && IsHTMLEditor()) { + editor.beginTransaction(); + try { + editor.setBackgroundColor(gColorObj.BackgroundColor); + + if (gColorObj.Type == "Page" && gColorObj.BackgroundColor) { + // Set all page colors not explicitly set, + // else you can end up with unreadable pages + // because viewer's default colors may not be same as page author's + var bodyelement = GetBodyElement(); + if (bodyelement) { + var defColors = GetDefaultBrowserColors(); + if (defColors) { + if (!bodyelement.getAttribute("text")) { + editor.setAttributeOrEquivalent( + bodyelement, + "text", + defColors.TextColor, + false + ); + } + + // The following attributes have no individual CSS declaration counterparts + // Getting rid of them in favor of CSS implies CSS rules management + if (!bodyelement.getAttribute("link")) { + editor.setAttribute(bodyelement, "link", defColors.LinkColor); + } + + if (!bodyelement.getAttribute("alink")) { + editor.setAttribute( + bodyelement, + "alink", + defColors.ActiveLinkColor + ); + } + + if (!bodyelement.getAttribute("vlink")) { + editor.setAttribute( + bodyelement, + "vlink", + defColors.VisitedLinkColor + ); + } + } + } + } + } catch (e) {} + + editor.endTransaction(); + } + + goUpdateCommandState("cmd_backgroundColor"); + } + gContentWindow.focus(); +} +/* eslint-enable complexity */ + +function GetParentTable(element) { + var node = element; + while (node) { + if (node.nodeName.toLowerCase() == "table") { + return node; + } + + node = node.parentNode; + } + return node; +} + +function GetParentTableCell(element) { + var node = element; + while (node) { + if ( + node.nodeName.toLowerCase() == "td" || + node.nodeName.toLowerCase() == "th" + ) { + return node; + } + + node = node.parentNode; + } + return node; +} + +function EditorDblClick(event) { + // Only bring up properties if clicked on an element or selected link + let element = event.target; + // We use "href" instead of "a" to not be fooled by named anchor + if (!element) { + try { + element = GetCurrentEditor().getSelectedElement("href"); + } catch (e) {} + } + + // Don't fire for body/p and other block elements. + // It's common that people try to double-click + // to select a word, but the click hits an empty area. + if ( + element && + ![ + "body", + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "div", + "pre", + ].includes(element.nodeName.toLowerCase()) + ) { + goDoCommand("cmd_objectProperties"); + event.preventDefault(); + } +} + +/* TODO: We need an oncreate hook to do enabling/disabling for the + Format menu. There should be code like this for the + object-specific "Properties" item +*/ +// For property dialogs, we want the selected element, +// but will accept a parent link, list, or table cell if inside one +function GetObjectForProperties() { + var editor = GetCurrentEditor(); + if (!editor || !IsHTMLEditor()) { + return null; + } + + var element; + try { + element = editor.getSelectedElement(""); + } catch (e) {} + if (element) { + if (element.namespaceURI == "http://www.w3.org/1998/Math/MathML") { + // If the object is a MathML element, we collapse the selection on it and + // we return its <math> ancestor. Hence the math dialog will be used. + GetCurrentEditor().selection.collapse(element, 0); + } else { + return element; + } + } + + // Find nearest parent of selection anchor node + // that is a link, list, table cell, or table + + var anchorNode; + var node; + try { + anchorNode = editor.selection.anchorNode; + if (anchorNode.firstChild) { + // Start at actual selected node + var offset = editor.selection.anchorOffset; + // Note: If collapsed, offset points to element AFTER caret, + // thus node may be null + node = anchorNode.childNodes.item(offset); + } + if (!node) { + node = anchorNode; + } + } catch (e) {} + + while (node) { + if (node.nodeName) { + var nodeName = node.nodeName.toLowerCase(); + + // Done when we hit the body or #text. + if (nodeName == "body" || nodeName == "#text") { + break; + } + + if ( + (nodeName == "a" && node.href) || + nodeName == "ol" || + nodeName == "ul" || + nodeName == "dl" || + nodeName == "td" || + nodeName == "th" || + nodeName == "table" || + nodeName == "math" + ) { + return node; + } + } + node = node.parentNode; + } + return null; +} + +function UpdateWindowTitle() { + try { + var filename = ""; + var windowTitle = ""; + var title = document.title; + + // Append just the 'leaf' filename to the Doc. Title for the window caption + var docUrl = GetDocumentUrl(); + if (docUrl && !IsUrlAboutBlank(docUrl)) { + var scheme = GetScheme(docUrl); + filename = GetFilename(docUrl); + if (filename) { + windowTitle = " [" + scheme + ":/.../" + filename + "]"; + } + + var fileType = IsHTMLEditor() ? "html" : "text"; + // Save changed title in the recent pages data in prefs + SaveRecentFilesPrefs(title, fileType); + } + + document.title = (title || filename) + windowTitle; + } catch (e) { + dump(e); + } +} + +function SaveRecentFilesPrefs(aTitle, aFileType) { + var curUrl = StripPassword(GetDocumentUrl()); + var historyCount = Services.prefs.getIntPref("editor.history.url_maximum"); + + var titleArray = []; + var urlArray = []; + var typeArray = []; + + if (historyCount && !IsUrlAboutBlank(curUrl) && GetScheme(curUrl) != "data") { + titleArray.push(aTitle); + urlArray.push(curUrl); + typeArray.push(aFileType); + } + + for (let i = 0; i < historyCount && urlArray.length < historyCount; i++) { + let url = Services.prefs.getStringPref("editor.history_url_" + i, ""); + + // Continue if URL pref is missing because + // a URL not found during loading may have been removed + + // Skip over current an "data" URLs + if (url && url != curUrl && GetScheme(url) != "data") { + let title = Services.prefs.getStringPref("editor.history_title_" + i, ""); + let fileType = Services.prefs.getStringPref( + "editor.history_type_" + i, + "" + ); + titleArray.push(title); + urlArray.push(url); + typeArray.push(fileType); + } + } + + // Resave the list back to prefs in the new order + for (let i = 0; i < urlArray.length; i++) { + SetStringPref("editor.history_title_" + i, titleArray[i]); + SetStringPref("editor.history_url_" + i, urlArray[i]); + SetStringPref("editor.history_type_" + i, typeArray[i]); + } +} + +function EditorInitFormatMenu() { + try { + InitObjectPropertiesMenuitem(); + InitRemoveStylesMenuitems( + "removeStylesMenuitem", + "removeLinksMenuitem", + "removeNamedAnchorsMenuitem" + ); + } catch (ex) {} +} + +function InitObjectPropertiesMenuitem() { + // Set strings and enable for the [Object] Properties item + // Note that we directly do the enabling instead of + // using goSetCommandEnabled since we already have the command. + var cmd = document.getElementById("cmd_objectProperties"); + if (!cmd) { + return null; + } + + var element; + var menuStr = GetString("AdvancedProperties"); + var name; + + if (IsEditingRenderedHTML()) { + element = GetObjectForProperties(); + } + + if (element && element.nodeName) { + var objStr = ""; + cmd.removeAttribute("disabled"); + name = element.nodeName.toLowerCase(); + switch (name) { + case "img": + // Check if img is enclosed in link + // (use "href" to not be fooled by named anchor) + try { + if (GetCurrentEditor().getElementOrParentByTagName("href", element)) { + objStr = GetString("ImageAndLink"); + // Return "href" so it is detected as a link. + name = "href"; + } + } catch (e) {} + + if (objStr == "") { + objStr = GetString("Image"); + } + break; + case "hr": + objStr = GetString("HLine"); + break; + case "table": + objStr = GetString("Table"); + break; + case "th": + name = "td"; + // Falls through + case "td": + objStr = GetString("TableCell"); + break; + case "ol": + case "ul": + case "dl": + objStr = GetString("List"); + break; + case "li": + objStr = GetString("ListItem"); + break; + case "form": + objStr = GetString("Form"); + break; + case "input": + var type = element.getAttribute("type"); + if (type && type.toLowerCase() == "image") { + objStr = GetString("InputImage"); + } else { + objStr = GetString("InputTag"); + } + break; + case "textarea": + objStr = GetString("TextArea"); + break; + case "select": + objStr = GetString("Select"); + break; + case "button": + objStr = GetString("Button"); + break; + case "label": + objStr = GetString("Label"); + break; + case "fieldset": + objStr = GetString("FieldSet"); + break; + case "a": + if (element.name) { + objStr = GetString("NamedAnchor"); + name = "anchor"; + } else if (element.href) { + objStr = GetString("Link"); + name = "href"; + } + break; + } + if (objStr) { + menuStr = GetString("ObjectProperties").replace(/%obj%/, objStr); + } + } else { + // We show generic "Properties" string, but disable the command. + cmd.setAttribute("disabled", "true"); + } + cmd.setAttribute("label", menuStr); + cmd.setAttribute("accesskey", GetString("ObjectPropertiesAccessKey")); + return name; +} + +function InitParagraphMenu() { + var mixedObj = { value: null }; + var state; + try { + state = GetCurrentEditor().getParagraphState(mixedObj); + } catch (e) {} + var IDSuffix; + + // PROBLEM: When we get blockquote, it masks other styles contained by it + // We need a separate method to get blockquote state + + // We use "x" as uninitialized paragraph state + if (!state || state == "x") { + // No paragraph container. + IDSuffix = "bodyText"; + } else { + IDSuffix = state; + } + + // Set "radio" check on one item, but... + var menuItem = document.getElementById("menu_" + IDSuffix); + menuItem.setAttribute("checked", "true"); + + // ..."bodyText" is returned if mixed selection, so remove checkmark + if (mixedObj.value) { + menuItem.setAttribute("checked", "false"); + } +} + +function GetListStateString() { + try { + var editor = GetCurrentEditor(); + + var mixedObj = { value: null }; + var hasOL = { value: false }; + var hasUL = { value: false }; + var hasDL = { value: false }; + editor.getListState(mixedObj, hasOL, hasUL, hasDL); + + if (mixedObj.value) { + return "mixed"; + } + if (hasOL.value) { + return "ol"; + } + if (hasUL.value) { + return "ul"; + } + + if (hasDL.value) { + var hasLI = { value: false }; + var hasDT = { value: false }; + var hasDD = { value: false }; + editor.getListItemState(mixedObj, hasLI, hasDT, hasDD); + if (mixedObj.value) { + return "mixed"; + } + if (hasLI.value) { + return "li"; + } + if (hasDT.value) { + return "dt"; + } + if (hasDD.value) { + return "dd"; + } + } + } catch (e) {} + + // return "noList" if we aren't in a list at all + return "noList"; +} + +function InitListMenu() { + if (!IsHTMLEditor()) { + return; + } + + var IDSuffix = GetListStateString(); + + // Set enable state for the "None" menuitem + goSetCommandEnabled("cmd_removeList", IDSuffix != "noList"); + + // Set "radio" check on one item, but... + // we won't find a match if it's "mixed" + var menuItem = document.getElementById("menu_" + IDSuffix); + if (menuItem) { + menuItem.setAttribute("checked", "true"); + } +} + +function GetAlignmentString() { + var mixedObj = { value: null }; + var alignObj = { value: null }; + try { + GetCurrentEditor().getAlignment(mixedObj, alignObj); + } catch (e) {} + + if (mixedObj.value) { + return "mixed"; + } + if (alignObj.value == Ci.nsIHTMLEditor.eLeft) { + return "left"; + } + if (alignObj.value == Ci.nsIHTMLEditor.eCenter) { + return "center"; + } + if (alignObj.value == Ci.nsIHTMLEditor.eRight) { + return "right"; + } + if (alignObj.value == Ci.nsIHTMLEditor.eJustify) { + return "justify"; + } + + // return "left" if we got here + return "left"; +} + +function InitAlignMenu() { + if (!IsHTMLEditor()) { + return; + } + + var IDSuffix = GetAlignmentString(); + + // we won't find a match if it's "mixed" + var menuItem = document.getElementById("menu_" + IDSuffix); + if (menuItem) { + menuItem.setAttribute("checked", "true"); + } +} + +function EditorSetDefaultPrefsAndDoctype() { + var editor = GetCurrentEditor(); + + var domdoc; + try { + domdoc = editor.document; + } catch (e) { + dump(e + "\n"); + } + if (!domdoc) { + dump("EditorSetDefaultPrefsAndDoctype: EDITOR DOCUMENT NOT FOUND\n"); + return; + } + + // Insert a doctype element + // if it is missing from existing doc + if (!domdoc.doctype) { + var newdoctype = domdoc.implementation.createDocumentType( + "HTML", + "-//W3C//DTD HTML 4.01 Transitional//EN", + "" + ); + if (newdoctype) { + domdoc.insertBefore(newdoctype, domdoc.firstChild); + } + } + + // search for head; we'll need this for meta tag additions + let headelement = domdoc.querySelector("head"); + if (!headelement) { + headelement = domdoc.createElement("head"); + domdoc.insertAfter(headelement, domdoc.firstChild); + } + + /* only set default prefs for new documents */ + if (!IsUrlAboutBlank(GetDocumentUrl())) { + return; + } + + // search for author meta tag. + // if one is found, don't do anything. + // if not, create one and make it a child of the head tag + // and set its content attribute to the value of the editor.author preference. + + if (domdoc.querySelector("meta")) { + // we should do charset first since we need to have charset before + // hitting other 8-bit char in other meta tags + // grab charset pref and make it the default charset + var element; + var prefCharsetString = Services.prefs.getCharPref( + "intl.charset.fallback.override" + ); + if (prefCharsetString) { + editor.documentCharacterSet = prefCharsetString; + } + + // let's start by assuming we have an author in case we don't have the pref + + var prefAuthorString = null; + let authorFound = domdoc.querySelector('meta[name="author"]'); + try { + prefAuthorString = Services.prefs.getStringPref("editor.author"); + } catch (ex) {} + if ( + prefAuthorString && + prefAuthorString != 0 && + !authorFound && + headelement + ) { + // create meta tag with 2 attributes + element = domdoc.createElement("meta"); + if (element) { + element.setAttribute("name", "author"); + element.setAttribute("content", prefAuthorString); + headelement.appendChild(element); + } + } + } + + // add title tag if not present + if (headelement && !editor.document.querySelector("title")) { + var titleElement = domdoc.createElement("title"); + if (titleElement) { + headelement.appendChild(titleElement); + } + } + + // find body node + var bodyelement = GetBodyElement(); + if (bodyelement) { + if (Services.prefs.getBoolPref("editor.use_custom_colors")) { + let text_color = Services.prefs.getCharPref("editor.text_color"); + let background_color = Services.prefs.getCharPref( + "editor.background_color" + ); + + // add the color attributes to the body tag. + // and use them for the default text and background colors if not empty + editor.setAttributeOrEquivalent(bodyelement, "text", text_color, true); + gDefaultTextColor = text_color; + editor.setAttributeOrEquivalent( + bodyelement, + "bgcolor", + background_color, + true + ); + gDefaultBackgroundColor = background_color; + bodyelement.setAttribute( + "link", + Services.prefs.getCharPref("editor.link_color") + ); + bodyelement.setAttribute( + "alink", + Services.prefs.getCharPref("editor.active_link_color") + ); + bodyelement.setAttribute( + "vlink", + Services.prefs.getCharPref("editor.followed_link_color") + ); + } + // Default image is independent of Custom colors??? + try { + let background_image = Services.prefs.getCharPref( + "editor.default_background_image" + ); + if (background_image) { + editor.setAttributeOrEquivalent( + bodyelement, + "background", + background_image, + true + ); + } + } catch (e) { + dump("BACKGROUND EXCEPTION: " + e + "\n"); + } + } + // auto-save??? +} + +function GetBodyElement() { + try { + return GetCurrentEditor().rootElement; + } catch (ex) { + dump("no body tag found?!\n"); + // better have one, how can we blow things up here? + } + return null; +} + +// -------------------------------------------------------------------- +function initFontStyleMenu(menuPopup) { + for (var i = 0; i < menuPopup.children.length; i++) { + var menuItem = menuPopup.children[i]; + var theStyle = menuItem.getAttribute("state"); + if (theStyle) { + menuItem.setAttribute("checked", theStyle); + } + } +} + +// ----------------------------------------------------------------------------------- +function IsSpellCheckerInstalled() { + return true; // Always installed. +} + +// ----------------------------------------------------------------------------------- +function IsFindInstalled() { + return ( + "@mozilla.org/embedcomp/rangefind;1" in Cc && + "@mozilla.org/find/find_service;1" in Cc + ); +} + +// ----------------------------------------------------------------------------------- +function RemoveInapplicableUIElements() { + // For items that are in their own menu block, remove associated separator + // (we can't use "hidden" since class="hide-in-IM" CSS rule interferes) + + // if no find, remove find ui + if (!IsFindInstalled()) { + HideItem("menu_find"); + HideItem("menu_findnext"); + HideItem("menu_replace"); + HideItem("menu_find"); + RemoveItem("sep_find"); + } + + // if no spell checker, remove spell checker ui + if (!IsSpellCheckerInstalled()) { + HideItem("spellingButton"); + HideItem("menu_checkspelling"); + RemoveItem("sep_checkspelling"); + } + + // Remove menu items (from overlay shared with HTML editor) in non-HTML. + if (!IsHTMLEditor()) { + HideItem("insertAnchor"); + HideItem("insertImage"); + HideItem("insertHline"); + HideItem("insertTable"); + HideItem("insertHTML"); + HideItem("insertFormMenu"); + HideItem("fileExportToText"); + HideItem("viewFormatToolbar"); + HideItem("viewEditModeToolbar"); + } +} + +function HideItem(id) { + var item = document.getElementById(id); + if (item) { + item.hidden = true; + } +} + +function RemoveItem(id) { + var item = document.getElementById(id); + if (item) { + item.remove(); + } +} + +// Command Updating Strategy: +// Don't update on on selection change, only when menu is displayed, +// with this "oncreate" handler: +function EditorInitTableMenu() { + try { + InitJoinCellMenuitem("menu_JoinTableCells"); + } catch (ex) {} + + // Set enable states for all table commands + goUpdateTableMenuItems(document.getElementById("composerTableMenuItems")); +} + +function InitJoinCellMenuitem(id) { + // Change text on the "Join..." item depending if we + // are joining selected cells or just cell to right + // TODO: What to do about normal selection that crosses + // table border? Try to figure out all cells + // included in the selection? + var menuText; + var menuItem = document.getElementById(id); + if (!menuItem) { + return; + } + + // Use "Join selected cells if there's more than 1 cell selected + var numSelected; + var foundElement; + + try { + var tagNameObj = {}; + var countObj = { value: 0 }; + foundElement = GetCurrentTableEditor().getSelectedOrParentTableElement( + tagNameObj, + countObj + ); + numSelected = countObj.value; + } catch (e) {} + if (foundElement && numSelected > 1) { + menuText = GetString("JoinSelectedCells"); + } else { + menuText = GetString("JoinCellToRight"); + } + + menuItem.setAttribute("label", menuText); + menuItem.setAttribute("accesskey", GetString("JoinCellAccesskey")); +} + +function InitRemoveStylesMenuitems( + removeStylesId, + removeLinksId, + removeNamedAnchorsId +) { + var editor = GetCurrentEditor(); + if (!editor) { + return; + } + + // Change wording of menuitems depending on selection + var stylesItem = document.getElementById(removeStylesId); + var linkItem = document.getElementById(removeLinksId); + + var isCollapsed = editor.selection.isCollapsed; + if (stylesItem) { + stylesItem.setAttribute( + "label", + isCollapsed ? GetString("StopTextStyles") : GetString("RemoveTextStyles") + ); + stylesItem.setAttribute( + "accesskey", + GetString("RemoveTextStylesAccesskey") + ); + } + if (linkItem) { + linkItem.setAttribute( + "label", + isCollapsed ? GetString("StopLinks") : GetString("RemoveLinks") + ); + linkItem.setAttribute("accesskey", GetString("RemoveLinksAccesskey")); + // Note: disabling text style is a pain since there are so many - forget it! + + // Disable if not in a link, but always allow "Remove" + // if selection isn't collapsed since we only look at anchor node + try { + SetElementEnabled( + linkItem, + !isCollapsed || editor.getElementOrParentByTagName("href", null) + ); + } catch (e) {} + } + // Disable if selection is collapsed + SetElementEnabledById(removeNamedAnchorsId, !isCollapsed); +} + +function goUpdateTableMenuItems(commandset) { + var editor = GetCurrentTableEditor(); + if (!editor) { + dump("goUpdateTableMenuItems: too early, not initialized\n"); + return; + } + + var enabled = false; + var enabledIfTable = false; + + var flags = editor.flags; + if (!(flags & Ci.nsIEditor.eEditorReadonlyMask) && IsEditingRenderedHTML()) { + var tagNameObj = { value: "" }; + var element; + try { + element = editor.getSelectedOrParentTableElement(tagNameObj, { + value: 0, + }); + } catch (e) {} + + if (element) { + // Value when we need to have a selected table or inside a table + enabledIfTable = true; + + // All others require being inside a cell or selected cell + enabled = tagNameObj.value == "td"; + } + } + + // Loop through command nodes + for (var i = 0; i < commandset.children.length; i++) { + var commandID = commandset.children[i].getAttribute("id"); + if (commandID) { + if ( + commandID == "cmd_InsertTable" || + commandID == "cmd_JoinTableCells" || + commandID == "cmd_SplitTableCell" || + commandID == "cmd_ConvertToTable" + ) { + // Call the update method in the command class + goUpdateCommand(commandID); + } else if ( + commandID == "cmd_DeleteTable" || + commandID == "cmd_editTable" || + commandID == "cmd_TableOrCellColor" || + commandID == "cmd_SelectTable" + ) { + // Directly set with the values calculated here + goSetCommandEnabled(commandID, enabledIfTable); + } else { + goSetCommandEnabled(commandID, enabled); + } + } + } +} + +// ----------------------------------------------------------------------------------- +// Helpers for inserting and editing tables: + +function IsInTable() { + var editor = GetCurrentEditor(); + try { + var flags = editor.flags; + return ( + IsHTMLEditor() && + !(flags & Ci.nsIEditor.eEditorReadonlyMask) && + IsEditingRenderedHTML() && + null != editor.getElementOrParentByTagName("table", null) + ); + } catch (e) {} + return false; +} + +function IsInTableCell() { + try { + var editor = GetCurrentEditor(); + var flags = editor.flags; + return ( + IsHTMLEditor() && + !(flags & Ci.nsIEditor.eEditorReadonlyMask) && + IsEditingRenderedHTML() && + null != editor.getElementOrParentByTagName("td", null) + ); + } catch (e) {} + return false; +} + +function IsSelectionInOneCell() { + try { + var editor = GetCurrentEditor(); + var selection = editor.selection; + + if (selection.rangeCount == 1) { + // We have a "normal" single-range selection + if ( + !selection.isCollapsed && + selection.anchorNode != selection.focusNode + ) { + // Check if both nodes are within the same cell + var anchorCell = editor.getElementOrParentByTagName( + "td", + selection.anchorNode + ); + var focusCell = editor.getElementOrParentByTagName( + "td", + selection.focusNode + ); + return ( + focusCell != null && anchorCell != null && focusCell == anchorCell + ); + } + // Collapsed selection or anchor == focus (thus must be in 1 cell) + return true; + } + } catch (e) {} + return false; +} + +// Call this with insertAllowed = true to allow inserting if not in existing table, +// else use false to do nothing if not in a table +function EditorInsertOrEditTable(insertAllowed) { + if (IsInTable()) { + // Edit properties of existing table + window.openDialog( + "chrome://messenger/content/messengercompose/EdTableProps.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "", + "TablePanel" + ); + gContentWindow.focus(); + } else if (insertAllowed) { + try { + if (GetCurrentEditor().selection.isCollapsed) { + // If we have a caret, insert a blank table... + EditorInsertTable(); + } else { + // Else convert the selection into a table. + goDoCommand("cmd_ConvertToTable"); + } + } catch (e) {} + } +} + +function EditorInsertTable() { + // Insert a new table + window.openDialog( + "chrome://messenger/content/messengercompose/EdInsertTable.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "" + ); + gContentWindow.focus(); +} + +function EditorTableCellProperties() { + if (!IsHTMLEditor()) { + return; + } + + try { + var cell = GetCurrentEditor().getElementOrParentByTagName("td", null); + if (cell) { + // Start Table Properties dialog on the "Cell" panel + window.openDialog( + "chrome://messenger/content/messengercompose/EdTableProps.xhtml", + "_blank", + "chrome,close,titlebar,modal", + "", + "CellPanel" + ); + gContentWindow.focus(); + } + } catch (e) {} +} + +function GetNumberOfContiguousSelectedRows() { + if (!IsHTMLEditor()) { + return 0; + } + + var rows = 0; + var editor = GetCurrentTableEditor(); + var rowObj = { value: 0 }; + var colObj = { value: 0 }; + var cell = editor.getFirstSelectedCellInTable(rowObj, colObj); + if (!cell) { + return 0; + } + + // We have at least one row + rows++; + + var lastIndex = rowObj.value; + for (let cell of editor.getSelectedCells()) { + editor.getCellIndexes(cell, rowObj, colObj); + var index = rowObj.value; + if (index == lastIndex + 1) { + lastIndex = index; + rows++; + } + } + + return rows; +} + +function GetNumberOfContiguousSelectedColumns() { + if (!IsHTMLEditor()) { + return 0; + } + + var columns = 0; + + var editor = GetCurrentTableEditor(); + var colObj = { value: 0 }; + var rowObj = { value: 0 }; + var cell = editor.getFirstSelectedCellInTable(rowObj, colObj); + if (!cell) { + return 0; + } + + // We have at least one column + columns++; + + var lastIndex = colObj.value; + for (let cell of editor.getSelectedCells()) { + editor.getCellIndexes(cell, rowObj, colObj); + var index = colObj.value; + if (index == lastIndex + 1) { + lastIndex = index; + columns++; + } + } + + return columns; +} + +function EditorOnFocus() { + // Current window already has the InsertCharWindow + if ("InsertCharWindow" in window && window.InsertCharWindow) { + return; + } + + // Find window with an InsertCharsWindow and switch association to this one + var windowWithDialog = FindEditorWithInsertCharDialog(); + if (windowWithDialog) { + // Switch the dialog to current window + // this sets focus to dialog, so bring focus back to editor window + if (SwitchInsertCharToThisWindow(windowWithDialog)) { + top.document.commandDispatcher.focusedWindow.focus(); + } + } +} + +function SwitchInsertCharToThisWindow(windowWithDialog) { + if ( + windowWithDialog && + "InsertCharWindow" in windowWithDialog && + windowWithDialog.InsertCharWindow + ) { + // Move dialog association to the current window + window.InsertCharWindow = windowWithDialog.InsertCharWindow; + windowWithDialog.InsertCharWindow = null; + + // Switch the dialog's opener to current window's + window.InsertCharWindow.opener = window; + + // Bring dialog to the foreground + window.InsertCharWindow.focus(); + return true; + } + return false; +} + +function FindEditorWithInsertCharDialog() { + try { + // Find window with an InsertCharsWindow and switch association to this one + + for (let tempWindow of Services.wm.getEnumerator(null)) { + if ( + !tempWindow.closed && + tempWindow != window && + "InsertCharWindow" in tempWindow && + tempWindow.InsertCharWindow + ) { + return tempWindow; + } + } + } catch (e) {} + return null; +} + +function EditorFindOrCreateInsertCharWindow() { + if ("InsertCharWindow" in window && window.InsertCharWindow) { + window.InsertCharWindow.focus(); + } else { + // Since we switch the dialog during EditorOnFocus(), + // this should really never be found, but it's good to be sure + var windowWithDialog = FindEditorWithInsertCharDialog(); + if (windowWithDialog) { + SwitchInsertCharToThisWindow(windowWithDialog); + } else { + // The dialog will set window.InsertCharWindow to itself + window.openDialog( + "chrome://messenger/content/messengercompose/EdInsertChars.xhtml", + "_blank", + "chrome,close,titlebar", + "" + ); + } + } +} + +// Find another HTML editor window to associate with the InsertChar dialog +// or close it if none found (May be a mail composer) +function SwitchInsertCharToAnotherEditorOrClose() { + if ("InsertCharWindow" in window && window.InsertCharWindow) { + var enumerator; + try { + enumerator = Services.wm.getEnumerator(null); + } catch (e) {} + if (!enumerator) { + return; + } + + // TODO: Fix this to search for command controllers and look for "cmd_InsertChars" + // For now, detect just Web Composer and HTML Mail Composer + for (let tempWindow of enumerator) { + if ( + !tempWindow.closed && + tempWindow != window && + tempWindow != window.InsertCharWindow && + "GetCurrentEditor" in tempWindow && + tempWindow.GetCurrentEditor() + ) { + tempWindow.InsertCharWindow = window.InsertCharWindow; + window.InsertCharWindow = null; + tempWindow.InsertCharWindow.opener = tempWindow; + return; + } + } + // Didn't find another editor - close the dialog + window.InsertCharWindow.close(); + } +} + +function UpdateTOC() { + window.openDialog( + "chrome://messenger/content/messengercompose/EdInsertTOC.xhtml", + "_blank", + "chrome,close,modal,titlebar" + ); + window.content.focus(); +} + +function InitTOCMenu() { + var elt = GetCurrentEditor().document.getElementById("mozToc"); + var createMenuitem = document.getElementById("insertTOCMenuitem"); + var updateMenuitem = document.getElementById("updateTOCMenuitem"); + var removeMenuitem = document.getElementById("removeTOCMenuitem"); + if (removeMenuitem && createMenuitem && updateMenuitem) { + if (elt) { + createMenuitem.setAttribute("disabled", "true"); + updateMenuitem.removeAttribute("disabled"); + removeMenuitem.removeAttribute("disabled"); + } else { + createMenuitem.removeAttribute("disabled"); + removeMenuitem.setAttribute("disabled", "true"); + updateMenuitem.setAttribute("disabled", "true"); + } + } +} + +function RemoveTOC() { + var theDocument = GetCurrentEditor().document; + var elt = theDocument.getElementById("mozToc"); + if (elt) { + elt.remove(); + } + + let anchorNodes = theDocument.querySelectorAll('a[name^="mozTocId"]'); + for (let node of anchorNodes) { + if (node.parentNode) { + node.remove(); + } + } +} diff --git a/comm/mail/components/compose/content/editorUtilities.js b/comm/mail/components/compose/content/editorUtilities.js new file mode 100644 index 0000000000..3af6810c9c --- /dev/null +++ b/comm/mail/components/compose/content/editorUtilities.js @@ -0,0 +1,1015 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from editor.js */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// Each editor window must include this file +// Variables shared by all dialogs: + +// Object to attach commonly-used widgets (all dialogs should use this) +var gDialog = {}; + +var kOutputEncodeBasicEntities = + Ci.nsIDocumentEncoder.OutputEncodeBasicEntities; +var kOutputEncodeHTMLEntities = Ci.nsIDocumentEncoder.OutputEncodeHTMLEntities; +var kOutputEncodeLatin1Entities = + Ci.nsIDocumentEncoder.OutputEncodeLatin1Entities; +var kOutputEncodeW3CEntities = Ci.nsIDocumentEncoder.OutputEncodeW3CEntities; +var kOutputFormatted = Ci.nsIDocumentEncoder.OutputFormatted; +var kOutputLFLineBreak = Ci.nsIDocumentEncoder.OutputLFLineBreak; +var kOutputSelectionOnly = Ci.nsIDocumentEncoder.OutputSelectionOnly; +var kOutputWrap = Ci.nsIDocumentEncoder.OutputWrap; + +var gStringBundle; +var gFilePickerDirectory; + +/** *********** Message dialogs */ + +// Optional: Caller may supply text to substitute for "Ok" and/or "Cancel" +function ConfirmWithTitle(title, message, okButtonText, cancelButtonText) { + let okFlag = okButtonText + ? Services.prompt.BUTTON_TITLE_IS_STRING + : Services.prompt.BUTTON_TITLE_OK; + let cancelFlag = cancelButtonText + ? Services.prompt.BUTTON_TITLE_IS_STRING + : Services.prompt.BUTTON_TITLE_CANCEL; + + return ( + Services.prompt.confirmEx( + window, + title, + message, + okFlag * Services.prompt.BUTTON_POS_0 + + cancelFlag * Services.prompt.BUTTON_POS_1, + okButtonText, + cancelButtonText, + null, + null, + { value: 0 } + ) == 0 + ); +} + +/** *********** String Utilities */ + +function GetString(name) { + if (!gStringBundle) { + try { + gStringBundle = Services.strings.createBundle( + "chrome://messenger/locale/messengercompose/editor.properties" + ); + } catch (ex) {} + } + if (gStringBundle) { + try { + return gStringBundle.GetStringFromName(name); + } catch (e) {} + } + return null; +} + +function GetFormattedString(aName, aVal) { + if (!gStringBundle) { + try { + gStringBundle = Services.strings.createBundle( + "chrome://messenger/locale/messengercompose/editor.properties" + ); + } catch (ex) {} + } + if (gStringBundle) { + try { + return gStringBundle.formatStringFromName(aName, [aVal]); + } catch (e) {} + } + return null; +} + +function TrimStringLeft(string) { + if (!string) { + return ""; + } + return string.trimLeft(); +} + +function TrimStringRight(string) { + if (!string) { + return ""; + } + return string.trimRight(); +} + +// Remove whitespace from both ends of a string +function TrimString(string) { + if (!string) { + return ""; + } + return string.trim(); +} + +function TruncateStringAtWordEnd(string, maxLength, addEllipses) { + // Return empty if string is null, undefined, or the empty string + if (!string) { + return ""; + } + + // We assume they probably don't want whitespace at the beginning + string = string.trimLeft(); + if (string.length <= maxLength) { + return string; + } + + // We need to truncate the string to maxLength or fewer chars + if (addEllipses) { + maxLength -= 3; + } + string = string.replace(RegExp("(.{0," + maxLength + "})\\s.*"), "$1"); + + if (string.length > maxLength) { + string = string.slice(0, maxLength); + } + + if (addEllipses) { + string += "..."; + } + return string; +} + +// Replace all whitespace characters with supplied character +// E.g.: Use charReplace = " ", to "unwrap" the string by removing line-end chars +// Use charReplace = "_" when you don't want spaces (like in a URL) +function ReplaceWhitespace(string, charReplace) { + return string.trim().replace(/\s+/g, charReplace); +} + +// Replace whitespace with "_" and allow only HTML CDATA +// characters: "a"-"z","A"-"Z","0"-"9", "_", ":", "-", ".", +// and characters above ASCII 127 +function ConvertToCDATAString(string) { + return string + .replace(/\s+/g, "_") + .replace(/[^a-zA-Z0-9_\.\-\:\u0080-\uFFFF]+/g, ""); +} + +function GetSelectionAsText() { + try { + return GetCurrentEditor().outputToString( + "text/plain", + kOutputSelectionOnly + ); + } catch (e) {} + + return ""; +} + +/** *********** Get Current Editor and associated interfaces or info */ + +function GetCurrentEditor() { + // Get the active editor from the <editor> tag + // XXX This will probably change if we support > 1 editor in main Composer window + // (e.g. a plaintext editor for HTMLSource) + + // For dialogs: Search up parent chain to find top window with editor + var editor; + try { + var editorElement = GetCurrentEditorElement(); + editor = editorElement.getEditor(editorElement.contentWindow); + + // Do QIs now so editor users won't have to figure out which interface to use + // Using "instanceof" does the QI for us. + editor instanceof Ci.nsIHTMLEditor; + } catch (e) { + dump(e) + "\n"; + } + + return editor; +} + +function GetCurrentTableEditor() { + var editor = GetCurrentEditor(); + return editor && editor instanceof Ci.nsITableEditor ? editor : null; +} + +function GetCurrentEditorElement() { + var tmpWindow = window; + + do { + // Get the <editor> element(s) + let editorItem = tmpWindow.document.querySelector("editor"); + + // This will change if we support > 1 editor element + if (editorItem) { + return editorItem; + } + + tmpWindow = tmpWindow.opener; + } while (tmpWindow); + + return null; +} + +function GetCurrentCommandManager() { + try { + return GetCurrentEditorElement().commandManager; + } catch (e) { + dump(e) + "\n"; + } + + return null; +} + +function GetCurrentEditorType() { + try { + return GetCurrentEditorElement().editortype; + } catch (e) { + dump(e) + "\n"; + } + + return ""; +} + +/** + * Gets the editor's spell checker. Could return null if there are no + * dictionaries installed. + * + * @returns {nsIInlineSpellChecker?} + */ +function GetCurrentEditorSpellChecker() { + try { + return GetCurrentEditor().getInlineSpellChecker(true); + } catch (ex) {} + return null; +} + +function IsHTMLEditor() { + // We don't have an editorElement, just return false + if (!GetCurrentEditorElement()) { + return false; + } + + var editortype = GetCurrentEditorType(); + switch (editortype) { + case "html": + case "htmlmail": + return true; + + case "text": + case "textmail": + return false; + + default: + dump("INVALID EDITOR TYPE: " + editortype + "\n"); + break; + } + return false; +} + +function PageIsEmptyAndUntouched() { + return IsDocumentEmpty() && !IsDocumentModified() && !IsHTMLSourceChanged(); +} + +function IsInHTMLSourceMode() { + return gEditorDisplayMode == kDisplayModeSource; +} + +// are we editing HTML (i.e. neither in HTML source mode, nor editing a text file) +function IsEditingRenderedHTML() { + return IsHTMLEditor() && !IsInHTMLSourceMode(); +} + +function IsDocumentEditable() { + try { + return GetCurrentEditor().isDocumentEditable; + } catch (e) {} + return false; +} + +function IsDocumentEmpty() { + try { + return GetCurrentEditor().documentIsEmpty; + } catch (e) {} + return false; +} + +function IsDocumentModified() { + try { + return GetCurrentEditor().documentModified; + } catch (e) {} + return false; +} + +function IsHTMLSourceChanged() { + // gSourceTextEditor will not be defined if we're just a text editor. + return gSourceTextEditor ? gSourceTextEditor.documentModified : false; +} + +function newCommandParams() { + try { + return Cu.createCommandParams(); + } catch (e) { + dump("error thrown in newCommandParams: " + e + "\n"); + } + return null; +} + +/** *********** General editing command utilities */ + +function GetDocumentTitle() { + try { + return GetCurrentEditorElement().contentDocument.title; + } catch (e) {} + + return ""; +} + +function SetDocumentTitle(title) { + try { + GetCurrentEditorElement().contentDocument.title = title; + + // Update window title (doesn't work if called from a dialog) + if ("UpdateWindowTitle" in window) { + window.UpdateWindowTitle(); + } + } catch (e) {} +} + +function EditorGetTextProperty( + property, + attribute, + value, + firstHas, + anyHas, + allHas +) { + try { + return GetCurrentEditor().getInlinePropertyWithAttrValue( + property, + attribute, + value, + firstHas, + anyHas, + allHas + ); + } catch (e) {} +} + +function EditorSetTextProperty(property, attribute, value) { + try { + GetCurrentEditor().setInlineProperty(property, attribute, value); + if ("gContentWindow" in window) { + window.gContentWindow.focus(); + } + } catch (e) {} +} + +function EditorRemoveTextProperty(property, attribute) { + try { + GetCurrentEditor().removeInlineProperty(property, attribute); + if ("gContentWindow" in window) { + window.gContentWindow.focus(); + } + } catch (e) {} +} + +/** *********** Element enbabling/disabling */ + +// this function takes an elementID and a flag +// if the element can be found by ID, then it is either enabled (by removing "disabled" attr) +// or disabled (setAttribute) as specified in the "doEnable" parameter +function SetElementEnabledById(elementID, doEnable) { + SetElementEnabled(document.getElementById(elementID), doEnable); +} + +function SetElementEnabled(element, doEnable) { + if (element) { + if (doEnable) { + element.removeAttribute("disabled"); + } else { + element.setAttribute("disabled", "true"); + } + } else { + dump("Element not found in SetElementEnabled\n"); + } +} + +/** *********** Services / Prefs */ + +function GetFileProtocolHandler() { + let handler = Services.io.getProtocolHandler("file"); + return handler.QueryInterface(Ci.nsIFileProtocolHandler); +} + +function SetStringPref(aPrefName, aPrefValue) { + try { + Services.prefs.setStringPref(aPrefName, aPrefValue); + } catch (e) {} +} + +// Set initial directory for a filepicker from URLs saved in prefs +function SetFilePickerDirectory(filePicker, fileType) { + if (filePicker) { + try { + // Save current directory so we can reset it in SaveFilePickerDirectory + gFilePickerDirectory = filePicker.displayDirectory; + + let location = Services.prefs.getComplexValue( + "editor.lastFileLocation." + fileType, + Ci.nsIFile + ); + if (location) { + filePicker.displayDirectory = location; + } + } catch (e) {} + } +} + +// Save the directory of the selected file to prefs +function SaveFilePickerDirectory(filePicker, fileType) { + if (filePicker && filePicker.file) { + try { + var fileDir; + if (filePicker.file.parent) { + fileDir = filePicker.file.parent.QueryInterface(Ci.nsIFile); + } + + Services.prefs.setComplexValue( + "editor.lastFileLocation." + fileType, + Ci.nsIFile, + fileDir + ); + + Services.prefs.savePrefFile(null); + } catch (e) {} + } + + // Restore the directory used before SetFilePickerDirectory was called; + // This reduces interference with Browser and other module directory defaults + if (gFilePickerDirectory) { + filePicker.displayDirectory = gFilePickerDirectory; + } + + gFilePickerDirectory = null; +} + +function GetDefaultBrowserColors() { + var colors = { + TextColor: 0, + BackgroundColor: 0, + LinkColor: 0, + ActiveLinkColor: 0, + VisitedLinkColor: 0, + }; + var useSysColors = Services.prefs.getBoolPref( + "browser.display.use_system_colors", + false + ); + + if (!useSysColors) { + colors.TextColor = Services.prefs.getCharPref( + "browser.display.foreground_color", + 0 + ); + colors.BackgroundColor = Services.prefs.getCharPref( + "browser.display.background_color", + 0 + ); + } + // Use OS colors for text and background if explicitly asked or pref is not set + if (!colors.TextColor) { + colors.TextColor = "windowtext"; + } + + if (!colors.BackgroundColor) { + colors.BackgroundColor = "window"; + } + + colors.LinkColor = Services.prefs.getCharPref("browser.anchor_color"); + colors.ActiveLinkColor = Services.prefs.getCharPref("browser.active_color"); + colors.VisitedLinkColor = Services.prefs.getCharPref("browser.visited_color"); + + return colors; +} + +/** *********** URL handling */ + +function TextIsURI(selectedText) { + return ( + selectedText && + /^http:\/\/|^https:\/\/|^file:\/\/|^ftp:\/\/|^about:|^mailto:|^news:|^snews:|^telnet:|^ldap:|^ldaps:|^gopher:|^finger:|^javascript:/i.test( + selectedText + ) + ); +} + +function IsUrlAboutBlank(urlString) { + return urlString.startsWith("about:blank"); +} + +function MakeRelativeUrl(url) { + let inputUrl = url.trim(); + if (!inputUrl) { + return inputUrl; + } + + // Get the filespec relative to current document's location + // NOTE: Can't do this if file isn't saved yet! + var docUrl = GetDocumentBaseUrl(); + var docScheme = GetScheme(docUrl); + + // Can't relativize if no doc scheme (page hasn't been saved) + if (!docScheme) { + return inputUrl; + } + + var urlScheme = GetScheme(inputUrl); + + // Do nothing if not the same scheme or url is already relativized + if (docScheme != urlScheme) { + return inputUrl; + } + + // Host must be the same + var docHost = GetHost(docUrl); + var urlHost = GetHost(inputUrl); + if (docHost != urlHost) { + return inputUrl; + } + + // Get just the file path part of the urls + // XXX Should we use GetCurrentEditor().documentCharacterSet for 2nd param ? + let docPath = Services.io.newURI( + docUrl, + GetCurrentEditor().documentCharacterSet + ).pathQueryRef; + let urlPath = Services.io.newURI( + inputUrl, + GetCurrentEditor().documentCharacterSet + ).pathQueryRef; + + // We only return "urlPath", so we can convert the entire docPath for + // case-insensitive comparisons. + var doCaseInsensitive = docScheme == "file" && AppConstants.platform == "win"; + if (doCaseInsensitive) { + docPath = docPath.toLowerCase(); + } + + // Get document filename before we start chopping up the docPath + var docFilename = GetFilename(docPath); + + // Both url and doc paths now begin with "/" + // Look for shared dirs starting after that + urlPath = urlPath.slice(1); + docPath = docPath.slice(1); + + var firstDirTest = true; + var nextDocSlash = 0; + var done = false; + + // Remove all matching subdirs common to both doc and input urls + do { + nextDocSlash = docPath.indexOf("/"); + var nextUrlSlash = urlPath.indexOf("/"); + + if (nextUrlSlash == -1) { + // We're done matching and all dirs in url + // what's left is the filename + done = true; + + // Remove filename for named anchors in the same file + if (nextDocSlash == -1 && docFilename) { + var anchorIndex = urlPath.indexOf("#"); + if (anchorIndex > 0) { + var urlFilename = doCaseInsensitive ? urlPath.toLowerCase() : urlPath; + + if (urlFilename.startsWith(docFilename)) { + urlPath = urlPath.slice(anchorIndex); + } + } + } + } else if (nextDocSlash >= 0) { + // Test for matching subdir + var docDir = docPath.slice(0, nextDocSlash); + var urlDir = urlPath.slice(0, nextUrlSlash); + if (doCaseInsensitive) { + urlDir = urlDir.toLowerCase(); + } + + if (urlDir == docDir) { + // Remove matching dir+"/" from each path + // and continue to next dir. + docPath = docPath.slice(nextDocSlash + 1); + urlPath = urlPath.slice(nextUrlSlash + 1); + } else { + // No match, we're done. + done = true; + + // Be sure we are on the same local drive or volume + // (the first "dir" in the path) because we can't + // relativize to different drives/volumes. + // UNIX doesn't have volumes, so we must not do this else + // the first directory will be misinterpreted as a volume name. + if ( + firstDirTest && + docScheme == "file" && + AppConstants.platform != "unix" + ) { + return inputUrl; + } + } + } else { + // No more doc dirs left, we're done + done = true; + } + + firstDirTest = false; + } while (!done); + + // Add "../" for each dir left in docPath + while (nextDocSlash > 0) { + urlPath = "../" + urlPath; + nextDocSlash = docPath.indexOf("/", nextDocSlash + 1); + } + return urlPath; +} + +function MakeAbsoluteUrl(url) { + let resultUrl = TrimString(url); + if (!resultUrl) { + return resultUrl; + } + + // Check if URL is already absolute, i.e., it has a scheme + let urlScheme = GetScheme(resultUrl); + + if (urlScheme) { + return resultUrl; + } + + let docUrl = GetDocumentBaseUrl(); + let docScheme = GetScheme(docUrl); + + // Can't relativize if no doc scheme (page hasn't been saved) + if (!docScheme) { + return resultUrl; + } + + // Make a URI object to use its "resolve" method + let absoluteUrl = resultUrl; + let docUri = Services.io.newURI( + docUrl, + GetCurrentEditor().documentCharacterSet + ); + + try { + absoluteUrl = docUri.resolve(resultUrl); + // This is deprecated and buggy! + // If used, we must make it a path for the parent directory (remove filename) + // absoluteUrl = IOService.resolveRelativePath(resultUrl, docUrl); + } catch (e) {} + + return absoluteUrl; +} + +// Get the HREF of the page's <base> tag or the document location +// returns empty string if no base href and document hasn't been saved yet +function GetDocumentBaseUrl() { + try { + var docUrl; + + // if document supplies a <base> tag, use that URL instead + let base = GetCurrentEditor().document.querySelector("base"); + if (base) { + docUrl = base.getAttribute("href"); + } + if (!docUrl) { + docUrl = GetDocumentUrl(); + } + + if (!IsUrlAboutBlank(docUrl)) { + return docUrl; + } + } catch (e) {} + return ""; +} + +function GetDocumentUrl() { + try { + return GetCurrentEditor().document.URL; + } catch (e) {} + return ""; +} + +// Extract the scheme (e.g., 'file', 'http') from a URL string +function GetScheme(urlspec) { + var resultUrl = TrimString(urlspec); + // Unsaved document URL has no acceptable scheme yet + if (!resultUrl || IsUrlAboutBlank(resultUrl)) { + return ""; + } + + var scheme = ""; + try { + // This fails if there's no scheme + scheme = Services.io.extractScheme(resultUrl); + } catch (e) {} + + return scheme ? scheme.toLowerCase() : ""; +} + +function GetHost(urlspec) { + if (!urlspec) { + return ""; + } + + var host = ""; + try { + host = Services.io.newURI(urlspec).host; + } catch (e) {} + + return host; +} + +function GetUsername(urlspec) { + if (!urlspec) { + return ""; + } + + var username = ""; + try { + username = Services.io.newURI(urlspec).username; + } catch (e) {} + + return username; +} + +function GetFilename(urlspec) { + if (!urlspec || IsUrlAboutBlank(urlspec)) { + return ""; + } + + var filename; + + try { + let uri = Services.io.newURI(urlspec); + if (uri) { + let url = uri.QueryInterface(Ci.nsIURL); + if (url) { + filename = url.fileName; + } + } + } catch (e) {} + + return filename ? filename : ""; +} + +// Return the url without username and password +// Optional output objects return extracted username and password strings +// This uses just string routines via nsIIOServices +function StripUsernamePassword(urlspec, usernameObj, passwordObj) { + urlspec = TrimString(urlspec); + if (!urlspec || IsUrlAboutBlank(urlspec)) { + return urlspec; + } + + if (usernameObj) { + usernameObj.value = ""; + } + if (passwordObj) { + passwordObj.value = ""; + } + + // "@" must exist else we will never detect username or password + var atIndex = urlspec.indexOf("@"); + if (atIndex > 0) { + try { + let uri = Services.io.newURI(urlspec); + let username = uri.username; + let password = uri.password; + + if (usernameObj && username) { + usernameObj.value = username; + } + if (passwordObj && password) { + passwordObj.value = password; + } + if (username) { + let usernameStart = urlspec.indexOf(username); + if (usernameStart != -1) { + return urlspec.slice(0, usernameStart) + urlspec.slice(atIndex + 1); + } + } + } catch (e) {} + } + return urlspec; +} + +function StripPassword(urlspec, passwordObj) { + urlspec = TrimString(urlspec); + if (!urlspec || IsUrlAboutBlank(urlspec)) { + return urlspec; + } + + if (passwordObj) { + passwordObj.value = ""; + } + + // "@" must exist else we will never detect password + var atIndex = urlspec.indexOf("@"); + if (atIndex > 0) { + try { + let password = Services.io.newURI(urlspec).password; + + if (passwordObj && password) { + passwordObj.value = password; + } + if (password) { + // Find last ":" before "@" + let colon = urlspec.lastIndexOf(":", atIndex); + if (colon != -1) { + // Include the "@" + return urlspec.slice(0, colon) + urlspec.slice(atIndex); + } + } + } catch (e) {} + } + return urlspec; +} + +// Version to use when you have an nsIURI object +function StripUsernamePasswordFromURI(uri) { + var urlspec = ""; + if (uri) { + try { + urlspec = uri.spec; + var userPass = uri.userPass; + if (userPass) { + let start = urlspec.indexOf(userPass); + urlspec = + urlspec.slice(0, start) + urlspec.slice(start + userPass.length + 1); + } + } catch (e) {} + } + return urlspec; +} + +function InsertUsernameIntoUrl(urlspec, username) { + if (!urlspec || !username) { + return urlspec; + } + + try { + let URI = Services.io.newURI( + urlspec, + GetCurrentEditor().documentCharacterSet + ); + URI.username = username; + return URI.spec; + } catch (e) {} + + return urlspec; +} + +function ConvertRGBColorIntoHEXColor(color) { + if (/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/.test(color)) { + var r = Number(RegExp.$1).toString(16); + if (r.length == 1) { + r = "0" + r; + } + var g = Number(RegExp.$2).toString(16); + if (g.length == 1) { + g = "0" + g; + } + var b = Number(RegExp.$3).toString(16); + if (b.length == 1) { + b = "0" + b; + } + return "#" + r + g + b; + } + + return color; +} + +/** *********** CSS */ + +function GetHTMLOrCSSStyleValue(element, attrName, cssPropertyName) { + var value; + if (Services.prefs.getBoolPref("editor.use_css") && IsHTMLEditor()) { + value = element.style.getPropertyValue(cssPropertyName); + } + + if (!value) { + value = element.getAttribute(attrName); + } + + if (!value) { + return ""; + } + + return value; +} + +/** *********** Miscellaneous */ +// Clone simple JS objects +function Clone(obj) { + var clone = {}; + for (var i in obj) { + if (typeof obj[i] == "object") { + clone[i] = Clone(obj[i]); + } else { + clone[i] = obj[i]; + } + } + return clone; +} + +/** + * Utility functions to handle shortended data: URLs in EdColorProps.js and EdImageOverlay.js. + */ + +/** + * Is the passed in image URI a shortened data URI? + * + * @returns {bool} + */ +function isImageDataShortened(aImageData) { + return /^data:/i.test(aImageData) && aImageData.includes("…"); +} + +/** + * Event handler for Copy or Cut + * + * @param aEvent the event + */ +function onCopyOrCutShortened(aEvent) { + // Put the original data URI onto the clipboard in case the value + // is a shortened data URI. + let field = aEvent.target; + let startPos = field.selectionStart; + if (startPos == undefined) { + return; + } + let endPos = field.selectionEnd; + let selection = field.value.substring(startPos, endPos).trim(); + + // Test that a) the user selected the whole value, + // b) the value is a data URI, + // c) it contains the ellipsis we added. Otherwise it could be + // a new value that the user pasted in. + if (selection == field.value.trim() && isImageDataShortened(selection)) { + aEvent.clipboardData.setData("text/plain", field.fullDataURI); + if (aEvent.type == "cut") { + // We have to cut the selection manually. Since we tested that + // everything was selected, we can just reset the field. + field.value = ""; + } + aEvent.preventDefault(); + } +} + +/** + * Set up element showing an image URI with a shortened version. + * and add event handler for Copy or Cut. + * + * @param aImageData the data: URL of the image to be shortened. + * Note: Original stored in 'aDialogField.fullDataURI'. + * @param aDialogField The field of the dialog to contain the data. + * @returns {bool} URL was shortened? + */ +function shortenImageData(aImageData, aDialogField) { + let shortened = false; + aDialogField.value = aImageData.replace( + /^(data:.+;base64,)(.*)/i, + function (match, nonDataPart, dataPart) { + if (dataPart.length <= 35) { + return match; + } + + shortened = true; + aDialogField.addEventListener("copy", onCopyOrCutShortened); + aDialogField.addEventListener("cut", onCopyOrCutShortened); + aDialogField.fullDataURI = aImageData; + aDialogField.removeAttribute("tooltiptext"); + aDialogField.setAttribute("tooltip", "shortenedDataURI"); + return ( + nonDataPart + + dataPart.substring(0, 5) + + "…" + + dataPart.substring(dataPart.length - 30) + ); + } + ); + return shortened; +} + +/** + * Return full data URIs for a shortened element. + * + * @param aDialogField The field of the dialog containing the data. + */ +function restoredImageData(aDialogField) { + return aDialogField.fullDataURI; +} diff --git a/comm/mail/components/compose/content/images/tag-anchor.gif b/comm/mail/components/compose/content/images/tag-anchor.gif Binary files differnew file mode 100644 index 0000000000..ccb809b50b --- /dev/null +++ b/comm/mail/components/compose/content/images/tag-anchor.gif diff --git a/comm/mail/components/compose/content/messengercompose.xhtml b/comm/mail/components/compose/content/messengercompose.xhtml new file mode 100644 index 0000000000..6881bf7cf3 --- /dev/null +++ b/comm/mail/components/compose/content/messengercompose.xhtml @@ -0,0 +1,2572 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messengercompose/messengercompose.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/menulist.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/inContentDialog.css" type="text/css"?> + +<!DOCTYPE html [ + <!ENTITY % messengercomposeDTD SYSTEM "chrome://messenger/locale/messengercompose/messengercompose.dtd" > + %messengercomposeDTD; + <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" > + %brandDTD; + <!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd"> + %customizeToolbarDTD; + <!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd"> + %viewZoomOverlayDTD; + <!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd"> + %baseMenuOverlayDTD; + <!ENTITY % msgCompSMIMEDTD SYSTEM "chrome://messenger-smime/locale/msgCompSMIMEOverlay.dtd"> + %msgCompSMIMEDTD; + <!ENTITY % editorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/editorOverlay.dtd"> + %editorOverlayDTD; + <!ENTITY % utilityOverlayDTD SYSTEM + "chrome://communicator/locale/utilityOverlay.dtd"> + %utilityOverlayDTD; + <!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd"> + %messengerDTD; +]> + +<html id="msgcomposeWindow" xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + icon="msgcomposeWindow" + scrolling="false" + windowtype="msgcompose" + toggletoolbar="true" + persist="screenX screenY width height sizemode" + lightweightthemes="true" +#ifdef XP_MACOSX + macanimationtype="document" + chromemargin="0,-1,-1,-1" +#endif + fullscreenbutton="true"> +<head> + <title>&msgComposeWindow.title;</title> + <link rel="localization" href="branding/brand.ftl"/> + <link rel="localization" href="messenger/messenger.ftl" /> + <link rel="localization" href="messenger/messengercompose/messengercompose.ftl" /> + <link rel="localization" href="messenger/menubar.ftl" /> + <link rel="localization" href="messenger/appmenu.ftl" /> + <link rel="localization" href="messenger/openpgp/openpgp.ftl" /> + <link rel="localization" href="messenger/openpgp/keyAssistant.ftl" /> + <link rel="localization" href="messenger/openpgp/composeKeyStatus.ftl"/> + <link rel="localization" href="toolkit/main-window/findbar.ftl" /> + <link rel="localization" href="toolkit/global/textActions.ftl" /> + <link rel="localization" href="toolkit/printing/printUI.ftl" /> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/pane-splitter.js"></script> + <script defer="defer" src="chrome://messenger/content/accountUtils.js"></script> + <script defer="defer" src="chrome://messenger/content/mailCore.js"></script> + <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script> + <script defer="defer" src="chrome://messenger/content/messengercompose/editor.js"></script> + <script defer="defer" src="chrome://messenger/content/messengercompose/editorUtilities.js"></script> + <script defer="defer" src="chrome://messenger/content/messengercompose/ComposerCommands.js"></script> + <script defer="defer" src="chrome://messenger/content/messengercompose/MsgComposeCommands.js"></script> + <script defer="defer" src="chrome://messenger/content/messengercompose/bigFileObserver.js"></script> + <script defer="defer" src="chrome://messenger/content/messengercompose/cloudAttachmentLinkManager.js"></script> + <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script> + <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script> + <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script> + <script defer="defer" src="chrome://messenger/content/addressbook/abDragDrop.js"></script> + <script defer="defer" src="chrome://messenger/content/messengercompose/addressingWidgetOverlay.js"></script> + <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script> + <script defer="defer" src="chrome://messenger/content/addressbook/abCommon.js"></script> + <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script> +#ifdef XP_MACOSX + <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script> +#endif + <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script> + <script defer="defer" src="chrome://openpgp/content/ui/enigmailMsgComposeOverlay.js"></script> + <script defer="defer" src="chrome://openpgp/content/ui/commonWorkflows.js"></script> + <script defer="defer" src="chrome://openpgp/content/ui/keyAssistant.js"></script> +</head> +<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <stringbundle id="bundle_composeMsgs" src="chrome://messenger/locale/messengercompose/composeMsgs.properties"/> + <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/> + <stringbundle id="brandBundle" src="chrome://branding/locale/brand.properties"/> + +<commandset id="composeCommands"> + <commandset id="msgComposeCommandUpdate" + commandupdater="true" + events="focus" + oncommandupdate="CommandUpdate_MsgCompose()"/> + <commandset id="globalEditMenuItems" + commandupdater="true" + events="focus" + oncommandupdate="goUpdateGlobalEditMenuItems()"/> + <commandset id="selectEditMenuItems" + commandupdater="true" + events="select" + oncommandupdate="goUpdateSelectEditMenuItems()"/> + <commandset id="undoEditMenuItems" + commandupdater="true" + events="undo" + oncommandupdate="goUpdateUndoEditMenuItems()"/> + <commandset id="clipboardEditMenuItems" + commandupdater="true" + events="clipboard" + oncommandupdate="goUpdatePasteMenuItems()"/> + + <!-- commands updated when the editor gets created --> + <commandset id="commonEditorMenuItems" + commandupdater="true" + events="create" + oncommandupdate="goUpdateComposerMenuItems(this)"> + <command id="cmd_print" oncommand="goDoCommand('cmd_print')"/> + <command id="cmd_quitApplication" oncommand="goDoCommand('cmd_quitApplication')"/> + </commandset> + + <commandset id="composerMenuItems" + commandupdater="true" + events="create, mode_switch" + oncommandupdate="goUpdateComposerMenuItems(this)"> + <!-- format menu --> + <command id="cmd_listProperties" oncommand="goDoCommand('cmd_listProperties')"/> + <command id="cmd_colorProperties" oncommand="goDoCommand('cmd_colorProperties')"/> + + <command id="cmd_link" oncommand="goDoCommand('cmd_link')"/> + <command id="cmd_anchor" oncommand="goDoCommand('cmd_anchor')"/> + <command id="cmd_image" oncommand="goDoCommand('cmd_image')"/> + <command id="cmd_hline" oncommand="goDoCommand('cmd_hline')"/> + <command id="cmd_table" oncommand="goDoCommand('cmd_table')"/> + <command id="cmd_objectProperties" oncommand="goDoCommand('cmd_objectProperties')"/> + <command id="cmd_insertChars" oncommand="goDoCommand('cmd_insertChars')" label="&insertCharsCmd.label;"/> + <command id="cmd_insertHTMLWithDialog" oncommand="goDoCommand('cmd_insertHTMLWithDialog')" label="&insertHTMLCmd.label;"/> + <command id="cmd_insertMathWithDialog" oncommand="goDoCommand('cmd_insertMathWithDialog')" label="&insertMathCmd.label;"/> + + <command id="cmd_insertBreakAll" oncommand="goDoCommand('cmd_insertBreakAll')"/> + + <!-- dummy command used just to disable things in non-HTML modes --> + <command id="cmd_renderedHTMLEnabler"/> + </commandset> + + <!-- edit menu commands. These get updated by code in globalOverlay.js --> + <commandset id="composerEditMenuItems" + commandupdater="true" + events="create, mode_switch" + oncommandupdate="goUpdateComposerMenuItems(this)"> + <command id="cmd_pasteNoFormatting" oncommand="goDoCommand('cmd_pasteNoFormatting')" + label="&pasteNoFormatting.label;" accesskey="&pasteNoFormatting.accesskey;"/> + <command id="cmd_findReplace" oncommand="goDoCommand('cmd_findReplace')"/> + <command id="cmd_find" oncommand="goDoCommand('cmd_find')"/> + <command id="cmd_findNext" oncommand="goDoCommand('cmd_findNext');"/> + <command id="cmd_findPrev" oncommand="goDoCommand('cmd_findPrev');"/> + <command id="cmd_spelling" oncommand="goDoCommand('cmd_spelling')"/> + <command id="cmd_pasteQuote" oncommand="goDoCommand('cmd_pasteQuote')" label="&pasteAsQuotationCmd.label;"/> + </commandset> + + <!-- style related commands that update on creation, and on selection change --> + <commandset id="composerStyleMenuItems" + commandupdater="true" + events="create, style, mode_switch" + oncommandupdate="goUpdateComposerMenuItems(this)"> + <command id="cmd_bold" state="false" oncommand="doStyleUICommand('cmd_bold')"/> + <command id="cmd_italic" state="false" oncommand="doStyleUICommand('cmd_italic')"/> + <command id="cmd_underline" state="false" oncommand="doStyleUICommand('cmd_underline')"/> + <command id="cmd_tt" state="false" oncommand="goDoCommand('cmd_tt')"/> + <command id="cmd_smiley"/> + + <command id="cmd_strikethrough" state="false" oncommand="doStyleUICommand('cmd_strikethrough');"/> + <command id="cmd_superscript" state="false" oncommand="doStyleUICommand('cmd_superscript');"/> + <command id="cmd_subscript" state="false" oncommand="doStyleUICommand('cmd_subscript');"/> + <command id="cmd_nobreak" state="false" oncommand="goDoCommand('cmd_nobreak');"/> + + <command id="cmd_em" state="false" oncommand="goDoCommand('cmd_em')"/> + <command id="cmd_strong" state="false" oncommand="goDoCommand('cmd_strong')"/> + <command id="cmd_cite" state="false" oncommand="goDoCommand('cmd_cite')"/> + <command id="cmd_abbr" state="false" oncommand="goDoCommand('cmd_abbr')"/> + <command id="cmd_acronym" state="false" oncommand="goDoCommand('cmd_acronym')"/> + <command id="cmd_code" state="false" oncommand="goDoCommand('cmd_code')"/> + <command id="cmd_samp" state="false" oncommand="goDoCommand('cmd_samp')"/> + <command id="cmd_var" state="false" oncommand="goDoCommand('cmd_var')"/> + + <command id="cmd_ul" state="false" oncommand="doStyleUICommand('cmd_ul')"/> + <command id="cmd_ol" state="false" oncommand="doStyleUICommand('cmd_ol')"/> + + <command id="cmd_indent" oncommand="goDoCommand('cmd_indent')"/> + <command id="cmd_outdent" oncommand="goDoCommand('cmd_outdent')"/> + + <command id="cmd_paragraphState" state=""/> + <command id="cmd_fontFace" state="" oncommand="doStatefulCommand('cmd_fontFace', event.target.value)"/> + + <!-- No "oncommand", use EditorSelectColor() to bring up color dialog --> + <command id="cmd_fontColor" state="" disabled="false"/> + <command id="cmd_backgroundColor" state="" disabled="false"/> + <command id="cmd_highlight" state="transparent" oncommand="EditorSelectColor('Highlight', event);"/> + + <command id="cmd_align" state=""/> + + <command id="cmd_increaseFontStep" oncommand="goDoCommand('cmd_increaseFontStep')"/> + <command id="cmd_decreaseFontStep" oncommand="goDoCommand('cmd_decreaseFontStep')"/> + + <command id="cmd_removeStyles" oncommand="editorRemoveTextStyling();"/> + <command id="cmd_removeLinks" oncommand="goDoCommand('cmd_removeLinks')"/> + <command id="cmd_removeNamedAnchors" oncommand="goDoCommand('cmd_removeNamedAnchors')"/> + </commandset> + + <commandset id="composerTableMenuItems" + commandupdater="true" + events="create, mode_switch" + oncommandupdate="goUpdateTableMenuItems(this)"> + <!-- Table menu --> + <command id="cmd_SelectTable" oncommand="goDoCommand('cmd_SelectTable')"/> + <command id="cmd_SelectRow" oncommand="goDoCommand('cmd_SelectRow')"/> + <command id="cmd_SelectColumn" oncommand="goDoCommand('cmd_SelectColumn')"/> + <command id="cmd_SelectCell" oncommand="goDoCommand('cmd_SelectCell')"/> + <command id="cmd_SelectAllCells" oncommand="goDoCommand('cmd_SelectAllCells')"/> + <command id="cmd_InsertTable" oncommand="goDoCommand('cmd_InsertTable')"/> + <command id="cmd_InsertRowAbove" oncommand="goDoCommand('cmd_InsertRowAbove')"/> + <command id="cmd_InsertRowBelow" oncommand="goDoCommand('cmd_InsertRowBelow')"/> + <command id="cmd_InsertColumnBefore" oncommand="goDoCommand('cmd_InsertColumnBefore')"/> + <command id="cmd_InsertColumnAfter" oncommand="goDoCommand('cmd_InsertColumnAfter')"/> + <command id="cmd_InsertCellBefore" oncommand="goDoCommand('cmd_InsertCellBefore')"/> + <command id="cmd_InsertCellAfter" oncommand="goDoCommand('cmd_InsertCellAfter')"/> + <command id="cmd_DeleteTable" oncommand="goDoCommand('cmd_DeleteTable')"/> + <command id="cmd_DeleteRow" oncommand="goDoCommand('cmd_DeleteRow')"/> + <command id="cmd_DeleteColumn" oncommand="goDoCommand('cmd_DeleteColumn')"/> + <command id="cmd_DeleteCell" oncommand="goDoCommand('cmd_DeleteCell')"/> + <command id="cmd_DeleteCellContents" oncommand="goDoCommand('cmd_DeleteCellContents')"/> + <command id="cmd_JoinTableCells" oncommand="goDoCommand('cmd_JoinTableCells')"/> + <command id="cmd_SplitTableCell" oncommand="goDoCommand('cmd_SplitTableCell')"/> + <command id="cmd_ConvertToTable" oncommand="goDoCommand('cmd_ConvertToTable')"/> + <command id="cmd_TableOrCellColor" oncommand="goDoCommand('cmd_TableOrCellColor')"/> + <command id="cmd_editTable" oncommand="goDoCommand('cmd_editTable')"/> + </commandset> + + <!-- commands updated only when the menu gets created --> + <commandset id="composerListMenuItems" + commandupdater="true" + events="create, mode_switch" + oncommandupdate="goUpdateComposerMenuItems(this)"> + <!-- List menu --> + <command id="cmd_dt" oncommand="goDoCommand('cmd_dt')"/> + <command id="cmd_dd" oncommand="goDoCommand('cmd_dd')"/> + <command id="cmd_removeList" oncommand="goDoCommand('cmd_removeList')"/> + <!-- cmd_ul and cmd_ol are shared with toolbar and are in composerStyleMenuItems commandset --> + </commandset> + + <!-- File Menu --> + <command id="cmd_new" oncommand="goDoCommand('cmd_newMessage')"/> + <command id="cmd_attachFile" oncommand="goDoCommand('cmd_attachFile')"/> + <command id="cmd_attachCloud" oncommand="attachToCloud(event)"/> + <command id="cmd_attachPage" oncommand="goDoCommand('cmd_attachPage')"/> + <command id="cmd_attachVCard" checked="false" + oncommand="ToggleAttachVCard(event.target)"/> + <command id="cmd_attachPublicKey" checked="false" + oncommand="toggleAttachMyPublicKey(event.target)"/> + <command id="cmd_remindLater" checked="false" + oncommand="toggleAttachmentReminder()"/> + <command id="cmd_close" oncommand="goDoCommand('cmd_close')"/> + <command id="cmd_saveDefault" oncommand="goDoCommand('cmd_saveDefault')"/> + <command id="cmd_saveAsFile" oncommand="goDoCommand('cmd_saveAsFile')"/> + <command id="cmd_saveAsDraft" oncommand="goDoCommand('cmd_saveAsDraft')"/> + <command id="cmd_saveAsTemplate" oncommand="goDoCommand('cmd_saveAsTemplate')"/> + <command id="cmd_sendButton" oncommand="goDoCommand('cmd_sendButton')"/> + <command id="cmd_sendNow" oncommand="goDoCommand('cmd_sendNow')"/> + <command id="cmd_sendWithCheck" oncommand="goDoCommand('cmd_sendWithCheck')"/> + <command id="cmd_sendLater" oncommand="goDoCommand('cmd_sendLater')"/> + <command id="cmd_print" oncommand="goDoCommand('cmd_print')"/> + + <!-- Edit Menu --> + <!--command id="cmd_pasteQuote"/ DO NOT INCLUDE THOSE COMMANDS ELSE THE EDIT MENU WILL BE BROKEN! --> + <!--command id="cmd_find"/--> + <!--command id="cmd_findNext"/--> + <command id="cmd_undo" oncommand="goDoCommand('cmd_undo')" disabled="true"/> + <command id="cmd_redo" oncommand="goDoCommand('cmd_redo')" disabled="true"/> + <command id="cmd_cut" oncommand="goDoCommand('cmd_cut')" disabled="true"/> + <command id="cmd_copy" oncommand="goDoCommand('cmd_copy')" disabled="true"/> + <command id="cmd_paste" oncommand="goDoCommand('cmd_paste')" disabled="true"/> + <command id="cmd_rewrap" oncommand="goDoCommand('cmd_rewrap')"/> + <command id="cmd_delete" + oncommand="goDoCommand('cmd_delete')" + valueDefault="&deleteCmd.label;" + valueDefaultAccessKey="&deleteCmd.accesskey;" + valueRemoveAttachmentAccessKey="&removeAttachment.accesskey;" + disabled="true"/> + <command id="cmd_selectAll" + oncommand="goDoCommand('cmd_selectAll')" disabled="true"/> + <command id="cmd_removeAllAttachments" + oncommand="goDoCommand('cmd_removeAllAttachments')"/> + <command id="cmd_openAttachment" + oncommand="goDoCommand('cmd_openAttachment')" disabled="true"/> + <command id="cmd_renameAttachment" + oncommand="goDoCommand('cmd_renameAttachment')" disabled="true"/> + <command id="cmd_reorderAttachments" + oncommand="goDoCommand('cmd_reorderAttachments')" disabled="true"/> + <command id="cmd_toggleAttachmentPane" + oncommand="goDoCommand('cmd_toggleAttachmentPane')"/> + <command id="cmd_account" + oncommand="goDoCommand('cmd_account')"/> + + <!-- Reorder Attachments Panel --> + <command id="cmd_moveAttachmentLeft" + oncommand="goDoCommand('cmd_moveAttachmentLeft')" disabled="true"/> + <command id="cmd_moveAttachmentRight" + oncommand="goDoCommand('cmd_moveAttachmentRight')" disabled="true"/> + <command id="cmd_moveAttachmentBundleUp" + oncommand="goDoCommand('cmd_moveAttachmentBundleUp')" disabled="true"/> + <command id="cmd_moveAttachmentBundleDown" + oncommand="goDoCommand('cmd_moveAttachmentBundleDown')" disabled="true"/> + <command id="cmd_moveAttachmentTop" + oncommand="goDoCommand('cmd_moveAttachmentTop')" disabled="true"/> + <command id="cmd_moveAttachmentBottom" + oncommand="goDoCommand('cmd_moveAttachmentBottom')" disabled="true"/> + <command id="cmd_sortAttachmentsToggle" + sortdirection="ascending" + oncommand="goDoCommand('cmd_sortAttachmentsToggle')" disabled="true"/> + + <!-- View Menu --> + <command id="cmd_showFormatToolbar" + oncommand="goDoCommand('cmd_showFormatToolbar')"/> + + <commandset id="viewZoomCommands" + commandupdater="false" + events="create-menu-view" + oncommandupdate="goUpdateMailMenuItems(this);"> + <command id="cmd_fullZoomReduce" + oncommand="goDoCommand('cmd_fullZoomReduce');"/> + <command id="cmd_fullZoomEnlarge" + oncommand="goDoCommand('cmd_fullZoomEnlarge');"/> + <command id="cmd_fullZoomReset" + oncommand="goDoCommand('cmd_fullZoomReset');"/> + <command id="cmd_fullZoomToggle" + oncommand="goDoCommand('cmd_fullZoomToggle');"/> + </commandset> + + <!-- Options Menu --> + <command id="cmd_quoteMessage" oncommand="goDoCommand('cmd_quoteMessage')"/> + <command id="cmd_toggleReturnReceipt" + oncommand="goDoCommand('cmd_toggleReturnReceipt')"/> + <command id="cmd_insert"/> + <command id="cmd_viewSecurityStatus" + oncommand="showMessageComposeSecurityStatus();"/> + +#ifdef XP_MACOSX + <!-- Mac Window menu --> + <command id="minimizeWindow" label="&minimizeWindow.label;" oncommand="window.minimize();"/> + <command id="zoomWindow" label="&zoomWindow.label;" oncommand="zoomWindow();"/> +#endif + + <command id="cmd_CustomizeComposeToolbar" + oncommand="CustomizeMailToolbar('compose-toolbox', 'CustomizeComposeToolbar')"/> + + <command id="cmd_convertCloud" oncommand="convertSelectedToCloudAttachment(event.target.cloudFileAccount); event.stopPropagation();"/> + <command id="cmd_convertAttachment" oncommand="goDoCommand('cmd_convertAttachment')"/> + <command id="cmd_cancelUpload" oncommand="goDoCommand('cmd_cancelUpload')"/> + <command id="cmd_customizeFromAddress" oncommand="MakeFromFieldEditable();" + checked="false" label="&customizeFromAddress.label;"/> +</commandset> + + <commandset> + <command id="cmd_reload" oncommand="document.getElementById('requestFrame').reload()"/> + <command id="cmd_stop" oncommand="document.getElementById('requestFrame').stop()"/> + <command id="cmd_copyLink" oncommand="goDoCommand('cmd_copyLink')" disabled="false"/> + <command id="cmd_copyImage" oncommand="goDoCommand('cmd_copyImageContents')" disabled="false"/> + </commandset> + +<keyset id="tasksKeys"> + <!-- File Menu --> + <key id="key_newMessage" key="&newMessageCmd2.key;" oncommand="goOpenNewMessage(null);" modifiers="accel"/> + <key id="key_close" key="&closeCmd.key;" command="cmd_close" modifiers="accel"/> + <key id="key_save" key="&saveCmd.key;" command="cmd_saveDefault" modifiers="accel"/> + <key id="key_send" keycode="&sendCmd.keycode;" observes="cmd_sendWithCheck" modifiers="accel"/> + <key id="key_sendLater" keycode="&sendLaterCmd.keycode;" observes="cmd_sendLater" modifiers="accel, shift"/> + <key id="key_print" key="&printCmd.key;" command="cmd_print" modifiers="accel"/> + <key id="printKb" key="&printCmd.key;" command="cmd_print" modifiers="accel"/> + + <!-- Edit Menu --> + <key id="key_undo" data-l10n-id="text-action-undo-shortcut" modifiers="accel" internal="true"/> + <key id="key_redo" +#ifdef XP_UNIX + data-l10n-id="text-action-undo-shortcut" + modifiers="accel,shift" +#else + data-l10n-id="text-action-redo-shortcut" + modifiers="accel" +#endif + internal="true"/> + <key id="key_cut" data-l10n-id="text-action-cut-shortcut" modifiers="accel" internal="true"/> + <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" internal="true"/> + <key id="key_paste" data-l10n-id="text-action-paste-shortcut" modifiers="accel" internal="true"/> + <key id="pastequotationkb" key="&pasteAsQuotationCmd.key;" + observes="cmd_pasteQuote" modifiers="accel, shift"/> + <key id="pastenoformattingkb" key="&pasteNoFormattingCmd.key;" + modifiers="accel, shift" observes="cmd_pasteNoFormatting"/> + <key id="key_rewrap" key="&editRewrapCmd.key;" command="cmd_rewrap" modifiers="accel"/> +#ifdef XP_MACOSX + <key id="key_delete" keycode="VK_BACK" command="cmd_delete"/> + <key id="key_delete2" keycode="VK_DELETE" command="cmd_delete"/> +#else + <key id="key_delete" keycode="VK_DELETE" command="cmd_delete"/> + <key id="key_renameAttachment" keycode="VK_F2" + command="cmd_renameAttachment"/> +#endif + <key id="key_reorderAttachments" + key="&reorderAttachmentsCmd.key;" modifiers="accel,shift" + command="cmd_reorderAttachments"/> + <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" internal="true"/> + <key id="key_find" key="&findBarCmd.key;" command="cmd_find" modifiers="accel"/> +#ifndef XP_MACOSX + <key id="key_findReplace" key="&findReplaceCmd.key;" command="cmd_findReplace" modifiers="accel"/> +#endif + <key id="key_findNext" key="&findAgainCmd.key;" command="cmd_findNext" modifiers="accel"/> + <key id="key_findPrev" key="&findPrevCmd.key;" command="cmd_findPrev" modifiers="accel, shift"/> + <key keycode="&findAgainCmd.key2;" command="cmd_findNext"/> + <key keycode="&findPrevCmd.key2;" command="cmd_findPrev" modifiers="shift"/> + + <!-- Reorder Attachments Panel --> + <key id="key_moveAttachmentLeft" keycode="VK_LEFT" modifiers="alt" + command="cmd_moveAttachmentLeft"/> + <key id="key_moveAttachmentRight" keycode="VK_RIGHT" modifiers="alt" + command="cmd_moveAttachmentRight"/> + <key id="key_moveAttachmentBundleUp" keycode="VK_UP" modifiers="alt" + command="cmd_moveAttachmentBundleUp"/> + <key id="key_moveAttachmentBundleDown" keycode="VK_DOWN" modifiers="alt" + command="cmd_moveAttachmentBundleDown"/> +#ifdef XP_MACOSX + <key id="key_moveAttachmentTop" keycode="VK_UP" modifiers="accel alt" + command="cmd_moveAttachmentTop"/> + <key id="key_moveAttachmentBottom" keycode="VK_DOWN" modifiers="accel alt" + command="cmd_moveAttachmentBottom"/> + <key id="key_moveAttachmentTop2" keycode="VK_Home" modifiers="alt" + command="cmd_moveAttachmentTop"/> + <key id="key_moveAttachmentBottom2" keycode="VK_End" modifiers="alt" + command="cmd_moveAttachmentBottom"/> +#else + <key id="key_moveAttachmentTop" keycode="VK_Home" modifiers="alt" + command="cmd_moveAttachmentTop"/> + <key id="key_moveAttachmentBottom" keycode="VK_End" modifiers="alt" + command="cmd_moveAttachmentBottom"/> +#endif + <key id="key_sortAttachmentsToggle" key="&sortAttachmentsPanelBtn.key;" + modifiers="alt" command="cmd_sortAttachmentsToggle"/> + + <!-- View Menu --> + <key id="key_addressSidebar" keycode="VK_F9" oncommand="toggleContactsSidebar();"/> + + <keyset id="viewZoomKeys"> + <key id="key_fullZoomReduce" key="&fullZoomReduceCmd.commandkey;" + command="cmd_fullZoomReduce" modifiers="accel"/> + <key key="&fullZoomReduceCmd.commandkey2;" + command="cmd_fullZoomReduce" modifiers="accel"/> + <key id="key_fullZoomEnlarge" key="&fullZoomEnlargeCmd.commandkey;" + command="cmd_fullZoomEnlarge" modifiers="accel"/> + <key key="&fullZoomEnlargeCmd.commandkey2;" + command="cmd_fullZoomEnlarge" modifiers="accel"/> + <key key="&fullZoomEnlargeCmd.commandkey3;" + command="cmd_fullZoomEnlarge" modifiers="accel"/> + <key id="key_fullZoomReset" key="&fullZoomResetCmd.commandkey;" + command="cmd_fullZoomReset" modifiers="accel"/> + <key key="&fullZoomResetCmd.commandkey2;" + command="cmd_fullZoomReset" modifiers="accel"/> + </keyset> + + <!-- Options Menu --> + <key id="key_checkspelling" key="&checkSpellingCmd2.key;" command="cmd_spelling" modifiers="accel,shift"/> + +#ifdef XP_WIN + <key keycode="&checkSpellingCmd2.key2;" command="cmd_spelling"/> +#endif + + <!-- Tools Menu --> + <key id="key_mail" key="&messengerCmd.commandkey;" oncommand="toMessengerWindow();" modifiers="accel"/> + + <!-- Tab/F6 Keys --> + <key keycode="VK_TAB" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control"/> + <key keycode="VK_TAB" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control,shift"/> + <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control"/> + <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="control,shift"/> + <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);" modifiers="shift"/> + <key keycode="VK_F6" oncommand="moveFocusToNeighbouringArea(event);"/> + +#ifdef XP_MACOSX + <!-- Mac Window Menu --> + <key id="key_minimizeWindow" command="minimizeWindow" key="&minimizeWindow.key;" modifiers="accel"/> + <key id="key_openHelp" oncommand="openSupportURL();" key="&productHelpMac.commandkey;" modifiers="&productHelpMac.modifiers;"/> +#else + <key id="key_openHelp" oncommand="openSupportURL();" keycode="&productHelp.commandkey;"/> +#endif + <key keycode="VK_ESCAPE" oncommand="handleEsc();"/> +</keyset> + +<keyset id="editorKeys"> + <key id="boldkb" key="&styleBoldCmd.key;" observes="cmd_bold" modifiers="accel"/> + <key id="italickb" key="&styleItalicCmd.key;" observes="cmd_italic" modifiers="accel"/> + <key id="underlinekb" key="&styleUnderlineCmd.key;" observes="cmd_underline" modifiers="accel"/> + <key id="fixedwidthkb" key="&fontFixedWidth.key;" observes="cmd_tt" modifiers="accel"/> + + <key id="increaseindentkb" key="&increaseIndent.key;" observes="cmd_indent" modifiers="accel"/> + <key id="decreaseindentkb" key="&decreaseIndent.key;" observes="cmd_outdent" modifiers="accel"/> + + <key id="removestyleskb" key="&formatRemoveStyles.key;" observes="cmd_removeStyles" modifiers="accel, shift"/> + <key id="removestyleskb2" key=" " observes="cmd_removeStyles" modifiers="accel"/> + <key id="removelinkskb" key="&formatRemoveLinks.key;" observes="cmd_removeLinks" modifiers="accel, shift"/> + <key id="removenamedanchorskb" key="&formatRemoveNamedAnchors2.key;" observes="cmd_removeNamedAnchors" modifiers="accel, shift"/> + <key id="decreasefontsizekb" key="&decrementFontSize.key;" observes="cmd_decreaseFontStep" modifiers="accel"/> + <key key="&decrementFontSize.key;" observes="cmd_decreaseFontStep" modifiers="accel, shift"/> + <key key="&decrementFontSize.key2;" observes="cmd_decreaseFontStep" modifiers="accel"/> + + <key id="increasefontsizekb" key="&incrementFontSize.key;" observes="cmd_increaseFontStep" modifiers="accel"/> + <key key="&incrementFontSize.key;" observes="cmd_increaseFontStep" modifiers="accel, shift"/> + <key key="&incrementFontSize.key2;" observes="cmd_increaseFontStep" modifiers="accel"/> + + <key id="insertlinkkb" key="&insertLinkCmd2.key;" observes="cmd_link" modifiers="accel"/> +</keyset> + +<popupset id="mainPopupSet"> +#include ../../../base/content/widgets/browserPopups.inc.xhtml +</popupset> + +<!-- Reorder Attachments Panel --> +<panel id="reorderAttachmentsPanel" + orient="vertical" + type="arrow" + flip="slide" + onpopupshowing="reorderAttachmentsPanelOnPopupShowing();" + consumeoutsideclicks="false" + noautohide="true"> + <description class="panelTitle">&reorderAttachmentsPanel.label;</description> + <toolbarbutton id="btn_moveAttachmentFirst" + class="panelButton" + data-l10n-id="move-attachment-first-panel-button" + key="key_moveAttachmentTop" + command="cmd_moveAttachmentTop"/> + <toolbarbutton id="btn_moveAttachmentLeft" + class="panelButton" + data-l10n-id="move-attachment-left-panel-button" + key="key_moveAttachmentLeft" + command="cmd_moveAttachmentLeft"/> + <toolbarbutton id="btn_moveAttachmentBundleUp" + class="panelButton" + label="&moveAttachmentBundleUpPanelBtn.label;" + key="key_moveAttachmentBundleUp" + command="cmd_moveAttachmentBundleUp"/> + <toolbarbutton id="btn_moveAttachmentRight" + class="panelButton" + data-l10n-id="move-attachment-right-panel-button" + key="key_moveAttachmentRight" + command="cmd_moveAttachmentRight"/> + <toolbarbutton id="btn_moveAttachmentLast" + class="panelButton" + data-l10n-id="move-attachment-last-panel-button" + key="key_moveAttachmentBottom" + command="cmd_moveAttachmentBottom"/> + <toolbarbutton id="btn_sortAttachmentsToggle" + class="panelButton" + label="&sortAttachmentsPanelBtn.Sort.AZ.label;" + label-AZ="&sortAttachmentsPanelBtn.Sort.AZ.label;" + label-ZA="&sortAttachmentsPanelBtn.Sort.ZA.label;" + label-selection-AZ="&sortAttachmentsPanelBtn.SortSelection.AZ.label;" + label-selection-ZA="&sortAttachmentsPanelBtn.SortSelection.ZA.label;" + key="key_sortAttachmentsToggle" + command="cmd_sortAttachmentsToggle"/> +</panel> + +<menupopup id="extraAddressRowsMenu" + class="no-icon-menupopup no-accel-menupopup" + onpopupshown="extraAddressRowsMenuOpened();" + onpopuphidden="extraAddressRowsMenuClosed();"> + <!-- Default set up is for a mail account, where we prefer showing the + - buttons, rather than the menu items, for the mail rows. + - For the news rows, we prefer the menu items over the buttons. --> + <menuitem id="addr_replyShowAddressRowMenuItem" + class="menuitem-iconic" + oncommand="showAndFocusAddressRow('addressRowReply')" + label="&replyAddr2.label;"/> + <menuitem id="addr_toShowAddressRowMenuItem" disableonsend="true" + class="mail-show-row-menuitem menuitem-iconic" + oncommand="showAndFocusAddressRow('addressRowTo')" + hidden="true" + data-button-id="addr_toShowAddressRowButton" + data-prefer-button="true"/> + <menuitem id="addr_ccShowAddressRowMenuItem" disableonsend="true" + class="mail-show-row-menuitem menuitem-iconic" + oncommand="showAndFocusAddressRow('addressRowCc')" + hidden="true" + data-button-id="addr_ccShowAddressRowButton" + data-prefer-button="true"/> + <menuitem id="addr_bccShowAddressRowMenuItem" disableonsend="true" + class="mail-show-row-menuitem menuitem-iconic" + oncommand="showAndFocusAddressRow('addressRowBcc')" + hidden="true" + data-button-id="addr_bccShowAddressRowButton" + data-prefer-button="true"/> + <menuitem id="addr_newsgroupsShowAddressRowMenuItem" + class="news-show-row-menuitem menuitem-iconic" + oncommand="showAndFocusAddressRow('addressRowNewsgroups')" + data-button-id="addr_newsgroupsShowAddressRowButton" + label="&newsgroupsAddr2.label;" + data-prefer-button="false"/> + <menuitem id="addr_followupShowAddressRowMenuItem" + class="news-show-row-menuitem menuitem-iconic" + oncommand="showAndFocusAddressRow('addressRowFollowup')" + data-button-id="addr_followupShowAddressRowButton" + label="&followupAddr2.label;" + data-prefer-button="false"/> +</menupopup> + +<menupopup id="msgComposeContext" + onpopupshowing="msgComposeContextOnShowing(event);" + onpopuphiding="msgComposeContextOnHiding(event);"> + + <!-- Spellchecking menu items --> + <menuitem id="spellCheckNoSuggestions" + data-l10n-id="text-action-spell-no-suggestions" + disabled="true"/> + <menuseparator id="spellCheckAddSep" /> + <menuitem id="spellCheckAddToDictionary" + data-l10n-id="text-action-spell-add-to-dictionary" + oncommand="gSpellChecker.addToDictionary();"/> + <menuitem id="spellCheckUndoAddToDictionary" + data-l10n-id="text-action-spell-undo-add-to-dictionary" + oncommand="gSpellChecker.undoAddToDictionary();" /> + <menuitem id="spellCheckIgnoreWord" label="&spellCheckIgnoreWord.label;" + accesskey="&spellCheckIgnoreWord.accesskey;" + oncommand="gSpellChecker.ignoreWord();"/> + <menuseparator id="spellCheckSuggestionsSeparator"/> + + <menuitem data-l10n-id="text-action-undo" command="cmd_undo"/> + <menuitem data-l10n-id="text-action-cut" command="cmd_cut"/> + <menuitem data-l10n-id="text-action-copy" command="cmd_copy"/> + <menuitem data-l10n-id="text-action-paste" command="cmd_paste"/> + <menuitem command="cmd_pasteNoFormatting"/> + <menuitem label="&pasteQuote.label;" accesskey="&pasteQuote.accesskey;" command="cmd_pasteQuote"/> + <menuitem data-l10n-id="text-action-delete" command="cmd_delete"/> + <menuseparator/> + <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll"/> + + <!-- Spellchecking general menu items (enable, add dictionaries...) --> + <menuseparator id="spellCheckSeparator"/> + <menuitem id="spellCheckEnable" + data-l10n-id="text-action-spell-check-toggle" + type="checkbox" + oncommand="toggleSpellCheckingEnabled();"/> + <menuitem id="spellCheckAddDictionariesMain" + label="&spellAddDictionaries.label;" + accesskey="&spellAddDictionaries.accesskey;" + oncommand="openDictionaryList();"/> + <menu id="spellCheckDictionaries" + data-l10n-id="text-action-spell-dictionaries"> + <menupopup id="spellCheckDictionariesMenu"> + <menuseparator id="spellCheckLanguageSeparator"/> + <menuitem id="spellCheckAddDictionaries" + label="&spellAddDictionaries.label;" + accesskey="&spellAddDictionaries.accesskey;" + oncommand="openDictionaryList();"/> + </menupopup> + </menu> + +</menupopup> + +<menupopup id="msgComposeAttachmentItemContext" + onpopupshowing="updateAttachmentItems();"> + <menuitem id="composeAttachmentContext_openItem" + label="&openAttachment.label;" + accesskey="&openAttachment.accesskey;" + command="cmd_openAttachment"/> + <menuitem id="composeAttachmentContext_renameItem" + label="&renameAttachment.label;" + accesskey="&renameAttachment.accesskey;" + command="cmd_renameAttachment"/> + <menuitem id="composeAttachmentContext_reorderItem" + label="&reorderAttachments.label;" + accesskey="&reorderAttachments.accesskey;" + command="cmd_reorderAttachments"/> + <menuseparator id="composeAttachmentContext_beforeRemoveSeparator"/> + <menuitem id="composeAttachmentContext_deleteItem" + label="&removeAttachment.label;" + accesskey="&removeAttachment.accesskey;" + command="cmd_delete"/> + <menu id="composeAttachmentContext_convertCloudMenu" + label="&convertCloud.label;" + accesskey="&convertCloud.accesskey;" + command="cmd_convertCloud"> + <menupopup id="convertCloudMenuItems_popup" + onpopupshowing="addConvertCloudMenuItems(this, 'convertCloudSeparator', 'context_convertCloud');"> + <menuitem id="convertCloudMenuItems_popup_convertAttachment" + type="radio" name="context_convertCloud" + label="&convertRegularAttachment.label;" + accesskey="&convertRegularAttachment.accesskey;" + command="cmd_convertAttachment"/> + <menuseparator id="convertCloudSeparator"/> + </menupopup> + </menu> + <menuitem id="composeAttachmentContext_cancelUploadItem" + label="&cancelUpload.label;" + accesskey="&cancelUpload.accesskey;" + command="cmd_cancelUpload"/> + <menuseparator/> + <menuitem id="composeAttachmentContext_selectAllItem" + label="&selectAll.label;" + accesskey="&selectAll.accesskey;" + command="cmd_selectAll"/> +</menupopup> + +<menupopup id="msgComposeAttachmentListContext" + onpopupshowing="updateAttachmentItems();"> + <menuitem id="attachmentListContext_selectAllItem" + label="&selectAll.label;" + accesskey="&selectAll.accesskey;" + command="cmd_selectAll"/> + <menuseparator/> + <menuitem id="attachmentListContext_attachFileItem" + data-l10n-id="context-menuitem-attach-files" + data-l10n-attrs="acceltext" + command="cmd_attachFile"/> + <menu id="attachmentListContext_attachCloudMenu" + label="&attachCloud.label;" + accesskey="&attachCloud.accesskey;" + command="cmd_attachCloud"> + <menupopup id="attachCloudMenu_attachCloudPopup" onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/> + </menu> + <menuitem id="attachmentListContext_attachPageItem" + label="&attachPage.label;" + accesskey="&attachPage.accesskey;" + command="cmd_attachPage"/> + <menuseparator id="attachmentListContext_remindLaterSeparator"/> + <menuitem id="attachmentListContext_remindLaterItem" + type="checkbox" + label="&remindLater.label;" + accesskey="&remindLater.accesskey;" + command="cmd_remindLater"/> + <menuitem id="attachmentListContext_reorderItem" + label="&reorderAttachments.label;" + accesskey="&reorderAttachments.accesskey;" + command="cmd_reorderAttachments"/> + <menuseparator id="attachmentListContext_removeAllSeparator"/> + <menuitem id="attachmentListContext_removeAllItem" + label="&removeAllAttachments.label;" + accesskey="&removeAllAttachments.accesskey;" + command="cmd_removeAllAttachments"/> +</menupopup> + +<menupopup id="attachmentHeaderContext" + onpopupshowing="attachmentHeaderContextOnPopupShowing();"> + <menuitem id="attachmentHeaderContext_initiallyShowItem" + type="checkbox" + label="&initiallyShowAttachmentPane.label;" + accesskey="&initiallyShowAttachmentPane.accesskey;" + oncommand="toggleInitiallyShowAttachmentPane(this);"/> +</menupopup> + +<menupopup id="format-toolbar-context-menu" + onpopupshowing="ToolbarContextMenu.updateExtension(this);"> + <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-manage-extension" + class="customize-context-manageExtension"/> + <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-remove-extension" + class="customize-context-removeExtension"/> +</menupopup> + +<menupopup id="toolbar-context-menu" + onpopupshowing="onViewToolbarsPopupShowing(event, 'compose-toolbox'); ToolbarContextMenu.updateExtension(this);"> + <menuseparator/> + <menuitem id="CustomizeComposeToolbar" + command="cmd_CustomizeComposeToolbar" + label="&customizeToolbar.label;" + accesskey="&customizeToolbar.accesskey;"/> + <menuseparator id="extensionsMailToolbarMenuSeparator"/> + <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-manage-extension" + class="customize-context-manageExtension"/> + <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-remove-extension" + class="customize-context-removeExtension"/> +</menupopup> + +<menupopup id="blockedContentOptions" value="" + onpopupshowing="onBlockedContentOptionsShowing(event);"> +</menupopup> + +<menupopup id="languageMenuList" + oncommand="ChangeLanguage(event);" + onpopupshowing="OnShowDictionaryMenu(event.target);" + onpopupshown="languageMenuListOpened();" + onpopuphidden="languageMenuListClosed();"> +</menupopup> + +<menupopup id="emailAddressPillPopup" + class="emailAddressPopup" + onpopupshowing="onPillPopupShowing(event);"> + <menuitem id="editAddressPill" + class="pill-action-edit" + data-l10n-id="pill-action-edit" + oncommand="editAddressPill(this.parentNode.triggerNode, event)"/> + <menuitem id="menu_delete" + data-l10n-id="text-action-delete" + oncommand="deleteSelectedPillsOnCommand()"/> + <menuseparator/> + <menuitem id="menu_cut" + data-l10n-id="text-action-cut" + oncommand="cutSelectedPillsOnCommand()"/> + <menuitem id="menu_copy" + data-l10n-id="text-action-copy" + oncommand="copySelectedPillsOnCommand()"/> + <menuseparator id="pillContextBeforeSelectAllSeparator"/> + <menuitem id="menu_selectAllSiblingPills" + oncommand="selectAllSiblingPillsOnCommand(this.parentNode.triggerNode)"/> + <menuitem id="menu_selectAllPills" + data-l10n-id="pill-action-select-all-pills" + oncommand="selectAllPillsOnCommand()"/> + <menuseparator id="pillContextBeforeMoveItemsSeparator"/> + <menuitem id="moveAddressPillTo" + class="pill-action-move" + data-l10n-id="pill-action-move-to" + oncommand="moveSelectedPillsOnCommand('addressRowTo')"/> + <menuitem id="moveAddressPillCc" + class="pill-action-move" + data-l10n-id="pill-action-move-cc" + oncommand="moveSelectedPillsOnCommand('addressRowCc')"/> + <menuitem id="moveAddressPillBcc" + class="pill-action-move" + data-l10n-id="pill-action-move-bcc" + oncommand="moveSelectedPillsOnCommand('addressRowBcc')"/> + <menuseparator id="pillContextBeforeExpandListSeparator"/> + <menuitem id="expandList" + class="pill-action-edit" + data-l10n-id="pill-action-expand-list" + hidden="true" + oncommand="expandList(this.parentNode.triggerNode)"/> +</menupopup> + + <toolbox id="compose-toolbox" + class="toolbox-top" + mode="full" + defaultmode="full" +#ifdef XP_MACOSX + iconsize="small" + defaulticonsize="small" +#endif + labelalign="end" + defaultlabelalign="end"> + +#ifdef XP_MACOSX + <hbox id="titlebar"> + <hbox id="titlebar-title" align="center" flex="1"> + <label id="titlebar-title-label" value="&msgComposeWindow.title;" flex="1" crop="end"/> + </hbox> +#include ../../../base/content/messenger-titlebar-items.inc.xhtml + </hbox> +#endif + <!-- Menu --> + <!-- if you change the id of the menubar, be sure to update mailCore.js::CustomizeMailToolbar and MailToolboxCustomizeDone --> + <toolbar is="customizable-toolbar" id="compose-toolbar-menubar2" + class="chromeclass-menubar themeable-full" + type="menubar" + customizable="true" +#ifdef XP_MACOSX + defaultset="menubar-items" +#else + defaultset="menubar-items,spring" +#endif +#ifdef XP_WIN + toolbarname="&menubarCmd.label;" + accesskey="&menubarCmd.accesskey;" +#endif + context="toolbar-context-menu" mode="full"> + + <toolbaritem id="menubar-items" align="center"> + <menubar id="mail-menubar"> + <menu id="menu_File" label="&fileMenu.label;" accesskey="&fileMenu.accesskey;"> + <menupopup id="menu_FilePopup"> + <menu id="menu_New" + label="&newMenu.label;" accesskey="&newMenu.accesskey;"> + <menupopup id="menu_NewPopup"> + <menuitem id="menu_NewMessage" + label="&newMessage.label;" accesskey="&newMessage.accesskey;" + key="key_newMessage" oncommand="goOpenNewMessage(event);"/> + <menuseparator/> + <menuitem id="menu_NewContact" label="&newContact.label;" + accesskey="&newContact.accesskey;" + oncommand="toAddressBook({ action: 'create' });"/> + </menupopup> + </menu> + <menu id="menu_Attach" label="&attachMenu.label;" accesskey="&attachMenu.accesskey;"> + <menupopup id="menu_AttachPopup" onpopupshowing="updateAttachmentItems();"> + <menuitem data-l10n-id="menuitem-attach-files" + data-l10n-attrs="acceltext" + command="cmd_attachFile"/> + <menu label="&attachCloudCmd.label;" accesskey="&attachCloudCmd.accesskey;" + command="cmd_attachCloud"> + <menupopup onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/> + </menu> + <menuitem label="&attachPageCmd.label;" + accesskey="&attachPageCmd.accesskey;" command="cmd_attachPage"/> + <menuseparator/> + <menuitem type="checkbox" + data-l10n-id="context-menuitem-attach-vcard" + command="cmd_attachVCard"/> + <menuitem id="menu_AttachPopup_attachPublicKey" type="checkbox" + data-l10n-id="context-menuitem-attach-openpgp-key" + command="cmd_attachPublicKey"/> + <menuseparator id="menu_Attach_RemindLaterSeparator"/> + <menuitem id="menu_Attach_RemindLaterItem" type="checkbox" label="&remindLater.label;" + accesskey="&remindLater.accesskey;" command="cmd_remindLater"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem id="menu_SaveCmd" label="&saveCmd.label;" accesskey="&saveCmd.accesskey;" + key="key_save" command="cmd_saveDefault"/> + <menu id="menu_SaveAsCmd" + label="&saveAsCmd.label;" accesskey="&saveAsCmd.accesskey;"> + <menupopup id="menu_SaveAsCmdPopup" onpopupshowing="InitFileSaveAsMenu();"> + <menuitem id="menu_SaveAsFileCmd" + label="&saveAsFileCmd.label;" accesskey="&saveAsFileCmd.accesskey;" + command="cmd_saveAsFile" type="radio" name="radiogroup_SaveAs"/> + <menuseparator/> + <menuitem label="&saveAsDraftCmd.label;" accesskey="&saveAsDraftCmd.accesskey;" + command="cmd_saveAsDraft" type="radio" name="radiogroup_SaveAs"/> + <menuitem label="&saveAsTemplateCmd.label;" accesskey="&saveAsTemplateCmd.accesskey;" + command="cmd_saveAsTemplate" type="radio" name="radiogroup_SaveAs"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem label="&sendNowCmd.label;" + accesskey="&sendNowCmd.accesskey;" key="key_send" + command="cmd_sendNow" id="menu-item-send-now"/> + <menuitem label="&sendLaterCmd.label;" + accesskey="&sendLaterCmd.accesskey;" key="key_sendLater" + command="cmd_sendLater"/> + <menuseparator/> + <menuitem id="printMenuItem" + label="&printCmd.label;" accesskey="&printCmd.accesskey;" + key="key_print" command="cmd_print"/> + <menuseparator id="menu_FileCloseSeparator"/> + <menuitem id="menu_close" + label="&closeCmd.label;" + key="key_close" + accesskey="&closeCmd.accesskey;" + command="cmd_close"/> + </menupopup> + </menu> + + <!-- Edit Menu --> + <menu id="menu_Edit" label="&editMenu.label;" accesskey="&editMenu.accesskey;"> + <menupopup id="menu_EditPopup" onpopupshowing="updateEditItems();"> + <menuitem id="menu_undo" + data-l10n-id="text-action-undo" + key="key_undo" command="cmd_undo"/> + <menuitem id="menu_redo" + data-l10n-id="text-action-redo" + key="key_redo" command="cmd_redo"/> + <menuseparator/> + <menuitem id="menu_cut" + data-l10n-id="text-action-cut" + key="key_cut" command="cmd_cut"/> + <menuitem id="menu_copy" + data-l10n-id="text-action-copy" + key="key_copy" command="cmd_copy"/> + <menuitem id="menu_paste" + data-l10n-id="text-action-paste" + key="key_paste" command="cmd_paste"/> + <menuitem id="menu_pasteNoFormatting" + command="cmd_pasteNoFormatting" key="pastenoformattingkb"/> + <menuitem id="menu_pasteQuote" + accesskey="&pasteAsQuotationCmd.accesskey;" + command="cmd_pasteQuote" + key="pastequotationkb"/> + <menuitem id="menu_delete" + data-l10n-id="text-action-delete" + key="key_delete" + command="cmd_delete"/> + <menuseparator/> + <menuitem id="menu_rewrap" + label="&editRewrapCmd.label;" + accesskey="&editRewrapCmd.accesskey;" + key="key_rewrap" + command="cmd_rewrap"/> + <menuitem id="menu_RenameAttachment" + label="&renameAttachmentCmd.label;" + accesskey="&renameAttachmentCmd.accesskey;" + key="key_renameAttachment" + command="cmd_renameAttachment"/> + <menuitem id="menu_reorderAttachments" + label="&reorderAttachmentsCmd.label;" + accesskey="&reorderAttachmentsCmd.accesskey;" + key="key_reorderAttachments" + command="cmd_reorderAttachments"/> + <menuseparator/> + <menuitem id="menu_selectAll" + data-l10n-id="text-action-select-all" + key="key_selectAll" + command="cmd_selectAll"/> + <menuseparator/> + <menuitem id="menu_findBar" + label="&findBarCmd.label;" + accesskey="&findBarCmd.accesskey;" + key="key_find" + command="cmd_find"/> +#ifndef XP_MACOSX + <menuitem id="menu_findReplace" + label="&findReplaceCmd.label;" + accesskey="&findReplaceCmd.accesskey;" + key="key_findReplace" + command="cmd_findReplace"/> +#else + <menuitem id="menu_findReplace" + label="&findReplaceCmd.label;" + accesskey="&findReplaceCmd.accesskey;" + command="cmd_findReplace"/> +#endif + <menuitem id="menu_findNext" + label="&findAgainCmd.label;" + accesskey="&findAgainCmd.accesskey;" + key="key_findNext" + command="cmd_findNext"/> + <menuitem id="menu_findPrev" + label="&findPrevCmd.label;" + accesskey="&findPrevCmd.accesskey;" + key="key_findPrev" + command="cmd_findPrev"/> +#ifdef XP_UNIX +#ifndef XP_MACOSX + <menuseparator id="prefSep"/> + <menuitem id="menu_accountmgr" + label="&accountManagerCmd2.label;" + accesskey="&accountManagerCmdUnix2.accesskey;" + command="cmd_account"/> + <menuitem id="menu_preferences" + data-l10n-id="menu-tools-settings" + oncommand="openOptionsDialog('paneCompose');"/> +#endif +#endif + </menupopup> + </menu> + + <!-- View Menu --> + <menu id="menu_View" label="&viewMenu.label;" accesskey="&viewMenu.accesskey;"> + <menupopup id="menu_View_Popup" onpopupshowing="updateViewItems();"> + <menu id="menu_ToolbarsNew" + label="&viewToolbarsMenuNew.label;" + accesskey="&viewToolbarsMenuNew.accesskey;" + onpopupshowing="onViewToolbarsPopupShowing(event, 'compose-toolbox');"> + <menupopup id="view_toolbars_popup"> + <menuitem id="menu_showFormatToolbar" + type="checkbox" + label="&showFormattingBarCmd.label;" + accesskey="&showFormattingBarCmd.accesskey;" + command="cmd_showFormatToolbar" + checked="true"/> + <menuitem id="menu_showTaskbar" + type="checkbox" + label="&showTaskbarCmd.label;" + accesskey="&showTaskbarCmd.accesskey;" + oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')" + checked="true"/> + <menuseparator id="viewMenuBeforeCustomizeComposeToolbarsSeparator"/> + <menuitem id="customizeComposeToolbars" + label="&customizeToolbar.label;" + accesskey="&customizeToolbar.accesskey;" + command="cmd_CustomizeComposeToolbar"/> + </menupopup> + </menu> + <menu id="viewFullZoomMenu" label="&fullZoom.label;" accesskey="&fullZoom.accesskey;" + onpopupshowing="UpdateFullZoomMenu()"> + <menupopup id="viewFullZoomPopupMenu"> + <menuitem id="menu_fullZoomEnlarge" key="key_fullZoomEnlarge" + label="&fullZoomEnlargeCmd.label;" + accesskey="&fullZoomEnlargeCmd.accesskey;" + command="cmd_fullZoomEnlarge"/> + <menuitem id="menu_fullZoomReduce" key="key_fullZoomReduce" + label="&fullZoomReduceCmd.label;" + accesskey="&fullZoomReduceCmd.accesskey;" + command="cmd_fullZoomReduce"/> + <menuseparator id="fullZoomAfterReduceSeparator"/> + <menuitem id="menu_fullZoomReset" key="key_fullZoomReset" + label="&fullZoomResetCmd.label;" + accesskey="&fullZoomResetCmd.accesskey;" + command="cmd_fullZoomReset"/> + <menuseparator id="fullZoomAfterResetSeparator"/> + <menuitem id="menu_fullZoomToggle" label="&fullZoomToggleCmd.label;" + accesskey="&fullZoomToggleCmd.accesskey;" + type="checkbox" command="cmd_fullZoomToggle" checked="false"/> + </menupopup> + </menu> + <menuseparator id="viewMenuBeforeShowToFieldSeparator"/> + <menuitem id="menu_showToField" + data-l10n-attrs="acceltext" + oncommand="showAndFocusAddressRow('addressRowTo')"/> + <menuitem id="menu_showCcField" + data-l10n-attrs="acceltext" + oncommand="showAndFocusAddressRow('addressRowCc')"/> + <menuitem id="menu_showBccField" + data-l10n-attrs="acceltext" + oncommand="showAndFocusAddressRow('addressRowBcc')"/> + <menuseparator id="viewMenuBeforeAddressSidebarSeparator"/> + <menuitem id="menu_AddressSidebar" + label="&addressSidebar.label;" accesskey="&addressSidebar.accesskey;" + type="checkbox" + key="key_addressSidebar" + oncommand="toggleContactsSidebar();"/> + <menuitem id="menu_toggleAttachmentPane" + data-l10n-id="menuitem-toggle-attachment-pane" + data-l10n-attrs="acceltext" + type="checkbox" + command="cmd_toggleAttachmentPane"/> + </menupopup> + </menu> + + <menu id="insertMenu" label="&insertMenu.label;" + accesskey="&insertMenu.accesskey;" command="cmd_renderedHTMLEnabler"> + <menupopup id="insertMenuPopup"> + <menuitem id="insertImage" + label="&insertImageCmd.label;" + accesskey="&insertImageCmd.accesskey;" + observes="cmd_image"/> + <menuitem id="insertTable" + label="&insertTableCmd.label;" + accesskey="&insertTableCmd.accesskey;" + observes="cmd_InsertTable"/> + <menuitem id="insertLink" + label="&insertLinkCmd2.label;" + accesskey="&insertLinkCmd2.accesskey;" + key="insertlinkkb" + observes="cmd_link"/> + <menuitem id="insertAnchor" + label="&insertAnchorCmd.label;" + accesskey="&insertAnchorCmd.accesskey;" + observes="cmd_anchor"/> + <menuitem id="insertHline" + label="&insertHLineCmd.label;" + accesskey="&insertHLineCmd.accesskey;" + observes="cmd_hline"/> + <menuitem id="insertHTMLSource" + accesskey="&insertHTMLCmd.accesskey;" + observes="cmd_insertHTMLWithDialog"/> + <menuitem id="insertMath" + accesskey="&insertMathCmd.accesskey;" + observes="cmd_insertMathWithDialog"/> + <menuitem id="insertChars" + accesskey="&insertCharsCmd.accesskey;" + command="cmd_insertChars"/> + + <menu id="insertTOC" label="&tocMenu.label;" accesskey="&tocMenu.accesskey;"> + <menupopup id="insertTOCPopup" onpopupshowing="InitTOCMenu()"> + <menuitem id="insertTOCMenuitem" + label="&insertTOC.label;" + accesskey="&insertTOC.accesskey;" + oncommand="UpdateTOC()"/> + <menuitem id="updateTOCMenuitem" + label="&updateTOC.label;" + accesskey="&updateTOC.accesskey;" + oncommand="UpdateTOC()"/> + <menuitem id="removeTOCMenuitem" + label="&removeTOC.label;" + accesskey="&removeTOC.accesskey;" + oncommand="RemoveTOC()"/> + </menupopup> + </menu> + <menuseparator id="insertMenuSeparator"/> + <menuitem id="insertBreakAll" + accesskey="&insertBreakAllCmd.accesskey;" + observes="cmd_insertBreakAll" + label="&insertBreakAllCmd.label;"/> + </menupopup> + </menu> + + <menu id="formatMenu" label="&formatMenu.label;" accesskey="&formatMenu.accesskey;" command="cmd_renderedHTMLEnabler"> + <menupopup id="formatMenuPopup" onpopupshowing="EditorInitFormatMenu()"> + <!-- Font face submenu --> + <menu id="fontFaceMenu" + label="&fontfaceMenu.label;" accesskey="&fontfaceMenu.accesskey;" + position="1"> + <menupopup id="fontFaceMenuPopup" + oncommand="if (event.target.localName == 'menuitem') { + doStatefulCommand('cmd_fontFace', event.target.getAttribute('value')); + }" + onpopupshowing="initFontFaceMenu(this);"> + <menuitem id="menu_fontFaceVarWidth" + label="&fontVarWidth.label;" + accesskey="&fontVarWidth.accesskey;" + value="" + type="radio" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_fontFaceFixedWidth" + label="&fontFixedWidth.label;" + accesskey="&fontFixedWidth.accesskey;" + value="monospace" + type="radio" + observes="cmd_renderedHTMLEnabler"/> + <menuseparator id="fontFaceMenuAfterGenericFontsSeparator"/> + <menuitem id="menu_fontFaceHelvetica" + label="&fontHelvetica.label;" + accesskey="&fontHelvetica.accesskey;" + value="Helvetica, Arial, sans-serif" + value_parsed="helvetica,arial,sans-serif" + type="radio" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_fontFaceTimes" + label="&fontTimes.label;" + accesskey="&fontTimes.accesskey;" + value="Times New Roman, Times, serif" + value_parsed="times new roman,times,serif" + type="radio" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_fontFaceCourier" + label="&fontCourier.label;" + accesskey="&fontCourier.accesskey;" + value="Courier New, Courier, monospace" + value_parsed="courier new,courier,monospace" + type="radio" + observes="cmd_renderedHTMLEnabler"/> + <menuseparator id="fontFaceMenuAfterDefaultFontsSeparator" + class="fontFaceMenuAfterDefaultFonts"/> + <menuseparator id="fontFaceMenuAfterUsedFontsSeparator" + class="fontFaceMenuAfterUsedFonts" + hidden="true"/> + <!-- Local font face items added here by initLocalFontFaceMenu() --> + </menupopup> + </menu> + + <!-- Font size submenu --> + <menu id="fontSizeMenu" label="&fontSizeMenu.label;" + accesskey="&fontSizeMenu.accesskey;" + position="2"> + <menupopup id="fontSizeMenuPopup" + onpopupshowing="initFontSizeMenu(this)" + oncommand="setFontSize(event)"> + <menuitem id="menu_decreaseFontSize" + label="&decreaseFontSize.label;" + accesskey="&decreaseFontSize.accesskey;" + key="decreasefontsizekb" + observes="cmd_decreaseFontStep" + type="radio" name="decreaseFontSize" autocheck="false"/> + <menuitem id="menu_increaseFontSize" + label="&increaseFontSize.label;" + accesskey="&increaseFontSize.accesskey;" + key="increasefontsizekb" + observes="cmd_increaseFontStep" + type="radio" name="increaseFontSize" autocheck="false"/> + <menuseparator id="fontSizeMenuAfterIncreaseFontSizeSeparator"/> + <menuitem id="menu_size-x-small" + label="&size-tinyCmd.label;" + accesskey="&size-tinyCmd.accesskey;" + value="1" + type="radio" name="fontSize" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_size-small" + label="&size-smallCmd.label;" + accesskey="&size-smallCmd.accesskey;" + value="2" + type="radio" name="fontSize" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_size-medium" + label="&size-mediumCmd.label;" + accesskey="&size-mediumCmd.accesskey;" + value="3" + type="radio" name="fontSize" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_size-large" + label="&size-largeCmd.label;" + accesskey="&size-largeCmd.accesskey;" + value="4" + type="radio" name="fontSize" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_size-x-large" + label="&size-extraLargeCmd.label;" + accesskey="&size-extraLargeCmd.accesskey;" + value="5" + type="radio" name="fontSize" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_size-xx-large" + label="&size-hugeCmd.label;" + accesskey="&size-hugeCmd.accesskey;" + value="6" + type="radio" name="fontSize" + observes="cmd_renderedHTMLEnabler"/> + </menupopup> + </menu> + + <!-- Font style submenu --> + <menu id="fontStyleMenu" label="&fontStyleMenu.label;" + accesskey="&fontStyleMenu.accesskey;" + position="3"> + <menupopup id="fontStyleMenuPopup" onpopupshowing="initFontStyleMenu(this)"> + <menuitem id="menu_styleBold" + label="&styleBoldCmd.label;" + accesskey="&styleBoldCmd.accesskey;" + observes="cmd_bold" + type="checkbox" + key="boldkb"/> + <menuitem id="menu_styleItalic" + label="&styleItalicCmd.label;" + accesskey="&styleItalicCmd.accesskey;" + observes="cmd_italic" + type="checkbox" + key="italickb"/> + <menuitem id="menu_styleUnderline" + label="&styleUnderlineCmd.label;" + accesskey="&styleUnderlineCmd.accesskey;" + observes="cmd_underline" + type="checkbox" + key="underlinekb"/> + <menuitem id="menu_styleStrikeThru" + label="&styleStrikeThruCmd.label;" + accesskey="&styleStrikeThruCmd.accesskey;" + observes="cmd_strikethrough" + type="checkbox"/> + <menuitem id="menu_styleSuperscript" + label="&styleSuperscriptCmd.label;" + accesskey="&styleSuperscriptCmd.accesskey;" + observes="cmd_superscript" + type="checkbox"/> + <menuitem id="menu_styleSubscript" + label="&styleSubscriptCmd.label;" + accesskey="&styleSubscriptCmd.accesskey;" + observes="cmd_subscript" + type="checkbox"/> + <menuitem id="menu_fontFixedWidth" + label="&fontFixedWidth.label;" + accesskey="&fontFixedWidth.accesskey;" + observes="cmd_tt" + type="checkbox" + key="fixedwidthkb"/> + <menuitem id="menu_styleNonbreaking" + label="&styleNonbreakingCmd.label;" + accesskey="&styleNonbreakingCmd.accesskey;" + observes="cmd_nobreak" + type="checkbox"/> + <menuseparator id="fontStyleMenuAfterNonbreakingSeparator"/> + <menuitem id="menu_styleEm" + label="&styleEm.label;" + accesskey="&styleEm.accesskey;" + observes="cmd_em" + type="checkbox"/> + <menuitem id="menu_styleStrong" + label="&styleStrong.label;" + accesskey="&styleStrong.accesskey;" + observes="cmd_strong" + type="checkbox"/> + <menuitem id="menu_styleCite" + label="&styleCite.label;" + accesskey="&styleCite.accesskey;" + observes="cmd_cite" + type="checkbox"/> + <menuitem id="menu_styleAbbr" + label="&styleAbbr.label;" + accesskey="&styleAbbr.accesskey;" + observes="cmd_abbr" + type="checkbox"/> + <menuitem id="menu_styleAcronym" + label="&styleAcronym.label;" + accesskey="&styleAcronym.accesskey;" + observes="cmd_acronym" + type="checkbox"/> + <menuitem id="menu_styleCode" + label="&styleCode.label;" + accesskey="&styleCode.accesskey;" + observes="cmd_code" + type="checkbox"/> + <menuitem id="menu_styleSamp" + label="&styleSamp.label;" + accesskey="&styleSamp.accesskey;" + observes="cmd_samp" + type="checkbox"/> + <menuitem id="menu_styleVar" + label="&styleVar.label;" + accesskey="&styleVar.accesskey;" + observes="cmd_var" + type="checkbox"/> + </menupopup> + </menu> + + <!-- Note: "cmd_fontColor" only monitors color state, it doesn't execute the command + (We should use "cmd_fontColorState" and "cmd_backgroundColorState" ?) --> + <menuitem id="fontColor" label="&formatFontColor.label;" + accesskey="&formatFontColor.accesskey;" + observes="cmd_fontColor" + oncommand="EditorSelectColor('Text', null);" + position="4"/> + <menuseparator id="removeSep" position="5"/> + + <!-- label and accesskey set at runtime from strings --> + <menuitem id="removeStylesMenuitem" key="removestyleskb" + observes="cmd_removeStyles" + position="6"/> + <menuitem id="removeLinksMenuitem" key="removelinkskb" + observes="cmd_removeLinks" + position="7"/> + <menuitem id="removeNamedAnchorsMenuitem" label="&formatRemoveNamedAnchors.label;" + key="removenamedanchorskb" + accesskey="&formatRemoveNamedAnchors.accesskey;" + observes="cmd_removeNamedAnchors" + position="8"/> + <menuseparator id="tabSep" position="9"/> + + <!-- Note: the 'Init' menu methods for Paragraph, List, and Align + assume that the id = 'menu_'+tagName (the 'value' label), + except for the first ('none') item + --> + <!-- Paragraph Style submenu --> + <menu id="paragraphMenu" label="¶graphMenu.label;" + accesskey="¶graphMenu.accesskey;" + position="10" onpopupshowing="InitParagraphMenu()"> + <menupopup id="paragraphMenuPopup" + oncommand="setParagraphState(event);"> + <menuitem id="menu_bodyText" + type="radio" + name="1" + label="&bodyTextCmd.label;" + accesskey="&bodyTextCmd.accesskey;" + value="" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_p" + type="radio" + name="1" + label="¶graphParagraphCmd.label;" + accesskey="¶graphParagraphCmd.accesskey;" + value="p" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_h1" + type="radio" + name="1" + label="&heading1Cmd.label;" + accesskey="&heading1Cmd.accesskey;" + value="h1" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_h2" + type="radio" + name="1" + label="&heading2Cmd.label;" + accesskey="&heading2Cmd.accesskey;" + value="h2" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_h3" + type="radio" + name="1" + label="&heading3Cmd.label;" + accesskey="&heading3Cmd.accesskey;" + value="h3" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_h4" + type="radio" + name="1" + label="&heading4Cmd.label;" + accesskey="&heading4Cmd.accesskey;" + value="h4" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_h5" + type="radio" + name="1" + label="&heading5Cmd.label;" + accesskey="&heading5Cmd.accesskey;" + value="h5" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_h6" + type="radio" + name="1" + label="&heading6Cmd.label;" + accesskey="&heading6Cmd.accesskey;" + value="h6" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_address" + type="radio" + name="1" + label="¶graphAddressCmd.label;" + accesskey="¶graphAddressCmd.accesskey;" + value="address" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_pre" + type="radio" + name="1" + label="¶graphPreformatCmd.label;" + accesskey="¶graphPreformatCmd.accesskey;" + value="pre" + observes="cmd_renderedHTMLEnabler"/> + </menupopup> + </menu> + + <!-- List Style submenu --> + <menu id="listMenu" label="&formatlistMenu.label;" + accesskey="&formatlistMenu.accesskey;" + position="11" onpopupshowing="InitListMenu()"> + <menupopup id="listMenuPopup"> + <menuitem id="menu_noList" + type="radio" + name="1" + label="&noneCmd.label;" + accesskey="&noneCmd.accesskey;" + observes="cmd_removeList"/> + <menuitem id="menu_ul" + type="radio" + name="1" + label="&listBulletCmd.label;" + accesskey="&listBulletCmd.accesskey;" + observes="cmd_ul"/> + <menuitem id="menu_ol" + type="radio" + name="1" + label="&listNumberedCmd.label;" + accesskey="&listNumberedCmd.accesskey;" + observes="cmd_ol"/> + <menuitem id="menu_dt" + type="radio" + name="1" + label="&listTermCmd.label;" + accesskey="&listTermCmd.accesskey;" + observes="cmd_dt"/> + <menuitem id="menu_dd" + type="radio" + name="1" + label="&listDefinitionCmd.label;" + accesskey="&listDefinitionCmd.accesskey;" + observes="cmd_dd"/> + <menuseparator/> + <menuitem id="listProps" + label="&listPropsCmd.label;" + accesskey="&listPropsCmd.accesskey;" + observes="cmd_listProperties"/> + </menupopup> + </menu> + <menuseparator id="identingSep" position="12"/> + + <menuitem id="increaseIndent" + label="&increaseIndent.label;" + accesskey="&increaseIndent.accesskey;" + key="increaseindentkb" + observes="cmd_indent" + position="13"/> + <menuitem id="decreaseIndent" + label="&decreaseIndent.label;" + accesskey="&decreaseIndent.accesskey;" + key="decreaseindentkb" + observes="cmd_outdent" + position="14"/> + + <menu id="alignMenu" label="&alignMenu.label;" accesskey="&alignMenu.accesskey;" + onpopupshowing="InitAlignMenu()" + position="15"> + <!-- Align submenu --> + <menupopup id="alignMenuPopup" + oncommand="doStatefulCommand('cmd_align', event.target.getAttribute('value'))"> + <menuitem id="menu_left" + label="&alignLeft.label;" + accesskey="&alignLeft.accesskey;" + type="radio" + name="1" + value="left" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_center" + label="&alignCenter.label;" + accesskey="&alignCenter.accesskey;" + type="radio" + name="1" + value="center" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_right" + label="&alignRight.label;" + accesskey="&alignRight.accesskey;" + type="radio" + name="1" + value="right" + observes="cmd_renderedHTMLEnabler"/> + <menuitem id="menu_justify" + label="&alignJustify.label;" + accesskey="&alignJustify.accesskey;" + type="radio" + name="1" + value="justify" + observes="cmd_renderedHTMLEnabler"/> + </menupopup> + </menu> + <menuseparator id="tableSep" position="16"/> + <menu id="tableMenu" label="&tableMenu.label;" accesskey="&tableMenu.accesskey;"> + <menupopup id="tableMenuPopup" onpopupshowing="EditorInitTableMenu()"> + <menu id="tableInsertMenu" label="&tableInsertMenu.label;" accesskey="&tableInsertMenu.accesskey;"> + <menupopup id="tableMenuPopup"> + <menuitem id="menu_insertTable" + label="&insertTableCmd.label;" + accesskey="&insertTableCmd.accesskey;" + observes="cmd_InsertTable"/> + <menuseparator id="tableMenuAfterInsertTableSeparator"/> + <menuitem id="menu_tableRowAbove" + label="&tableRowAbove.label;" + accesskey="&tableRowAbove.accesskey;" + observes="cmd_InsertRowAbove"/> + <menuitem id="menu_tableRowBelow" + label="&tableRowBelow.label;" + accesskey="&tableRowBelow.accesskey;" + observes="cmd_InsertRowBelow"/> + <menuseparator id="tableMenuAfterTableRowSeparator"/> + <menuitem id="menu_tableColumnBefore" + label="&tableColumnBefore.label;" + accesskey="&tableColumnBefore.accesskey;" + observes="cmd_InsertColumnBefore"/> + <menuitem id="menu_tableColumnAfter" + label="&tableColumnAfter.label;" + accesskey="&tableColumnAfter.accesskey;" + observes="cmd_InsertColumnAfter"/> + <menuseparator id="tableMenuAfterInsertColumnSeparator"/> + <menuitem id="menu_tableCellBefore" + label="&tableCellBefore.label;" + accesskey="&tableCellBefore.accesskey;" + observes="cmd_InsertCellBefore"/> + <menuitem id="menu_tableCellAfter" + label="&tableCellAfter.label;" + accesskey="&tableCellAfter.accesskey;" + observes="cmd_InsertCellAfter"/> + </menupopup> + </menu> + <menu id="tableSelectMenu" + label="&tableSelectMenu.label;" + accesskey="&tableSelectMenu.accesskey;" > + <menupopup id="tableSelectPopup"> + <menuitem id="menu_SelectTable" + label="&tableTable.label;" + accesskey="&tableTable.accesskey;" + observes="cmd_SelectTable"/> + <menuitem id="menu_SelectRow" + label="&tableRow.label;" + accesskey="&tableRow.accesskey;" + observes="cmd_SelectRow"/> + <menuitem id="menu_SelectColumn" + label="&tableColumn.label;" + accesskey="&tableColumn.accesskey;" + observes="cmd_SelectColumn"/> + <menuitem id="menu_SelectCell" + label="&tableCell.label;" + accesskey="&tableCell.accesskey;" + observes="cmd_SelectCell"/> + <menuitem id="menu_SelectAllCells" + label="&tableAllCells.label;" + accesskey="&tableAllCells.accesskey;" + observes="cmd_SelectAllCells"/> + </menupopup> + </menu> + <menu id="tableDeleteMenu" + label="&tableDeleteMenu.label;" + accesskey="&tableDeleteMenu.accesskey;"> + <menupopup id="tableDeletePopup"> + <menuitem id="menu_DeleteTable" + label="&tableTable.label;" + accesskey="&tableTable.accesskey;" + observes="cmd_DeleteTable"/> + <menuitem id="menu_DeleteRow" + label="&tableRows.label;" + accesskey="&tableRow.accesskey;" + observes="cmd_DeleteRow"/> + <menuitem id="menu_DeleteColumn" + label="&tableColumns.label;" + accesskey="&tableColumn.accesskey;" + observes="cmd_DeleteColumn"/> + <menuitem id="menu_DeleteCell" + label="&tableCells.label;" + accesskey="&tableCell.accesskey;" + observes="cmd_DeleteCell"/> + <menuitem id="menu_DeleteCellContents" + label="&tableCellContents.label;" + accesskey="&tableCellContents.accesskey;" + observes="cmd_DeleteCellContents"/> + </menupopup> + </menu> + <menuseparator/> + <!-- menu label is set in InitTableMenu --> + <menuitem id="menu_JoinTableCells" + label="&tableJoinCells.label;" + accesskey="&tableJoinCells.accesskey;" + observes="cmd_JoinTableCells"/> + <menuitem id="menu_SlitTableCell" + label="&tableSplitCell.label;" + accesskey="&tableSplitCell.accesskey;" + observes="cmd_SplitTableCell"/> + <menuitem id="menu_ConvertToTable" + label="&convertToTable.label;" + accesskey="&convertToTable.accesskey;" + observes="cmd_ConvertToTable"/> + <menuseparator/> + <menuitem id="menu_TableOrCellColor" + label="&tableOrCellColor.label;" + accesskey="&tableOrCellColor.accesskey;" + observes="cmd_TableOrCellColor"/> + <menuitem id="menu_tableProperties" + label="&tableProperties.label;" + accesskey="&tableProperties.accesskey;" + observes="cmd_editTable"/> + </menupopup> + </menu> + <menuseparator/> + <!-- label and accesskey filled in during menu creation --> + <menuitem id="objectProperties" + command="cmd_objectProperties"/> + <!-- Don't use 'observes', must call command correctly --> + <menuitem id="colorsAndBackground" + label="&colorsAndBackground.label;" + accesskey="&colorsAndBackground.accesskey;" + oncommand="goDoCommand('cmd_colorProperties')" + observes="cmd_renderedHTMLEnabler"/> + </menupopup> + </menu> + + <menu id="optionsMenu" label="&optionsMenu.label;" accesskey="&optionsMenu.accesskey;"> + <menupopup id="optionsMenuPopup" onpopupshowing="updateOptionsMenu();"> + <menuitem id="menu_checkspelling" + label="&checkSpellingCmd2.label;" + accesskey="&checkSpellingCmd2.accesskey;" + key="key_checkspelling" + command="cmd_spelling"/> + <menuitem id="menu_inlineSpellCheck" + label="&enableInlineSpellChecker.label;" + accesskey="&enableInlineSpellChecker.accesskey;" + type="checkbox" + oncommand="toggleSpellCheckingEnabled();"/> + <menuitem id="menu_quoteMessage" + label=""eCmd.label;" + accesskey=""eCmd.accesskey;" + command="cmd_quoteMessage"/> + <menuseparator/> + <menuitem id="returnReceiptMenu" type="checkbox" + label="&returnReceiptMenu.label;" + accesskey="&returnReceiptMenu.accesskey;" + checked="false" + command="cmd_toggleReturnReceipt"/> + <menuitem id="dsnMenu" type="checkbox" label="&dsnMenu.label;" accesskey="&dsnMenu.accesskey;" oncommand="ToggleDSN(event.target)"/> + <menuseparator/> + <menu id="outputFormatMenu" data-l10n-id="compose-send-format-menu"> + <menupopup id="outputFormatMenuPopup"> + <menuitem type="radio" name="output_format" id="format_auto" data-l10n-id="compose-send-auto-menu-item"/> + <menuitem type="radio" name="output_format" id="format_both" data-l10n-id="compose-send-both-menu-item"/> + <menuitem type="radio" name="output_format" id="format_html" data-l10n-id="compose-send-html-menu-item"/> + <menuitem type="radio" name="output_format" id="format_plain" data-l10n-id="compose-send-plain-menu-item"/> + </menupopup> + </menu> + <menu id="priorityMenu" label="&priorityMenu.label;" accesskey="&priorityMenu.accesskey;" onpopupshowing="updatePriorityMenu();" oncommand="PriorityMenuSelect(event.target);"> + <menupopup id="priorityMenuPopup"> + <menuitem type="radio" name="priority" label="&highestPriorityCmd.label;" accesskey="&highestPriorityCmd.accesskey;" value="Highest" id="priority_highest"/> + <menuitem type="radio" name="priority" label="&highPriorityCmd.label;" accesskey="&highPriorityCmd.accesskey;" value="High" id="priority_high"/> + <menuitem type="radio" name="priority" label="&normalPriorityCmd.label;" accesskey="&normalPriorityCmd.accesskey;" value="" id="priority_normal" checked="true"/> + <menuitem type="radio" name="priority" label="&lowPriorityCmd.label;" accesskey="&lowPriorityCmd.accesskey;" value="Low" id="priority_low"/> + <menuitem type="radio" name="priority" label="&lowestPriorityCmd.label;" accesskey="&lowestPriorityCmd.accesskey;" value="Lowest" id="priority_lowest"/> + </menupopup> + </menu> + <menu id="fccMenu" label="&fileCarbonCopyCmd.label;" + accesskey="&fileCarbonCopyCmd.accesskey;" + oncommand="MessageFcc(event.target._folder)"> + <menupopup is="folder-menupopup" id="fccMenuPopup" mode="filing" + showFileHereLabel="true" fileHereLabel="&fileHereMenu.label;"/> + </menu> + <menuseparator/> + <menuitem type="checkbox" command="cmd_customizeFromAddress" + accesskey="&customizeFromAddress.accesskey;"/> + </menupopup> + </menu> + + <menu id="encryptionMenu" data-l10n-id="encryption-menu"> + <menupopup onpopupshowing="setSecuritySettings('_Menubar');"> + + <menuitem id="encTech_OpenPGP_Menubar" + label="&menu_techPGP.label;" accesskey="&menu_techPGP.accesskey;" + value="OpenPGP" type="radio" name="radiogroup_encTech" + oncommand="onEncryptionChoice(event.target.value);"/> + <menuitem id="encTech_SMIME_Menubar" + label="&menu_techSMIME.label;" accesskey="&menu_techSMIME.accesskey;" + value="SMIME" type="radio" name="radiogroup_encTech" + oncommand="onEncryptionChoice(event.target.value);"/> + + <menuseparator id="encryptionOptionsSeparator_Menubar"/> + + <menuitem id="menu_securityEncrypt_Menubar" + type="checkbox" + data-l10n-id="menu-encrypt" + value="enc" + oncommand="onEncryptionChoice(event.target.value);"/> + <menuitem id="menu_securityEncryptSubject_Menubar" + type="checkbox" + data-l10n-id="menu-encrypt-subject" + value="encsub" + oncommand="onEncryptionChoice(event.target.value);"/> + <menuitem id="menu_securitySign_Menubar" + type="checkbox" + data-l10n-id="menu-sign" + value="sig" + oncommand="onEncryptionChoice(event.target.value);"/> + + <menuseparator id="statusInfoSeparator"/> + + <menuitem id="menu_recipientStatus_Menubar" + data-l10n-id="menu-manage-keys" + value="status" + oncommand="onEncryptionChoice(event.target.value);"/> + <menuitem id="menu_openManager_Menubar" + data-l10n-id="menu-open-key-manager" + value="manager" + oncommand="onEncryptionChoice(event.target.value);"/> + + </menupopup> + </menu> + + <menu id="tasksMenu" label="&tasksMenu.label;" accesskey="&tasksMenu.accesskey;"> + <menupopup id="taskPopup"> + <menuitem id="tasksMenuMail" accesskey="&messengerCmd.accesskey;" + label="&messengerCmd.label;" key="key_mail" + oncommand="toMessengerWindow();"/> + <menuitem id="tasksMenuAddressBook" + label="&addressBookCmd.label;" + accesskey="&addressBookCmd.accesskey;" + oncommand="toAddressBook();"/> +#ifndef XP_MACOSX + <menuseparator id="prefSep"/> + <menuitem id="menu_accountmgr" + label="&accountManagerCmd2.label;" + accesskey="&accountManagerCmd2.accesskey;" + command="cmd_account"/> + <menuitem id="menu_preferences" + data-l10n-id="menu-tools-settings" + oncommand="openOptionsDialog('paneCompose');"/> +#endif + </menupopup> + </menu> + +#ifdef XP_MACOSX +#include ../../../base/content/macWindowMenu.inc.xhtml +#endif + + <!-- Help --> +#include ../../../base/content/helpMenu.inc.xhtml + </menubar> + </toolbaritem> + </toolbar> + + <toolbarpalette id="MsgComposeToolbarPalette"> + + <toolbarbutton class="toolbarbutton-1" + id="button-send" label="&sendButton.label;" + tooltiptext="&sendButton.tooltip;" + command="cmd_sendButton" + now_label="&sendButton.label;" + now_tooltiptext="&sendButton.tooltip;" + later_label="&sendLaterCmd.label;" + later_tooltiptext="&sendlaterButton.tooltip;"> + </toolbarbutton> + + <toolbarbutton class="toolbarbutton-1" + id="button-contacts" label="&addressButton.label;" + tooltiptext="&addressButton.tooltip;" + autoCheck="false" type="checkbox" + oncommand="toggleContactsSidebar();"/> + + <toolbarbutton is="toolbarbutton-menu-button" id="button-attach" + data-l10n-id="toolbar-button-add-attachment" + type="menu" + class="toolbarbutton-1" + command="cmd_attachFile"> + <menupopup id="button-attachPopup" onpopupshowing="updateAttachmentItems();"> + <menuitem id="button-attachPopup_attachFileItem" + data-l10n-id="menuitem-attach-files" + data-l10n-attrs="acceltext" + command="cmd_attachFile"/> + <menu id="button-attachPopup_attachCloudMenu" + label="&attachCloudCmd.label;" + accesskey="&attachCloudCmd.accesskey;" + command="cmd_attachCloud"> + <menupopup id="attachCloudMenu_popup" onpopupshowing="if (event.target == this) { addAttachCloudMenuItems(this); }"/> + </menu> + <menuitem id="button-attachPopup_attachPageItem" + label="&attachPageCmd.label;" + accesskey="&attachPageCmd.accesskey;" + command="cmd_attachPage"/> + <menuseparator/> + <menuitem id="button-attachPopup_attachVCardItem" + type="checkbox" + data-l10n-id="context-menuitem-attach-vcard" + command="cmd_attachVCard"/> + <menuitem id="button-attachPopup_attachPublicKey" + type="checkbox" + data-l10n-id="context-menuitem-attach-openpgp-key" + command="cmd_attachPublicKey"/> + <menuseparator id="button-attachPopup_remindLaterSeparator"/> + <menuitem id="button-attachPopup_remindLaterItem" + type="checkbox" + label="&remindLater.label;" + accesskey="&remindLater.accesskey;" + command="cmd_remindLater"/> + </menupopup> + </toolbarbutton> + + <toolbarbutton id="button-encryption" + type="checkbox" autoCheck="false" + class="toolbarbutton-1" + data-l10n-id="encryption-toggle" + oncommand="toggleEncryptMessage();"/> + + <toolbarbutton id="button-signing" + type="checkbox" autoCheck="false" + class="toolbarbutton-1" + data-l10n-id="signing-toggle" + oncommand="toggleGlobalSignMessage();"/> + + <toolbarbutton is="toolbarbutton-menu-button" id="button-encryption-options" + type="menu" + class="toolbarbutton-1" + data-l10n-id="encryption-options-openpgp" + oncommand="showPopupById('encryptionToolbarMenu', 'button-encryption-options');"> + <menupopup id="encryptionToolbarMenu" + onpopupshowing="setSecuritySettings('_Toolbar');" + oncommand="onEncryptionChoice(event.target.value);"> + + <menuitem id="encTech_OpenPGP_Toolbar" + label="&menu_techPGP.label;" accesskey="&menu_techPGP.accesskey;" + value="OpenPGP" type="radio" name="radiogroup_encTech"/> + <menuitem id="encTech_SMIME_Toolbar" + label="&menu_techSMIME.label;" accesskey="&menu_techSMIME.accesskey;" + value="SMIME" type="radio" name="radiogroup_encTech"/> + + <menuseparator id="encryptionOptionsSeparator_Toolbar"/> + + <menuitem id="menu_securityEncrypt_Toolbar" + type="checkbox" + data-l10n-id="menu-encrypt" + value="enc"/> + <menuitem id="menu_securityEncryptSubject_Toolbar" + type="checkbox" + data-l10n-id="menu-encrypt-subject" + value="encsub"/> + <menuitem id="menu_securitySign_Toolbar" + type="checkbox" + data-l10n-id="menu-sign" + value="sig"/> + + <menuseparator id="statusInfoSeparator"/> + + <menuitem id="menu_recipientStatus_Toolbar" + data-l10n-id="menu-manage-keys" + value="status"/> + <menuitem id="menu_openManager_Toolbar" + data-l10n-id="menu-open-key-manager" + value="manager"/> + + </menupopup> + </toolbarbutton> + + <toolbarbutton is="toolbarbutton-menu-button" id="spellingButton" + type="menu" + class="toolbarbutton-1" + label="&spellingButton.label;" + tooltiptext="&spellingButton.tooltip;" + command="cmd_spelling"> + <!-- workaround for the bug that split menu doesn't take popup="popupID" --> + <menupopup onpopupshowing="event.preventDefault(); + showPopupById('languageMenuList', + 'spellingButton');"/> + </toolbarbutton> + + <toolbarbutton is="toolbarbutton-menu-button" id="button-save" + type="menu" + class="toolbarbutton-1" + label="&saveButton.label;" + tooltiptext="&saveButton.tooltip;" + command="cmd_saveDefault"> + <menupopup id="button-savePopup" onpopupshowing="InitFileSaveAsMenu();"> + <menuitem id="savePopup_saveAsFile" + label="&saveAsFileCmd.label;" accesskey="&saveAsFileCmd.accesskey;" + command="cmd_saveAsFile" type="radio" name="radiogroup_SaveAs"/> + <menuseparator/> + <menuitem id="savePopup_saveAsDraft" + label="&saveAsDraftCmd.label;" accesskey="&saveAsDraftCmd.accesskey;" + command="cmd_saveAsDraft" type="radio" name="radiogroup_SaveAs"/> + <menuitem id="savePopup_saveAsTemplate" + label="&saveAsTemplateCmd.label;" accesskey="&saveAsTemplateCmd.accesskey;" + command="cmd_saveAsTemplate" type="radio" name="radiogroup_SaveAs"/> + </menupopup> + </toolbarbutton> + + <toolbarbutton id="button-print" + class="toolbarbutton-1" + label="&printButton.label;" + command="cmd_print" + tooltiptext="&printButton.tooltip;"/> + <toolbarbutton class="toolbarbutton-1" + id="quoteButton" label=""eButton.label;" + tooltiptext=""eButton.tooltip;" + command="cmd_quoteMessage"/> + + <toolbarbutton id="cut-button" class="toolbarbutton-1" + data-l10n-id="text-action-cut" + command="cmd_cut" + tooltiptext="&cutButton.tooltip;"/> + <toolbarbutton id="copy-button" class="toolbarbutton-1" + data-l10n-id="text-action-copy" + command="cmd_copy" + tooltiptext="©Button.tooltip;"/> + <toolbarbutton id="paste-button" class="toolbarbutton-1" + data-l10n-id="text-action-paste" + command="cmd_paste" + tooltiptext="&pasteButton.tooltip;"/> + + <toolbaritem id="priority-button" + align="center" + pack="center" + title="&priorityButton.title;" + tooltiptext="&priorityButton.tooltiptext;"> + <label value="&priorityButton.label;" control="priorityMenu-button"/> + <menulist id="priorityMenu-button" value="" oncommand="PriorityMenuSelect(event.target);"> + <menupopup id="priorityMenu-buttonPopup"> + <menuitem id="list_priority_highest" + name="priority" + label="&highestPriorityCmd.label;" + value="Highest"/> + <menuitem id="list_priority_high" + name="priority" + label="&highPriorityCmd.label;" + value="High"/> + <menuitem id="list_priority_normal" + name="priority" + selected="true" + label="&normalPriorityCmd.label;" + value=""/> + <menuitem id="list_priority_low" + name="priority" + label="&lowPriorityCmd.label;" + value="Low"/> + <menuitem id="list_priority_lowest" + name="priority" + label="&lowestPriorityCmd.label;" + value="Lowest"/> + </menupopup> + </menulist> + </toolbaritem> + + <toolbarbutton id="button-returnReceipt" + class="toolbarbutton-1" + data-l10n-id="button-return-receipt" + type="checkbox" autoCheck="false" + command="cmd_toggleReturnReceipt"/> + </toolbarpalette> + <toolbar is="customizable-toolbar" + id="composeToolbar2" + class="chromeclass-toolbar themeable-full" + toolbarname="&showCompositionToolbarCmd.label;" + accesskey="&showCompositionToolbarCmd.accesskey;" + fullscreentoolbar="true" mode="full" +#ifdef XP_MACOSX + iconsize="small" +#endif + defaultset="button-send,separator,button-encryption,button-encryption-options,button-address,spellingButton,button-save,button-contacts,spring,button-attach" + customizable="true" + context="toolbar-context-menu"> + </toolbar> +</toolbox> + <html:div id="composeContentBox" class="printPreviewStack attachment-area-hidden"> + <html:div id="contactsSidebar"> + <box class="sidebar-header" align="center"> + <label id="contactsTitle" value="&addressesSidebarTitle.label;"/> + <spacer flex="1"/> + <toolbarbutton class="close-icon" + oncommand="toggleContactsSidebar();"/> + </box> + <browser id="contactsBrowser" src="" disablehistory="true"/> + </html:div> + + <html:hr is="pane-splitter" id="contactsSplitter" + resize-direction="horizontal" + resize-id="contactsSidebar" /> + + <toolbar is="customizable-toolbar" id="MsgHeadersToolbar" + class="themeable-full" + customizable="true" nowindowdrag="true" + ondragover="envelopeDragObserver.onDragOver(event);" + ondrop="envelopeDragObserver.onDrop(event);" + ondragleave="envelopeDragObserver.onDragLeave(event);"> + <hbox id="top-gradient-box" class="address-identity-recipient"> + <hbox class="aw-firstColBox"/> + <hbox id="identityLabel-box" align="center" + pack="end" style="&headersSpace2.style;"> + <label id="identityLabel" value="&fromAddr2.label;" + accesskey="&fromAddr.accesskey;" control="msgIdentity"/> + </hbox> + <menulist is="menulist-editable" id="msgIdentity" + type="description" + disableautoselect="true" onkeypress="fromKeyPress(event);" + oncommand="LoadIdentity(false);" disableonsend="true"> + <menupopup id="msgIdentityPopup"/> + </menulist> + + <html:div id="extraAddressRowsArea"> + <!-- Default set up is for a mail account, where we prefer + - showing the buttons, rather than the menu items, for + - the mail rows. + - The To field is already shown, so the button is hidden. + - For the news rows, we prefer the menu items over the + - buttons, so we hide them. --> + <html:button id="addr_toShowAddressRowButton" + disableonsend="true" + class="recipient-button plain-button" + data-address-row="addressRowTo" + onclick="showAndFocusAddressRow('addressRowTo');" + ondrop="showAddressRowButtonOnDrop(event);" + ondragover="showAddressRowButtonOnDragover(event);" + hidden="hidden"> + </html:button> + <html:button id="addr_ccShowAddressRowButton" + disableonsend="true" + class="recipient-button plain-button" + data-address-row="addressRowCc" + onclick="showAndFocusAddressRow('addressRowCc');" + ondrop="showAddressRowButtonOnDrop(event);" + ondragover="showAddressRowButtonOnDragover(event);"> + </html:button> + <html:button id="addr_bccShowAddressRowButton" + disableonsend="true" + class="recipient-button plain-button" + data-address-row="addressRowBcc" + onclick="showAndFocusAddressRow('addressRowBcc');" + ondrop="showAddressRowButtonOnDrop(event);" + ondragover="showAddressRowButtonOnDragover(event);"> + </html:button> + <html:button id="addr_newsgroupsShowAddressRowButton" + class="recipient-button plain-button" + hidden="hidden" + onclick="showAndFocusAddressRow('addressRowNewsgroups')"> + &newsgroupsAddr2.label; + </html:button> + <html:button id="addr_followupShowAddressRowButton" + class="recipient-button plain-button" + hidden="hidden" + onclick="showAndFocusAddressRow('addressRowFollowup')"> + &followupAddr2.label; + </html:button> + <html:button id="extraAddressRowsMenuButton" + data-l10n-id="extra-address-rows-menu-button" + aria-expanded="false" + aria-haspopup="menu" + aria-controls="extraAddressRowsMenu" + disableonsend="true" + class="plain-button" + onclick="openExtraAddressRowsMenu();"> + <!-- NOTE: button title should provide the accessibility + - context. --> + <html:img class="overflow-icon" + src="chrome://messenger/skin/icons/new/compact/overflow.svg" + alt="" /> + </html:button> + </html:div> + </hbox> + + <mail-recipients-area id="recipientsContainer" orient="vertical" + class="recipients-container"> + <hbox id="addressRowReply" + class="address-row hidden" + data-recipienttype="addr_reply" + data-show-self-menuitem="addr_replyShowAddressRowMenuItem"> + <hbox class="aw-firstColBox"> + <html:button class="remove-field-button plain-button" + onclick="closeLabelOnClick(event);"> + <html:img src="chrome://global/skin/icons/close.svg" + alt="" /> + </html:button> + </hbox> + <hbox class="address-label-container" align="top" pack="end" + style="&headersSpace2.style;"> + <label id="replyAddrLabel" value="&replyAddr2.label;" + control="replyAddrInput"/> + </hbox> + <hbox id="replyAddrContainer" flex="1" align="center" + class="input-container wrap-container address-container" + onclick="focusAddressInputOnClick(event);"> + <html:input is="autocomplete-input" id="replyAddrInput" + type="text" + class="plain address-input address-row-input mail-input" + disableonsend="true" + autocompletesearch="mydomain addrbook ldap news" + autocompletesearchparam="{}" + timeout="200" + maxrows="6" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="2" + ignoreblurwhilesearching="true" + onfocus="addressInputOnFocus(this);" + onblur="addressInputOnBlur(this);" + size="1"/> + </hbox> + </hbox> + + <hbox id="addressRowTo" + class="address-row" + data-recipienttype="addr_to" + data-show-self-menuitem="addr_toShowAddressRowMenuItem"> + <hbox class="aw-firstColBox"> + <html:button class="remove-field-button plain-button" + onclick="closeLabelOnClick(event);" + hidden="hidden"> + <html:img src="chrome://global/skin/icons/close.svg" + alt="" /> + </html:button> + </hbox> + <hbox class="address-label-container" align="top" pack="end" + style="&headersSpace2.style;"> + <label id="toAddrLabel" + data-l10n-id="to-address-row-label" + control="toAddrInput"/> + </hbox> + <hbox id="toAddrContainer" flex="1" align="center" + class="input-container wrap-container address-container" + onclick="focusAddressInputOnClick(event);"> + <html:input is="autocomplete-input" id="toAddrInput" + type="text" + class="plain address-input address-row-input mail-input mail-primary-input" + disableonsend="true" + autocompletesearch="mydomain addrbook ldap news" + autocompletesearchparam="{}" + timeout="200" + maxrows="6" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="2" + ignoreblurwhilesearching="true" + onfocus="addressInputOnFocus(this);" + onblur="addressInputOnBlur(this);" + size="1"/> + </hbox> + </hbox> + + <hbox id="addressRowCc" + class="address-row hidden" + data-recipienttype="addr_cc" + data-show-self-menuitem="addr_ccShowAddressRowMenuItem"> + <hbox class="aw-firstColBox"> + <html:button class="remove-field-button plain-button" + onclick="closeLabelOnClick(event);"> + <html:img src="chrome://global/skin/icons/close.svg" + alt="" /> + </html:button> + </hbox> + <hbox class="address-label-container" align="top" pack="end" + style="&headersSpace2.style;"> + <label id="ccAddrLabel" + data-l10n-id="cc-address-row-label" + control="ccAddrInput"/> + </hbox> + <hbox id="ccAddrContainer" flex="1" align="center" + class="input-container wrap-container address-container" + onclick="focusAddressInputOnClick(event);"> + <html:input is="autocomplete-input" id="ccAddrInput" + type="text" + class="plain address-input address-row-input mail-input" + disableonsend="true" + autocompletesearch="mydomain addrbook ldap news" + autocompletesearchparam="{}" + timeout="200" + maxrows="6" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="2" + ignoreblurwhilesearching="true" + onfocus="addressInputOnFocus(this);" + onblur="addressInputOnBlur(this);" + size="1"/> + </hbox> + </hbox> + + <hbox id="addressRowBcc" + class="address-row hidden" + data-recipienttype="addr_bcc" + data-show-self-menuitem="addr_bccShowAddressRowMenuItem"> + <hbox class="aw-firstColBox"> + <html:button class="remove-field-button plain-button" + onclick="closeLabelOnClick(event);"> + <html:img src="chrome://global/skin/icons/close.svg" + alt="" /> + </html:button> + </hbox> + <hbox class="address-label-container" align="top" pack="end" + style="&headersSpace2.style;"> + <label id="bccAddrLabel" + data-l10n-id="bcc-address-row-label" + control="bccAddrInput"/> + </hbox> + <hbox id="bccAddrContainer" flex="1" align="center" + class="input-container wrap-container address-container" + onclick="focusAddressInputOnClick(event);"> + <html:input is="autocomplete-input" id="bccAddrInput" + type="text" + class="plain address-input address-row-input mail-input" + disableonsend="true" + autocompletesearch="mydomain addrbook ldap news" + autocompletesearchparam="{}" + timeout="200" + maxrows="6" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="2" + ignoreblurwhilesearching="true" + onfocus="addressInputOnFocus(this);" + onblur="addressInputOnBlur(this);" + size="1"/> + </hbox> + </hbox> + + <hbox id="addressRowNewsgroups" + class="address-row hidden" + data-recipienttype="addr_newsgroups" + data-show-self-menuitem="addr_newsgroupsShowAddressRowMenuItem"> + <hbox class="aw-firstColBox"> + <html:button class="remove-field-button plain-button" + onclick="closeLabelOnClick(event);"> + <html:img src="chrome://global/skin/icons/close.svg" + alt="" /> + </html:button> + </hbox> + <hbox class="address-label-container" align="top" pack="end" + style="&headersSpace2.style;"> + <label id="newsgroupsAddrLabel" value="&newsgroupsAddr2.label;" + control="newsgroupsAddrInput"/> + </hbox> + <hbox id="newsgroupsAddrContainer" flex="1" align="center" + class="input-container wrap-container address-container" + onclick="focusAddressInputOnClick(event);"> + <html:input is="autocomplete-input" id="newsgroupsAddrInput" + type="text" + class="plain address-input address-row-input news-input news-primary-input" + disableonsend="true" + autocompletesearch="mydomain addrbook ldap news" + autocompletesearchparam="{}" + timeout="200" + maxrows="6" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="2" + ignoreblurwhilesearching="true" + onfocus="addressInputOnFocus(this);" + onblur="addressInputOnBlur(this);" + size="1"/> + </hbox> + </hbox> + + <hbox id="addressRowFollowup" + class="address-row hidden" + data-recipienttype="addr_followup" + data-show-self-menuitem="addr_followupShowAddressRowMenuItem"> + <hbox class="aw-firstColBox"> + <html:button class="remove-field-button plain-button" + onclick="closeLabelOnClick(event);"> + <html:img src="chrome://global/skin/icons/close.svg" + alt="" /> + </html:button> + </hbox> + <hbox class="address-label-container" align="top" pack="end" + style="&headersSpace2.style;"> + <label id="followupAddrLabel" value="&followupAddr2.label;" + control="followupAddrInput"/> + </hbox> + <hbox id="followupAddrContainer" flex="1" align="center" + class="input-container wrap-container address-container" + onclick="focusAddressInputOnClick(event);"> + <html:input is="autocomplete-input" id="followupAddrInput" + type="text" + class="plain address-input address-row-input news-input" + disableonsend="true" + autocompletesearch="mydomain addrbook ldap news" + autocompletesearchparam="{}" + timeout="200" + maxrows="6" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="2" + ignoreblurwhilesearching="true" + onfocus="addressInputOnFocus(this);" + onblur="addressInputOnBlur(this);" + size="1"/> + </hbox> + </hbox> + </mail-recipients-area> + + <hbox id="subject-box"> + <hbox class="aw-firstColBox"/> + <hbox id="subjectLabel-box" align="center" + pack="end" style="&headersSpace2.style;"> + <label id="subjectLabel" value="&subject2.label;" + accesskey="&subject.accesskey;" control="msgSubject"/> + </hbox> + <hbox id="msgSubjectContainer" flex="1" align="center" + class="input-container"> + <moz-input-box spellcheck="true" style="flex: 1;"> + <html:img id="msgEncryptedSubjectIcon" + src="chrome://messenger/skin/icons/message-encrypted-notok.svg" + onclick="toggleEncryptedSubject(event);" + hidden="hidden" + alt="" /> + <html:input id="msgSubject" + type="text" + class="input-inline textbox-input" + disableonsend="true" + oninput="msgSubjectOnInput(event);" + onkeypress="subjectKeyPress(event);" + aria-labelledby="subjectLabel" + style="flex: 1;"/> + </moz-input-box> + </hbox> + </hbox> + </toolbar> + + <toolbox id="FormatToolbox" mode="icons"> + <toolbar id="FormatToolbar" + class="chromeclass-toolbar themeable-brighttext" + persist="collapsed" + nowindowdrag="true"> +#include editFormatButtons.inc.xhtml + <spacer flex="1"/> + </toolbar> + </toolbox> + + <html:hr is="pane-splitter" id="headersSplitter" + resize-direction="vertical" + resize-id="MsgHeadersToolbar" /> + <html:div id="messageArea"> + <html:div id="dropAttachmentOverlay" class="drop-attachment-overlay"> + <html:aside id="addInline" class="drop-attachment-box"> + <html:span id="addInlineLabel" + data-l10n-id="drop-file-label-inline" + data-l10n-args='{"count": 1}' + class="drop-inline"></html:span> + </html:aside> + <html:aside id="addAsAttachment" + class="drop-attachment-box"> + <html:span id="addAsAttachmentLabel" + data-l10n-id="drop-file-label-attachment" + data-l10n-args='{"count": 1}' + class="drop-as-attachment"></html:span> + </html:aside> + </html:div> + <!-- + - The mail message body frame. The src does not exactly match + - "about:blank" so that WebExtension content scripts are not loaded + - here in the moments before navigation to about:blank?compose occurs. + --> + <editor id="messageEditor" + type="content" + primary="true" + src="about:blank?" + name="browser.message.body" + aria-label="&aria.message.bodyName;" + messagemanagergroup="browsers" + oncontextmenu="this._contextX = event.pageX; this._contextY = event.pageY;" + onclick="EditorClick(event);" + ondblclick="EditorDblClick(event);" + context="msgComposeContext"/> + + <html:div id="linkPreviewSettings" xmlns="http://www.w3.org/1999/xhtml" hidden="hidden"> + <span class="close">+</span> + <h2 data-l10n-id="link-preview-title"></h2> + <p data-l10n-id="link-preview-description"></p> + <p> + <input class="preview-autoadd" id="link-preview-autoadd" type="checkbox" /> + <label data-l10n-id="link-preview-autoadd" for="link-preview-autoadd"></label> + </p> + <p class="bottom"> + <span data-l10n-id="link-preview-replace-now"></span> + <button class="preview-replace" data-l10n-id="link-preview-yes-replace"></button> + </p> + </html:div> + + <findbar id="FindToolbar" browserid="messageEditor"/> + </html:div> + + <!-- NOTE: The splitter controls #attachmentBucket's size directly. --> + <html:hr is="pane-splitter" id="attachmentSplitter" + resize-direction="vertical" + resize-id="attachmentBucket" /> + <html:details id="attachmentArea"> + <html:summary> + <!-- Hide from accessibility tree since this is only used for a brief + - animation effect. --> + <html:span id="newAttachmentIndicator" aria-hidden="true"></html:span> + <html:img id="attachmentToggle" + src="chrome://messenger/skin/icons/new/nav-down-sm.svg" + alt="" /> + <html:span id="attachmentBucketCount"></html:span> + <html:span id="attachmentBucketSize" role="note"></html:span> + </html:summary> + + <richlistbox is="attachment-list" id="attachmentBucket" + aria-describedby="attachmentBucketCount" + class="attachmentList" + disableonsend="true" + seltype="multiple" + flex="1" + role="listbox" + context="msgComposeAttachmentListContext" + itemcontext="msgComposeAttachmentItemContext" + onclick="attachmentBucketOnClick(event);" + onkeypress="attachmentBucketOnKeyPress(event);" + onselect="attachmentBucketOnSelect();" + ondragstart="attachmentBucketDNDObserver.onDragStart(event);" + ondragover="envelopeDragObserver.onDragOver(event);" + ondrop="envelopeDragObserver.onDrop(event);" + ondragleave="envelopeDragObserver.onDragLeave(event);" + onblur="attachmentBucketOnBlur();"/> + </html:details> + </html:div> + + <panel id="customizeToolbarSheetPopup" noautohide="true"> + <iframe id="customizeToolbarSheetIFrame" + style="&dialog.dimensions;" + hidden="true"/> + </panel> + + <vbox id="compose-notification-bottom"> + <!-- notificationbox will be added here lazily. --> + </vbox> + + <html:div id="status-bar" class="statusbar" role="status"> + <html:div id="statusText"></html:div> + <html:progress id="compose-progressmeter" + class="progressmeter-statusbar" + value="0" max="100" + hidden="hidden"> + </html:progress> + <html:button id="languageStatusButton" + class="plain-button" + aria-expanded="false" + aria-haspopup="menu" + aria-controls="languageMenuList" + title="&languageStatusButton.tooltip;" + onclick="showPopupById('languageMenuList', 'languageStatusButton', 'before_start');" + hidden="hidden"> + </html:button> + </html:div> + +#include ../../../base/content/tabDialogs.inc.xhtml +#include ../../../extensions/openpgp/content/ui/keyAssistant.inc.xhtml + +<html:template id="dataCardTemplate" xmlns="http://www.w3.org/1999/xhtml"> + <aside class="moz-card" style="width:600px; display:flex; align-items:center; justify-content:center; flex-direction:row; flex-wrap:wrap; border-radius:10px; border:1px solid silver;"> + <a class="remove-card">+</a> + <div class="card-pic" style="display:flex; flex-direction:column; flex-basis:100%; flex:1;"> + <div style="margin:0 5px;"> + <img src="IMAGE" style="width:120px;" alt="" /> + </div> + </div> + <div class="card-content" style="display:flex; flex-direction:column; flex-basis:100%; flex:3;"> + <div style="margin:0 1em;"> + <p><small class="site" style="font-weight:lighter;">SITE</small></p> + <p> + <a href="#" style="font-weight:600; text-decoration:none;"><big class="title">TITLE</big></a> + </p> + <p class="description">DESCRIPTION</p> + <p> + <a href="#" class="url" style="display:inline-block; text-decoration:none; text-indent:-2ch; margin-inline:2ch;">URL</a> + </p> + </div> + </div> + </aside> +</html:template> +</html:body> +</html> |