diff options
Diffstat (limited to 'comm/mail/base/content/widgets')
17 files changed, 13245 insertions, 0 deletions
diff --git a/comm/mail/base/content/widgets/browserPopups.inc.xhtml b/comm/mail/base/content/widgets/browserPopups.inc.xhtml new file mode 100644 index 0000000000..468c2eb3eb --- /dev/null +++ b/comm/mail/base/content/widgets/browserPopups.inc.xhtml @@ -0,0 +1,192 @@ +# 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/. + +#ifndef NO_BROWSERCONTEXT + <menupopup id="browserContext" + onpopupshowing="return browserContextOnShowing(event);" + onpopuphiding="browserContextOnHiding(event);"> + <!-- Browser navigation --> +#ifdef XP_MACOSX + <menuitem id="browserContext-back" + data-l10n-id="content-tab-menu-back-mac" + command="Browser:Back"/> + <menuitem id="browserContext-forward" + data-l10n-id="content-tab-menu-forward-mac" + command="Browser:Forward"/> + <menuitem id="browserContext-reload" + tooltip="dynamic-shortcut-tooltip" + data-l10n-id="content-tab-menu-reload-mac" + command="cmd_reload"/> + <menuitem id="browserContext-stop" + tooltip="dynamic-shortcut-tooltip" + data-l10n-id="content-tab-menu-stop-mac" + command="cmd_stop"/> +#else + <menugroup id="context-navigation"> + <menuitem id="browserContext-back" + data-l10n-id="content-tab-menu-back" + data-l10n-args='{"shortcut":""}' + class="menuitem-iconic" + command="Browser:Back"/> + <menuitem id="browserContext-forward" + data-l10n-id="content-tab-menu-forward" + data-l10n-args='{"shortcut":""}' + class="menuitem-iconic" + command="Browser:Forward"/> + <menuitem id="browserContext-reload" + class="menuitem-iconic" + tooltip="dynamic-shortcut-tooltip" + data-l10n-id="content-tab-menu-reload" + command="cmd_reload"/> + <menuitem id="browserContext-stop" + class="menuitem-iconic" + tooltip="dynamic-shortcut-tooltip" + data-l10n-id="content-tab-menu-stop" + command="cmd_stop"/> + </menugroup> +#endif + <menuseparator id="browserContext-sep-navigation"/> + <!-- Spellchecking suggestions --> + <menuitem id="browserContext-spell-no-suggestions" + disabled="true" + data-l10n-id="text-action-spell-no-suggestions"/> + <menuitem id="browserContext-spell-add-to-dictionary" + data-l10n-id="text-action-spell-add-to-dictionary" + oncommand="gSpellChecker.addToDictionary();"/> + <menuitem id="browserContext-spell-undo-add-to-dictionary" + data-l10n-id="text-action-spell-undo-add-to-dictionary" + oncommand="gSpellChecker.undoAddToDictionary();" /> + <menuseparator id="browserContext-spell-suggestions-separator"/> + + <menuitem id="browserContext-openInBrowser" + label="&openInBrowser.label;" + accesskey="&openInBrowser.accesskey;" + oncommand="gContextMenu.openInBrowser();"/> + <menuitem id="browserContext-openLinkInBrowser" + label="&openLinkInBrowser.label;" + accesskey="&openLinkInBrowser.accesskey;" + oncommand="gContextMenu.openLinkInBrowser();"/> + <menuseparator id="browserContext-sep-open-browser"/> + <menuitem id="browserContext-undo" + label="&undoDefaultCmd.label;" + accesskey="&undoDefaultCmd.accesskey;" + command="cmd_undo"/> + <menuseparator id="browserContext-sep-undo"/> + <menuitem id="browserContext-cut" + data-l10n-id="text-action-cut" + command="cmd_copy"/> + <menuitem id="browserContext-copy" + data-l10n-id="text-action-copy" + command="cmd_copy"/> + <menuitem id="browserContext-paste" + data-l10n-id="text-action-paste" + command="cmd_paste"/> + <menuitem id="browserContext-selectall" + data-l10n-id="text-action-select-all" + command="cmd_selectAll"/> + <menuseparator id="browserContext-sep-clipboard"/> + + <menuitem id="browserContext-searchTheWeb" + label="[glodaComplete.webSearch1.label]" + oncommand="openWebSearch(event.target.value)"/> + + <!-- Spellchecking general menu items (enable, add dictionaries...) --> + <menuseparator id="browserContext-spell-separator"/> + <menuitem id="browserContext-spell-check-enabled" + data-l10n-id="text-action-spell-check-toggle" + type="checkbox" + oncommand="gSpellChecker.toggleEnabled();"/> + <menuitem id="browserContext-spell-add-dictionaries-main" + label="&spellAddDictionaries.label;" + accesskey="&spellAddDictionaries.accesskey;" + oncommand="gContextMenu.addDictionaries();"/> + <menu id="browserContext-spell-dictionaries" + data-l10n-id="text-action-spell-dictionaries"> + <menupopup id="browserContext-spell-dictionaries-menu"> + <menuseparator id="browserContext-spell-language-separator"/> + <menuitem id="browserContext-spell-add-dictionaries" + label="&spellAddDictionaries.label;" + accesskey="&spellAddDictionaries.accesskey;" + oncommand="gContextMenu.addDictionaries();"/> + </menupopup> + </menu> + + <menuitem id="browserContext-media-play" + label="&contextPlay.label;" + accesskey="&contextPlay.accesskey;" + oncommand="gContextMenu.mediaCommand('play');"/> + <menuitem id="browserContext-media-pause" + label="&contextPause.label;" + accesskey="&contextPause.accesskey;" + oncommand="gContextMenu.mediaCommand('pause');"/> + <menuitem id="browserContext-media-mute" + label="&contextMute.label;" + accesskey="&contextMute.accesskey;" + oncommand="gContextMenu.mediaCommand('mute');"/> + <menuitem id="browserContext-media-unmute" + label="&contextUnmute.label;" + accesskey="&contextUnmute.accesskey;" + oncommand="gContextMenu.mediaCommand('unmute');"/> + <menuseparator id="browserContext-sep-edit"/> + <menuitem id="browserContext-copylink" + label="©LinkCmd.label;" + accesskey="©LinkCmd.accesskey;" + command="cmd_copyLink"/> + <menuitem id="browserContext-copyimage" + label="©ImageAllCmd.label;" + accesskey="©ImageAllCmd.accesskey;" + command="cmd_copyImage"/> + <menuitem id="browserContext-addemail" + label="&AddToAddressBook.label;" + accesskey="&AddToAddressBook.accesskey;" + oncommand="addEmail(gContextMenu.linkURL);"/> + <menuitem id="browserContext-composeemailto" + label="&SendMessageTo.label;" + accesskey="&SendMessageTo.accesskey;" + oncommand="composeEmailTo(gContextMenu.linkURL);"/> + <menuitem id="browserContext-copyemail" + label="©EmailCmd.label;" + accesskey="©EmailCmd.accesskey;" + oncommand="gContextMenu.copyEmail();"/> + <menuseparator id="browserContext-sep-copy"/> + <menuitem id="browserContext-savelink" + label="&saveLinkAsCmd.label;" + accesskey="&saveLinkAsCmd.accesskey;" + oncommand="gContextMenu.saveLink();"/> + <menuitem id="browserContext-saveimage" + label="&saveImageAsCmd.label;" + accesskey="&saveImageAsCmd.accesskey;" + oncommand="gContextMenu.saveImage();"/> + </menupopup> +#endif + <panel id="DateTimePickerPanel" + type="arrow" + orient="vertical" + noautofocus="true" + norolluponanchor="true" + consumeoutsideclicks="never" + level="top" + tabspecific="true"> + </panel> + + <!-- For select dropdowns. The menupopup is what shows the list of options, + and the popuponly menulist makes things like the menuactive attributes + work correctly on the menupopup. ContentSelectDropdown expects the + popuponly menulist to be its immediate parent. --> + <menulist popuponly="true" id="ContentSelectDropdown" hidden="true"> + <menupopup rolluponmousewheel="true" + activateontab="true" position="after_start" + level="parent" +#ifdef XP_WIN + consumeoutsideclicks="false" ignorekeys="shortcuts" +#endif + /> + </menulist> + + <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete" + type="autocomplete" + role="group" + noautofocus="true"/> + + <tooltip id="remoteBrowserTooltip"/> diff --git a/comm/mail/base/content/widgets/browserPopups.js b/comm/mail/base/content/widgets/browserPopups.js new file mode 100644 index 0000000000..f6d2a2139f --- /dev/null +++ b/comm/mail/base/content/widgets/browserPopups.js @@ -0,0 +1,991 @@ +/* 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 ../utilityOverlay.js */ + +/* globals saveURL */ // From contentAreaUtils.js +/* globals goUpdateCommand */ // From globalOverlay.js + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { InlineSpellChecker, SpellCheckHelper } = ChromeUtils.importESModule( + "resource://gre/modules/InlineSpellChecker.sys.mjs" +); +var { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +var { ShortcutUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ShortcutUtils.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +ChromeUtils.defineModuleGetter( + this, + "MailUtils", + "resource:///modules/MailUtils.jsm" +); +var { E10SUtils } = ChromeUtils.importESModule( + "resource://gre/modules/E10SUtils.sys.mjs" +); + +var gContextMenu; +var gSpellChecker = new InlineSpellChecker(); + +/** Called by ContextMenuParent.sys.mjs */ +function openContextMenu({ data }, browser, actor) { + if (!browser.hasAttribute("context")) { + return; + } + + let wgp = actor.manager; + + if (!wgp.isCurrentGlobal) { + // Don't display context menus for unloaded documents + return; + } + + // NOTE: We don't use `wgp.documentURI` here as we want to use the failed + // channel URI in the case we have loaded an error page. + let documentURIObject = wgp.browsingContext.currentURI; + + let frameReferrerInfo = data.frameReferrerInfo; + if (frameReferrerInfo) { + frameReferrerInfo = E10SUtils.deserializeReferrerInfo(frameReferrerInfo); + } + + let linkReferrerInfo = data.linkReferrerInfo; + if (linkReferrerInfo) { + linkReferrerInfo = E10SUtils.deserializeReferrerInfo(linkReferrerInfo); + } + + let frameID = nsContextMenu.WebNavigationFrames.getFrameId( + wgp.browsingContext + ); + + nsContextMenu.contentData = { + context: data.context, + browser, + actor, + editFlags: data.editFlags, + spellInfo: data.spellInfo, + principal: wgp.documentPrincipal, + storagePrincipal: wgp.documentStoragePrincipal, + documentURIObject, + docLocation: data.docLocation, + charSet: data.charSet, + referrerInfo: E10SUtils.deserializeReferrerInfo(data.referrerInfo), + frameReferrerInfo, + linkReferrerInfo, + contentType: data.contentType, + contentDisposition: data.contentDisposition, + frameID, + frameOuterWindowID: frameID, + frameBrowsingContext: wgp.browsingContext, + selectionInfo: data.selectionInfo, + disableSetDesktopBackground: data.disableSetDesktopBackground, + loginFillInfo: data.loginFillInfo, + parentAllowsMixedContent: data.parentAllowsMixedContent, + userContextId: wgp.browsingContext.originAttributes.userContextId, + webExtContextData: data.webExtContextData, + cookieJarSettings: wgp.cookieJarSettings, + }; + + // Note: `popup` must be in `document`, but `browser` might be in a + // different document, such as about:3pane. + let popup = document.getElementById(browser.getAttribute("context")); + let context = nsContextMenu.contentData.context; + + // Fill in some values in the context from the WindowGlobalParent actor. + context.principal = wgp.documentPrincipal; + context.storagePrincipal = wgp.documentStoragePrincipal; + context.frameID = frameID; + context.frameOuterWindowID = wgp.outerWindowId; + context.frameBrowsingContextID = wgp.browsingContext.id; + + // We don't have access to the original event here, as that happened in + // another process. Therefore we synthesize a new MouseEvent to propagate the + // inputSource to the subsequently triggered popupshowing event. + let newEvent = document.createEvent("MouseEvent"); + let screenX = context.screenXDevPx / window.devicePixelRatio; + let screenY = context.screenYDevPx / window.devicePixelRatio; + newEvent.initNSMouseEvent( + "contextmenu", + true, + true, + null, + 0, + screenX, + screenY, + 0, + 0, + false, + false, + false, + false, + 2, + null, + 0, + context.mozInputSource + ); + popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent); +} + +/** + * Function to set the global nsContextMenu. Called by popupshowing on browserContext. + * + * @param {Event} event - The onpopupshowing event. + * @returns {boolean} + */ +function browserContextOnShowing(event) { + if (event.target.id != "browserContext") { + return true; + } + + gContextMenu = new nsContextMenu(event.target, event.shiftKey); + return gContextMenu.shouldDisplay; +} + +/** + * Function to clear out the global nsContextMenu. + * + * @param {Event} event - The onpopuphiding event. + */ +function browserContextOnHiding(event) { + if (event.target.id != "browserContext") { + return; + } + + gContextMenu.hiding(); + gContextMenu = null; +} + +class nsContextMenu { + constructor(aXulMenu, aIsShift) { + this.xulMenu = aXulMenu; + + // Get contextual info. + this.setContext(); + + if (!this.shouldDisplay) { + return; + } + + this.isContentSelected = + !this.selectionInfo || !this.selectionInfo.docSelectionIsCollapsed; + + if (!aIsShift) { + // The rest of this block sends menu information to WebExtensions. + let subject = { + menu: aXulMenu, + tab: document.getElementById("tabmail") + ? document.getElementById("tabmail").currentTabInfo + : window, + timeStamp: this.timeStamp, + isContentSelected: this.isContentSelected, + inFrame: this.inFrame, + isTextSelected: this.isTextSelected, + onTextInput: this.onTextInput, + onLink: this.onLink, + onImage: this.onImage, + onVideo: this.onVideo, + onAudio: this.onAudio, + onCanvas: this.onCanvas, + onEditable: this.onEditable, + onSpellcheckable: this.onSpellcheckable, + onPassword: this.onPassword, + srcUrl: this.mediaURL, + frameUrl: this.contentData ? this.contentData.docLocation : undefined, + pageUrl: this.browser ? this.browser.currentURI.spec : undefined, + linkText: this.linkTextStr, + linkUrl: this.linkURL, + selectionText: this.isTextSelected + ? this.selectionInfo.fullText + : undefined, + frameId: this.frameID, + webExtBrowserType: this.webExtBrowserType, + webExtContextData: this.contentData + ? this.contentData.webExtContextData + : undefined, + }; + + subject.wrappedJSObject = subject; + Services.obs.notifyObservers(subject, "on-build-contextmenu"); + } + + // Reset after "on-build-contextmenu" notification in case selection was + // changed during the notification. + this.isContentSelected = + !this.selectionInfo || !this.selectionInfo.docSelectionIsCollapsed; + this.initItems(); + + // If all items in the menu are hidden, set this.shouldDisplay to false + // so that the callers know to not even display the empty menu. + let contextPopup = document.getElementById("browserContext"); + for (let item of contextPopup.children) { + if (!item.hidden) { + return; + } + } + + // All items must have been hidden. + this.shouldDisplay = false; + } + + setContext() { + let context = Object.create(null); + + if (nsContextMenu.contentData) { + this.contentData = nsContextMenu.contentData; + context = this.contentData.context; + nsContextMenu.contentData = null; + } + + this.shouldDisplay = !this.contentData || context.shouldDisplay; + this.timeStamp = context.timeStamp; + + // Assign what's _possibly_ needed from `context` sent by ContextMenuChild.sys.mjs + // Keep this consistent with the similar code in ContextMenu's _setContext + this.bgImageURL = context.bgImageURL; + this.imageDescURL = context.imageDescURL; + this.imageInfo = context.imageInfo; + this.mediaURL = context.mediaURL; + + this.canSpellCheck = context.canSpellCheck; + this.hasBGImage = context.hasBGImage; + this.hasMultipleBGImages = context.hasMultipleBGImages; + this.isDesignMode = context.isDesignMode; + this.inFrame = context.inFrame; + this.inPDFViewer = context.inPDFViewer; + this.inSrcdocFrame = context.inSrcdocFrame; + this.inSyntheticDoc = context.inSyntheticDoc; + + this.link = context.link; + this.linkDownload = context.linkDownload; + this.linkProtocol = context.linkProtocol; + this.linkTextStr = context.linkTextStr; + this.linkURL = context.linkURL; + this.linkURI = this.getLinkURI(); // can't send; regenerate + + this.onAudio = context.onAudio; + this.onCanvas = context.onCanvas; + this.onCompletedImage = context.onCompletedImage; + this.onDRMMedia = context.onDRMMedia; + this.onPiPVideo = context.onPiPVideo; + this.onEditable = context.onEditable; + this.onImage = context.onImage; + this.onKeywordField = context.onKeywordField; + this.onLink = context.onLink; + this.onLoadedImage = context.onLoadedImage; + this.onMailtoLink = context.onMailtoLink; + this.onMozExtLink = context.onMozExtLink; + this.onNumeric = context.onNumeric; + this.onPassword = context.onPassword; + this.onSaveableLink = context.onSaveableLink; + this.onSpellcheckable = context.onSpellcheckable; + this.onTextInput = context.onTextInput; + this.onVideo = context.onVideo; + + this.target = context.target; + this.targetIdentifier = context.targetIdentifier; + + this.principal = context.principal; + this.storagePrincipal = context.storagePrincipal; + this.frameID = context.frameID; + this.frameOuterWindowID = context.frameOuterWindowID; + this.frameBrowsingContext = BrowsingContext.get( + context.frameBrowsingContextID + ); + + this.inSyntheticDoc = context.inSyntheticDoc; + this.inAboutDevtoolsToolbox = context.inAboutDevtoolsToolbox; + + // Everything after this isn't sent directly from ContextMenu + if (this.target) { + this.ownerDoc = this.target.ownerDocument; + } + + this.csp = E10SUtils.deserializeCSP(context.csp); + + if (!this.contentData) { + return; + } + + this.browser = this.contentData.browser; + if (this.browser && this.browser.currentURI.spec == "about:blank") { + this.shouldDisplay = false; + return; + } + this.selectionInfo = this.contentData.selectionInfo; + this.actor = this.contentData.actor; + + this.textSelected = this.selectionInfo?.text; + this.isTextSelected = !!this.textSelected?.length; + + this.webExtBrowserType = this.browser.getAttribute( + "webextension-view-type" + ); + + if (context.shouldInitInlineSpellCheckerUINoChildren) { + gSpellChecker.initFromRemote( + this.contentData.spellInfo, + this.actor.manager + ); + } + + if (this.contentData.spellInfo) { + this.spellSuggestions = this.contentData.spellInfo.spellSuggestions; + } + + if (context.shouldInitInlineSpellCheckerUIWithChildren) { + gSpellChecker.initFromRemote( + this.contentData.spellInfo, + this.actor.manager + ); + let canSpell = gSpellChecker.canSpellCheck && this.canSpellCheck; + this.showItem("browserContext-spell-check-enabled", canSpell); + this.showItem("browserContext-spell-separator", canSpell); + } + } + + hiding() { + if (this.actor) { + this.actor.hiding(); + } + + this.contentData = null; + gSpellChecker.clearSuggestionsFromMenu(); + gSpellChecker.clearDictionaryListFromMenu(); + gSpellChecker.uninit(); + } + + initItems() { + this.initSaveItems(); + this.initClipboardItems(); + this.initMediaPlayerItems(); + this.initBrowserItems(); + this.initSpellingItems(); + this.initSeparators(); + } + addDictionaries() { + openDictionaryList(); + } + initSpellingItems() { + let canSpell = + gSpellChecker.canSpellCheck && + !gSpellChecker.initialSpellCheckPending && + this.canSpellCheck; + let showDictionaries = canSpell && gSpellChecker.enabled; + let onMisspelling = gSpellChecker.overMisspelling; + let showUndo = canSpell && gSpellChecker.canUndo(); + this.showItem("browserContext-spell-check-enabled", canSpell); + this.showItem("browserContext-spell-separator", canSpell); + document + .getElementById("browserContext-spell-check-enabled") + .setAttribute("checked", canSpell && gSpellChecker.enabled); + + this.showItem("browserContext-spell-add-to-dictionary", onMisspelling); + this.showItem("browserContext-spell-undo-add-to-dictionary", showUndo); + + // suggestion list + this.showItem( + "browserContext-spell-suggestions-separator", + onMisspelling || showUndo + ); + if (onMisspelling) { + let addMenuItem = document.getElementById( + "browserContext-spell-add-to-dictionary" + ); + let suggestionCount = gSpellChecker.addSuggestionsToMenu( + addMenuItem.parentNode, + addMenuItem, + this.spellSuggestions + ); + this.showItem( + "browserContext-spell-no-suggestions", + suggestionCount == 0 + ); + } else { + this.showItem("browserContext-spell-no-suggestions", false); + } + + // dictionary list + this.showItem("browserContext-spell-dictionaries", showDictionaries); + if (canSpell) { + let dictMenu = document.getElementById( + "browserContext-spell-dictionaries-menu" + ); + let dictSep = document.getElementById( + "browserContext-spell-language-separator" + ); + let count = gSpellChecker.addDictionaryListToMenu(dictMenu, dictSep); + this.showItem(dictSep, count > 0); + this.showItem("browserContext-spell-add-dictionaries-main", 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 + this.showItem( + "browserContext-spell-language-separator", + showDictionaries + ); + this.showItem( + "browserContext-spell-add-dictionaries-main", + showDictionaries + ); + } else { + this.showItem("browserContext-spell-add-dictionaries-main", false); + } + } + initSaveItems() { + this.showItem("browserContext-savelink", this.onSaveableLink); + this.showItem("browserContext-saveimage", this.onLoadedImage); + } + initClipboardItems() { + // Copy depends on whether there is selected text. + // Enabling this context menu item is now done through the global + // command updating system. + + goUpdateGlobalEditMenuItems(); + + this.showItem("browserContext-cut", this.onTextInput); + this.showItem( + "browserContext-copy", + !this.onPlayableMedia && (this.isContentSelected || this.onTextInput) + ); + this.showItem("browserContext-paste", this.onTextInput); + + this.showItem("browserContext-undo", this.onTextInput); + // Select all not available in the thread pane or on playable media. + this.showItem("browserContext-selectall", !this.onPlayableMedia); + this.showItem("browserContext-copyemail", this.onMailtoLink); + this.showItem("browserContext-copylink", this.onLink && !this.onMailtoLink); + this.showItem("browserContext-copyimage", this.onImage); + + this.showItem("browserContext-composeemailto", this.onMailtoLink); + this.showItem("browserContext-addemail", this.onMailtoLink); + + let searchTheWeb = document.getElementById("browserContext-searchTheWeb"); + this.showItem( + searchTheWeb, + !this.onPlayableMedia && this.isContentSelected + ); + + if (!searchTheWeb.hidden) { + let selection = this.textSelected; + + let bundle = document.getElementById("bundle_messenger"); + let key = "openSearch.label"; + let abbrSelection; + if (selection.length > 15) { + key += ".truncated"; + abbrSelection = selection.slice(0, 15); + } else { + abbrSelection = selection; + } + + searchTheWeb.label = bundle.getFormattedString(key, [ + Services.search.defaultEngine.name, + abbrSelection, + ]); + searchTheWeb.value = selection; + } + } + initMediaPlayerItems() { + let onMedia = this.onVideo || this.onAudio; + // Several mutually exclusive items.... play/pause, mute/unmute, show/hide + this.showItem("browserContext-media-play", onMedia && this.target.paused); + this.showItem("browserContext-media-pause", onMedia && !this.target.paused); + this.showItem("browserContext-media-mute", onMedia && !this.target.muted); + this.showItem("browserContext-media-unmute", onMedia && this.target.muted); + if (onMedia) { + let hasError = + this.target.error != null || + this.target.networkState == this.target.NETWORK_NO_SOURCE; + this.setItemAttr("browserContext-media-play", "disabled", hasError); + this.setItemAttr("browserContext-media-pause", "disabled", hasError); + this.setItemAttr("browserContext-media-mute", "disabled", hasError); + this.setItemAttr("browserContext-media-unmute", "disabled", hasError); + } + } + initBackForwardMenuItemTooltip(menuItemId, l10nId, shortcutId) { + // On macOS regular menuitems are used and the shortcut isn't added. + if (AppConstants.platform == "macosx") { + return; + } + + let shortcut = document.getElementById(shortcutId); + if (shortcut) { + shortcut = ShortcutUtils.prettifyShortcut(shortcut); + } else { + // Sidebar doesn't have navigation buttons or shortcuts, but we still + // want to format the menu item tooltip to remove "$shortcut" string. + shortcut = ""; + } + let menuItem = document.getElementById(menuItemId); + document.l10n.setAttributes(menuItem, l10nId, { shortcut }); + } + initBrowserItems() { + // Work out if we are a context menu on a special item e.g. an image, link + // etc. + let onSpecialItem = + this.isContentSelected || + this.onCanvas || + this.onLink || + this.onImage || + this.onAudio || + this.onVideo || + this.onTextInput; + + // Internal about:* pages should not show nav items. + let shouldShowNavItems = + !onSpecialItem && this.browser.currentURI.scheme != "about"; + + // Ensure these commands are updated with their current status. + if (shouldShowNavItems) { + goUpdateCommand("Browser:Back"); + goUpdateCommand("Browser:Forward"); + goUpdateCommand("cmd_stop"); + goUpdateCommand("cmd_reload"); + } + + let stopped = document.getElementById("cmd_stop").hasAttribute("disabled"); + this.showItem("browserContext-reload", shouldShowNavItems && stopped); + this.showItem("browserContext-stop", shouldShowNavItems && !stopped); + this.showItem("browserContext-sep-navigation", shouldShowNavItems); + + if (AppConstants.platform == "macosx") { + this.showItem("browserContext-back", shouldShowNavItems); + this.showItem("browserContext-forward", shouldShowNavItems); + } else { + this.showItem("context-navigation", shouldShowNavItems); + + this.initBackForwardMenuItemTooltip( + "browserContext-back", + "content-tab-menu-back", + "key_goBackKb" + ); + this.initBackForwardMenuItemTooltip( + "browserContext-forward", + "content-tab-menu-forward", + "key_goForwardKb" + ); + } + + // Only show open in browser if we're not on a special item and we're not + // on an about: or chrome: protocol - for these protocols the browser is + // unlikely to show the same thing as we do (if at all), so therefore don't + // offer the option. + this.showItem( + "browserContext-openInBrowser", + !onSpecialItem && + ["http", "https"].includes(this.contentData?.documentURIObject?.scheme) + ); + + // Only show browserContext-openLinkInBrowser if we're on a link and it isn't + // a mailto link. + this.showItem( + "browserContext-openLinkInBrowser", + this.onLink && ["http", "https"].includes(this.linkProtocol) + ); + } + initSeparators() { + let separators = Array.from( + this.xulMenu.querySelectorAll(":scope > menuseparator") + ); + let lastShownSeparator = null; + for (let separator of separators) { + let shouldShow = this.shouldShowSeparator(separator); + if ( + !shouldShow && + lastShownSeparator && + separator.classList.contains("webextension-group-separator") + ) { + // The separator for the WebExtension elements group must be shown, hide + // the last shown menu separator instead. + lastShownSeparator.hidden = true; + shouldShow = true; + } + if (shouldShow) { + lastShownSeparator = separator; + } + separator.hidden = !shouldShow; + } + this.checkLastSeparator(this.xulMenu); + } + + /** + * Get a computed style property for an element. + * + * @param aElem + * A DOM node + * @param aProp + * The desired CSS property + * @returns the value of the property + */ + getComputedStyle(aElem, aProp) { + return aElem.ownerGlobal.getComputedStyle(aElem).getPropertyValue(aProp); + } + + /** + * Determine whether the clicked-on link can be saved, and whether it + * may be saved according to the ScriptSecurityManager. + * + * @returns true if the protocol can be persisted and if the target has + * permission to link to the URL, false if not + */ + isLinkSaveable() { + try { + Services.scriptSecurityManager.checkLoadURIWithPrincipal( + this.target.nodePrincipal, + this.linkURI, + Ci.nsIScriptSecurityManager.STANDARD + ); + } catch (e) { + // Don't save things we can't link to. + return false; + } + + // We don't do the Right Thing for news/snews yet, so turn them off + // until we do. + return ( + this.linkProtocol && + !( + this.linkProtocol == "mailto" || + this.linkProtocol == "javascript" || + this.linkProtocol == "news" || + this.linkProtocol == "snews" + ) + ); + } + + /** + * Save URL of clicked-on link. + */ + saveLink() { + saveURL( + this.linkURL, + null, + this.linkTextStr, + null, + true, + null, + null, + null, + document + ); + } + + /** + * Save a clicked-on image. + */ + saveImage() { + saveURL( + this.imageInfo.currentSrc, + null, + null, + "SaveImageTitle", + false, + null, + null, + null, + document + ); + } + + /** + * Extract email addresses from a mailto: link and put them on the + * clipboard. + */ + copyEmail() { + // Copy the comma-separated list of email addresses only. + // There are other ways of embedding email addresses in a mailto: + // link, but such complex parsing is beyond us. + + const kMailToLength = 7; // length of "mailto:" + + var url = this.linkURL; + var qmark = url.indexOf("?"); + var addresses; + + if (qmark > kMailToLength) { + addresses = url.substring(kMailToLength, qmark); + } else { + addresses = url.substr(kMailToLength); + } + + // Let's try to unescape it using a character set. + try { + addresses = Services.textToSubURI.unEscapeURIForUI(addresses); + } catch (ex) { + // Do nothing. + } + + var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + clipboard.copyString(addresses); + } + + // --------- + // Utilities + + /** + * Set a DOM node's hidden property by passing in the node's id or the + * element itself. + * + * @param aItemOrId + * a DOM node or the id of a DOM node + * @param aShow + * true to show, false to hide + */ + showItem(aItemOrId, aShow) { + var item = + aItemOrId.constructor == String + ? document.getElementById(aItemOrId) + : aItemOrId; + if (item) { + item.hidden = !aShow; + } + } + + /** + * Set a DOM node's disabled property by passing in the node's id or the + * element itself. + * + * @param aItemOrId A DOM node or the id of a DOM node + * @param aEnabled True to enable the element, false to disable. + */ + enableItem(aItemOrId, aEnabled) { + var item = + aItemOrId.constructor == String + ? document.getElementById(aItemOrId) + : aItemOrId; + item.disabled = !aEnabled; + } + + /** + * Set given attribute of specified context-menu item. If the + * value is null, then it removes the attribute (which works + * nicely for the disabled attribute). + * + * @param aId + * The id of an element + * @param aAttr + * The attribute name + * @param aVal + * The value to set the attribute to, or null to remove the attribute + */ + setItemAttr(aId, aAttr, aVal) { + var elem = document.getElementById(aId); + if (elem) { + if (aVal == null) { + // null indicates attr should be removed. + elem.removeAttribute(aAttr); + } else { + // Set attr=val. + elem.setAttribute(aAttr, aVal); + } + } + } + + /** + * Get an absolute URL for clicked-on link, from the href property or by + * resolving an XLink URL by hand. + * + * @returns the string absolute URL for the clicked-on link + */ + getLinkURL() { + if (this.link.href) { + return this.link.href; + } + var href = this.link.getAttributeNS("http://www.w3.org/1999/xlink", "href"); + if (!href || href.trim() == "") { + // Without this we try to save as the current doc, + // for example, HTML case also throws if empty. + throw new Error("Empty href"); + } + href = this.makeURLAbsolute(this.link.baseURI, href); + return href; + } + + /** + * Generate a URI object from the linkURL spec + * + * @returns an nsIURI if possible, or null if not + */ + getLinkURI() { + try { + return Services.io.newURI(this.linkURL); + } catch (ex) { + // e.g. empty URL string + } + return null; + } + + /** + * Get the scheme for the clicked-on linkURI, if present. + * + * @returns a scheme, possibly undefined, or null if there's no linkURI + */ + getLinkProtocol() { + if (this.linkURI) { + return this.linkURI.scheme; // Can be |undefined|. + } + + return null; + } + + /** + * Get the text of the clicked-on link. + * + * @returns {string} + */ + linkText() { + return this.linkTextStr; + } + + /** + * Determines whether the focused window has something selected. + * + * @returns true if there is a selection, false if not + */ + isContentSelection() { + return !document.commandDispatcher.focusedWindow.getSelection().isCollapsed; + } + + /** + * Convert relative URL to absolute, using a provided <base>. + * + * @param aBase + * The URL string to use as the base + * @param aUrl + * The possibly-relative URL string + * @returns The string absolute URL + */ + makeURLAbsolute(aBase, aUrl) { + // Construct nsIURL. + var baseURI = Services.io.newURI(aBase); + + return Services.io.newURI(baseURI.resolve(aUrl)).spec; + } + + /** + * Determine whether a DOM node is a text or password input, or a textarea. + * + * @param aNode + * The DOM node to check + * @returns true for textboxes, false for other elements + */ + isTargetATextBox(aNode) { + if (HTMLInputElement.isInstance(aNode)) { + return aNode.type == "text" || aNode.type == "password"; + } + + return HTMLTextAreaElement.isInstance(aNode); + } + + /** + * Determine whether a separator should be shown based on whether + * there are any non-hidden items between it and the previous separator. + * + * @param {DomElement} element - The separator element. + * @returns {boolean} True if the separator should be shown, false if not. + */ + shouldShowSeparator(element) { + if (element) { + let sibling = element.previousElementSibling; + while (sibling && sibling.localName != "menuseparator") { + if (!sibling.hidden) { + return true; + } + sibling = sibling.previousElementSibling; + } + } + return false; + } + + /** + * Ensures that there isn't a separator shown at the bottom of the menu. + * + * @param aPopup The menu to check. + */ + checkLastSeparator(aPopup) { + let sibling = aPopup.lastElementChild; + while (sibling) { + if (!sibling.hidden) { + if (sibling.localName == "menuseparator") { + // If we got here then the item is a menuseparator and everything + // below it hidden. + sibling.setAttribute("hidden", true); + return; + } + return; + } + sibling = sibling.previousElementSibling; + } + } + + openInBrowser() { + let url = this.contentData?.documentURIObject?.spec; + if (!url) { + return; + } + PlacesUtils.history + .insert({ + url, + visits: [ + { + date: new Date(), + }, + ], + }) + .catch(console.error); + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(url)); + } + + openLinkInBrowser() { + PlacesUtils.history + .insert({ + url: this.linkURL, + visits: [ + { + date: new Date(), + }, + ], + }) + .catch(console.error); + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(this.linkURI); + } + + mediaCommand(command) { + var media = this.target; + + switch (command) { + case "play": + media.play(); + break; + case "pause": + media.pause(); + break; + case "mute": + media.muted = true; + break; + case "unmute": + media.muted = false; + break; + // XXX hide controls & show controls don't work in emails as Javascript is + // disabled. May want to consider later for RSS feeds. + } + } +} + +ChromeUtils.defineESModuleGetters(nsContextMenu, { + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); diff --git a/comm/mail/base/content/widgets/customizable-toolbar.js b/comm/mail/base/content/widgets/customizable-toolbar.js new file mode 100644 index 0000000000..350e814716 --- /dev/null +++ b/comm/mail/base/content/widgets/customizable-toolbar.js @@ -0,0 +1,319 @@ +/* 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/. */ + +"use strict"; + +/* globals MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * Extends the built-in `toolbar` element to allow it to be customized. + * + * @augments {MozXULElement} + */ + class CustomizableToolbar extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback() || this._hasConnected) { + return; + } + this._hasConnected = true; + + this._toolbox = null; + this._newElementCount = 0; + + // Search for the toolbox palette in the toolbar binding because + // toolbars are constructed first. + let toolbox = this.toolbox; + if (!toolbox) { + return; + } + + if (!toolbox.palette) { + // Look to see if there is a toolbarpalette. + let node = toolbox.firstElementChild; + while (node) { + if (node.localName == "toolbarpalette") { + break; + } + node = node.nextElementSibling; + } + + if (!node) { + return; + } + + // Hold on to the palette but remove it from the document. + toolbox.palette = node; + toolbox.removeChild(node); + } + + // Build up our contents from the palette. + let currentSet = + this.getAttribute("currentset") || this.getAttribute("defaultset"); + + if (currentSet) { + this.currentSet = currentSet; + } + } + + /** + * Get the toolbox element connected to this toolbar. + * + * @returns {Element?} The toolbox element or null. + */ + get toolbox() { + if (this._toolbox) { + return this._toolbox; + } + + let toolboxId = this.getAttribute("toolboxid"); + if (toolboxId) { + let toolbox = document.getElementById(toolboxId); + if (!toolbox) { + let tbName = this.hasAttribute("toolbarname") + ? ` (${this.getAttribute("toolbarname")})` + : ""; + + throw new Error( + `toolbar ID ${this.id}${tbName}: toolboxid attribute '${toolboxId}' points to a toolbox that doesn't exist` + ); + } + this._toolbox = toolbox; + return this._toolbox; + } + + this._toolbox = + this.parentNode && this.parentNode.localName == "toolbox" + ? this.parentNode + : null; + + return this._toolbox; + } + + /** + * Sets the current set of items in the toolbar. + * + * @param {string} val - Comma-separated list of IDs or "__empty". + * @returns {string} Comma-separated list of IDs or "__empty". + */ + set currentSet(val) { + if (val == this.currentSet) { + return; + } + + // Build a cache of items in the toolbarpalette. + let palette = this.toolbox ? this.toolbox.palette : null; + let paletteChildren = palette ? palette.children : []; + + let paletteItems = {}; + + for (let item of paletteChildren) { + paletteItems[item.id] = item; + } + + let ids = val == "__empty" ? [] : val.split(","); + let children = this.children; + let nodeidx = 0; + let added = {}; + + // Iterate over the ids to use on the toolbar. + for (let id of ids) { + // Iterate over the existing nodes on the toolbar. nodeidx is the + // spot where we want to insert items. + let found = false; + for (let i = nodeidx; i < children.length; i++) { + let curNode = children[i]; + if (this._idFromNode(curNode) == id) { + // The node already exists. If i equals nodeidx, we haven't + // iterated yet, so the item is already in the right position. + // Otherwise, insert it here. + if (i != nodeidx) { + this.insertBefore(curNode, children[nodeidx]); + } + + added[curNode.id] = true; + nodeidx++; + found = true; + break; + } + } + if (found) { + // Move on to the next id. + continue; + } + + // The node isn't already on the toolbar, so add a new one. + let nodeToAdd = paletteItems[id] || this._getToolbarItem(id); + if (nodeToAdd && !(nodeToAdd.id in added)) { + added[nodeToAdd.id] = true; + this.insertBefore(nodeToAdd, children[nodeidx] || null); + nodeToAdd.setAttribute("removable", "true"); + nodeidx++; + } + } + + // Remove any leftover removable nodes. + for (let i = children.length - 1; i >= nodeidx; i--) { + let curNode = children[i]; + + let curNodeId = this._idFromNode(curNode); + // Skip over fixed items. + if (curNodeId && curNode.getAttribute("removable") == "true") { + if (palette) { + palette.appendChild(curNode); + } else { + this.removeChild(curNode); + } + } + } + } + + /** + * Gets the current set of items in the toolbar. + * + * @returns {string} Comma-separated list of IDs or "__empty". + */ + get currentSet() { + let node = this.firstElementChild; + let currentSet = []; + while (node) { + let id = this._idFromNode(node); + if (id) { + currentSet.push(id); + } + node = node.nextElementSibling; + } + + return currentSet.join(",") || "__empty"; + } + + /** + * Return the ID for a given toolbar item node, with special handling for + * some cases. + * + * @param {Element} node - Return the ID of this node. + * @returns {string} The ID of the node. + */ + _idFromNode(node) { + if (node.getAttribute("skipintoolbarset") == "true") { + return ""; + } + const specialItems = { + toolbarseparator: "separator", + toolbarspring: "spring", + toolbarspacer: "spacer", + }; + return specialItems[node.localName] || node.id; + } + + /** + * Returns a toolbar item based on the given ID. + * + * @param {string} id - The ID for the new toolbar item. + * @returns {Element?} The toolbar item corresponding to the ID, or null. + */ + _getToolbarItem(id) { + // Handle special cases. + if (["separator", "spring", "spacer"].includes(id)) { + let newItem = document.createXULElement("toolbar" + id); + // Due to timers resolution Date.now() can be the same for + // elements created in small timeframes. So ids are + // differentiated through a unique count suffix. + newItem.id = id + Date.now() + ++this._newElementCount; + if (id == "spring") { + newItem.flex = 1; + } + return newItem; + } + + let toolbox = this.toolbox; + if (!toolbox) { + return null; + } + + // Look for an item with the same id, as the item may be + // in a different toolbar. + let item = document.getElementById(id); + if ( + item && + item.parentNode && + item.parentNode.localName == "toolbar" && + item.parentNode.toolbox == toolbox + ) { + return item; + } + + if (toolbox.palette) { + // Attempt to locate an item with a matching ID within the palette. + let paletteItem = toolbox.palette.firstElementChild; + while (paletteItem) { + if (paletteItem.id == id) { + return paletteItem; + } + paletteItem = paletteItem.nextElementSibling; + } + } + return null; + } + + /** + * Insert an item into the toolbar. + * + * @param {string} id - The ID of the item to insert. + * @param {Element?} beforeElt - Optional element to insert the item before. + * @param {Element?} wrapper - Optional wrapper element. + * @returns {Element} The inserted item. + */ + insertItem(id, beforeElt, wrapper) { + let newItem = this._getToolbarItem(id); + if (!newItem) { + return null; + } + + let insertItem = newItem; + // Make sure added items are removable. + newItem.setAttribute("removable", "true"); + + // Wrap the item in another node if so inclined. + if (wrapper) { + wrapper.appendChild(newItem); + insertItem = wrapper; + } + + // Insert the palette item into the toolbar. + if (beforeElt) { + this.insertBefore(insertItem, beforeElt); + } else { + this.appendChild(insertItem); + } + return newItem; + } + + /** + * Determine whether the current set of toolbar items has custom + * interactive items or not. + * + * @param {string} currentSet - Comma-separated list of IDs or "__empty". + * @returns {boolean} Whether the current set has custom interactive items. + */ + hasCustomInteractiveItems(currentSet) { + if (currentSet == "__empty") { + return false; + } + + let defaultOrNoninteractive = (this.getAttribute("defaultset") || "") + .split(",") + .concat(["separator", "spacer", "spring"]); + + return currentSet + .split(",") + .some(item => !defaultOrNoninteractive.includes(item)); + } + } + + customElements.define("customizable-toolbar", CustomizableToolbar, { + extends: "toolbar", + }); +} diff --git a/comm/mail/base/content/widgets/foldersummary.js b/comm/mail/base/content/widgets/foldersummary.js new file mode 100644 index 0000000000..48bcb34d26 --- /dev/null +++ b/comm/mail/base/content/widgets/foldersummary.js @@ -0,0 +1,295 @@ +/** + * 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 */ +/* global MozXULElement */ +/* import-globals-from ../../../../mailnews/base/content/newmailalert.js */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + + /** + * MozFolderSummary displays a listing of NEW mails for the folder in question. + * For each mail the subject, sender and a message preview can be included. + * + * @augments {MozXULElement} + */ + class MozFolderSummary extends MozXULElement { + constructor() { + super(); + this.maxMsgHdrsInPopup = 8; + + this.showSubject = Services.prefs.getBoolPref( + "mail.biff.alert.show_subject" + ); + this.showSender = Services.prefs.getBoolPref( + "mail.biff.alert.show_sender" + ); + this.showPreview = Services.prefs.getBoolPref( + "mail.biff.alert.show_preview" + ); + this.messengerBundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + + ChromeUtils.defineModuleGetter( + this, + "MailUtils", + "resource:///modules/MailUtils.jsm" + ); + } + + hasMessages() { + return this.lastElementChild; + } + + static createFolderSummaryMessage() { + let vbox = document.createXULElement("vbox"); + vbox.setAttribute("class", "folderSummaryMessage"); + + let hbox = document.createXULElement("hbox"); + hbox.setAttribute("class", "folderSummary-message-row"); + + let subject = document.createXULElement("label"); + subject.setAttribute("class", "folderSummary-subject"); + + let sender = document.createXULElement("label"); + sender.setAttribute("class", "folderSummary-sender"); + sender.setAttribute("crop", "end"); + + hbox.appendChild(subject); + hbox.appendChild(sender); + + let preview = document.createXULElement("description"); + preview.setAttribute( + "class", + "folderSummary-message-row folderSummary-previewText" + ); + preview.setAttribute("crop", "end"); + + vbox.appendChild(hbox); + vbox.appendChild(preview); + return vbox; + } + + /** + * Check the given folder for NEW messages. + * + * @param {nsIMsgFolder} folder - The folder to examine. + * @param {nsIUrlListener} urlListener - Listener to notify if we run urls + * to fetch msgs. + * @param Object outAsync - Object with value property set to true if there + * are async fetches pending (a message preview will be available later). + * @returns true if the folder knows about messages that should be shown. + */ + parseFolder(folder, urlListener, outAsync) { + // Skip servers, Trash, Junk folders and newsgroups. + if ( + !folder || + folder.isServer || + !folder.hasNewMessages || + folder.getFlag(Ci.nsMsgFolderFlags.Junk) || + folder.getFlag(Ci.nsMsgFolderFlags.Trash) || + folder.server instanceof Ci.nsINntpIncomingServer + ) { + return false; + } + + let folderArray = []; + let msgDatabase; + try { + msgDatabase = folder.msgDatabase; + } catch (e) { + // The database for this folder may be missing (e.g. outdated/missing .msf), + // so just skip this folder. + return false; + } + + if (folder.flags & Ci.nsMsgFolderFlags.Virtual) { + let srchFolderUri = + msgDatabase.dBFolderInfo.getCharProperty("searchFolderUri"); + let folderUris = srchFolderUri.split("|"); + for (let uri of folderUris) { + let realFolder = this.MailUtils.getOrCreateFolder(uri); + if (!realFolder.isServer) { + folderArray.push(realFolder); + } + } + } else { + folderArray.push(folder); + } + + let haveMsgsToShow = false; + for (let folder of folderArray) { + // now get the database + try { + msgDatabase = folder.msgDatabase; + } catch (e) { + // The database for this folder may be missing (e.g. outdated/missing .msf), + // then just skip this folder. + continue; + } + + folder.msgDatabase = null; + let msgKeys = msgDatabase.getNewList(); + + let numNewMessages = folder.getNumNewMessages(false); + if (!numNewMessages) { + continue; + } + // NOTE: getNewlist returns all nsMsgMessageFlagType::New messages, + // while getNumNewMessages returns count of new messages since the last + // biff. Only show newly received messages since last biff in + // notification. + msgKeys = msgKeys.slice(-numNewMessages); + if (!msgKeys.length) { + continue; + } + + if (this.showPreview) { + // fetchMsgPreviewText forces the previewText property to get generated + // for each of the message keys. + try { + outAsync.value = folder.fetchMsgPreviewText(msgKeys, urlListener); + folder.msgDatabase = null; + } catch (ex) { + // fetchMsgPreviewText throws an error when we call it on a news + // folder + folder.msgDatabase = null; + continue; + } + } + + // If fetching the preview text is going to be an asynch operation and the + // caller is set up to handle that fact, then don't bother filling in any + // of the fields since we'll have to do this all over again when the fetch + // for the preview text completes. + // We don't expect to get called with a urlListener if we're doing a + // virtual folder. + if (outAsync.value && urlListener) { + return false; + } + + // In the case of async fetching for more than one folder, we may + // already have got enough to show (added by another urllistener). + let curHdrsInPopup = this.children.length; + if (curHdrsInPopup >= this.maxMsgHdrsInPopup) { + return false; + } + + for ( + let i = 0; + i + curHdrsInPopup < this.maxMsgHdrsInPopup && i < msgKeys.length; + i++ + ) { + let msgBox = MozFolderSummary.createFolderSummaryMessage(); + let msgHdr = msgDatabase.getMsgHdrForKey(msgKeys[i]); + msgBox.addEventListener("click", event => { + if (event.button !== 0) { + return; + } + this.MailUtils.displayMessageInFolderTab(msgHdr, true); + }); + + if (this.showSubject) { + let msgSubject = msgHdr.mime2DecodedSubject; + const kMsgFlagHasRe = 0x0010; // MSG_FLAG_HAS_RE + if (msgHdr.flags & kMsgFlagHasRe) { + msgSubject = msgSubject ? "Re: " + msgSubject : "Re: "; + } + msgBox.querySelector(".folderSummary-subject").textContent = + msgSubject; + } + + if (this.showSender) { + let addrs = MailServices.headerParser.parseEncodedHeader( + msgHdr.author, + msgHdr.effectiveCharset, + false + ); + let folderSummarySender = msgBox.querySelector( + ".folderSummary-sender" + ); + // Set the label value instead of textContent to avoid wrapping. + folderSummarySender.value = + addrs.length > 0 ? addrs[0].name || addrs[0].email : ""; + if (addrs.length > 1) { + let andOthersStr = + this.messengerBundle.GetStringFromName("andOthers"); + folderSummarySender.value += " " + andOthersStr; + } + } + + if (this.showPreview) { + msgBox.querySelector(".folderSummary-previewText").textContent = + msgHdr.getStringProperty("preview") || ""; + } + this.appendChild(msgBox); + haveMsgsToShow = true; + } + } + return haveMsgsToShow; + } + + /** + * Render NEW messages in a folder. + * + * @param {nsIMsgFolder} folder - A real folder containing new messages. + * @param {number[]} msgKeys - The keys of new messages. + */ + render(folder, msgKeys) { + let msgDatabase = folder.msgDatabase; + for (let msgKey of msgKeys.slice(0, this.maxMsgHdrsInPopup)) { + let msgBox = MozFolderSummary.createFolderSummaryMessage(); + let msgHdr = msgDatabase.getMsgHdrForKey(msgKey); + msgBox.addEventListener("click", event => { + if (event.button !== 0) { + return; + } + this.MailUtils.displayMessageInFolderTab(msgHdr, true); + }); + + if (this.showSubject) { + let msgSubject = msgHdr.mime2DecodedSubject; + const kMsgFlagHasRe = 0x0010; // MSG_FLAG_HAS_RE + if (msgHdr.flags & kMsgFlagHasRe) { + msgSubject = msgSubject ? "Re: " + msgSubject : "Re: "; + } + msgBox.querySelector(".folderSummary-subject").textContent = + msgSubject; + } + + if (this.showSender) { + let addrs = MailServices.headerParser.parseEncodedHeader( + msgHdr.author, + msgHdr.effectiveCharset, + false + ); + let folderSummarySender = msgBox.querySelector( + ".folderSummary-sender" + ); + // Set the label value instead of textContent to avoid wrapping. + folderSummarySender.value = + addrs.length > 0 ? addrs[0].name || addrs[0].email : ""; + if (addrs.length > 1) { + let andOthersStr = + this.messengerBundle.GetStringFromName("andOthers"); + folderSummarySender.value += " " + andOthersStr; + } + } + + if (this.showPreview) { + msgBox.querySelector(".folderSummary-previewText").textContent = + msgHdr.getStringProperty("preview") || ""; + } + this.appendChild(msgBox); + } + } + } + customElements.define("folder-summary", MozFolderSummary); +} diff --git a/comm/mail/base/content/widgets/gloda-autocomplete-input.js b/comm/mail/base/content/widgets/gloda-autocomplete-input.js new file mode 100644 index 0000000000..59f71ba6ae --- /dev/null +++ b/comm/mail/base/content/widgets/gloda-autocomplete-input.js @@ -0,0 +1,243 @@ +/** + * 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 MozXULElement */ + +"use strict"; + +// The autocomplete CE is defined lazily. Create one now to get +// autocomplete-input defined, allowing us to inherit from it. +if (!customElements.get("autocomplete-input")) { + delete document.createXULElement("input", { is: "autocomplete-input" }); +} + +customElements.whenDefined("autocomplete-input").then(() => { + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + const lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { + GlodaIMSearcher: "resource:///modules/GlodaIMSearcher.sys.mjs", + }); + ChromeUtils.defineModuleGetter( + lazy, + "Gloda", + "resource:///modules/gloda/GlodaPublic.jsm" + ); + ChromeUtils.defineModuleGetter( + lazy, + "GlodaMsgSearcher", + "resource:///modules/gloda/GlodaMsgSearcher.jsm" + ); + ChromeUtils.defineModuleGetter( + lazy, + "GlodaConstants", + "resource:///modules/gloda/GlodaConstants.jsm" + ); + + XPCOMUtils.defineLazyGetter( + lazy, + "glodaCompleter", + () => + Cc["@mozilla.org/autocomplete/search;1?name=gloda"].getService( + Ci.nsIAutoCompleteSearch + ).wrappedJSObject + ); + + /** + * The MozGlodaAutocompleteInput widget is used to display the autocomplete search bar. + * + * @augments {AutocompleteInput} + */ + class MozGlodaAutocompleteInput extends customElements.get( + "autocomplete-input" + ) { + constructor() { + super(); + + this.addEventListener( + "drop", + event => { + this.searchInputDNDObserver.onDrop(event); + }, + true + ); + + this.addEventListener("keypress", event => { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + // Trigger the click event if a popup result is currently selected. + if (this.popup.richlistbox.selectedIndex != -1) { + this.popup.onPopupClick(event); + } else { + this.doSearch(); + } + event.preventDefault(); + event.stopPropagation(); + } + + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + this.clearSearch(); + event.preventDefault(); + event.stopPropagation(); + } + }); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + + this.hasConnected = true; + super.connectedCallback(); + + this.setAttribute("is", "gloda-autocomplete-input"); + + // @implements {nsIObserver} + this.searchInputDNDObserver = { + onDrop: event => { + if (event.dataTransfer.types.includes("text/x-moz-address")) { + this.focus(); + this.value = event.dataTransfer.getData("text/plain"); + // XXX for some reason the input field is _cleared_ even though + // the search works. + this.doSearch(); + } + event.stopPropagation(); + }, + }; + + // @implements {nsIObserver} + this.textObserver = { + observe: (subject, topic, data) => { + try { + // Some autocomplete controllers throw NS_ERROR_NOT_IMPLEMENTED. + subject.popupElement; + } catch (ex) { + return; + } + if ( + topic == "autocomplete-did-enter-text" && + document.activeElement == this + ) { + let selectedIndex = this.popup.selectedIndex; + let curResult = lazy.glodaCompleter.curResult; + if (!curResult) { + // autocomplete didn't even finish. + return; + } + let row = curResult.getObjectAt(selectedIndex); + if (row == null) { + return; + } + if (row.fullText) { + // The autocomplete-did-enter-text notification is synchronously + // generated by nsAutoCompleteController which will attempt to + // call ClosePopup after we return and then tell the searchbox + // about the text entered. Since doSearch may close the current + // tab (and thus destroy the XUL document that owns the popup and + // the input field), the search box may no longer have its + // binding attached when we return and telling it about the + // entered text could fail. + // To avoid this, we defer the doSearch call to the next turn of + // the event loop by using setTimeout. + setTimeout(this.doSearch.bind(this), 0); + } else if (row.nounDef) { + let theQuery = lazy.Gloda.newQuery( + lazy.GlodaConstants.NOUN_MESSAGE + ); + if (row.nounDef.name == "tag") { + theQuery = theQuery.tags(row.item); + } else if (row.nounDef.name == "identity") { + theQuery = theQuery.involves(row.item); + } + theQuery.orderBy("-date"); + document.getElementById("tabmail").openTab("glodaFacet", { + query: theQuery, + }); + } + } + }, + }; + + let keyLabel = + AppConstants.platform == "macosx" ? "keyLabelMac" : "keyLabelNonMac"; + let placeholder = this.getAttribute("emptytextbase").replace( + "#1", + this.getAttribute(keyLabel) + ); + + this.setAttribute("placeholder", placeholder); + + Services.obs.addObserver( + this.textObserver, + "autocomplete-did-enter-text" + ); + + // make sure we set our emptytext here from the get-go + if (this.hasAttribute("placeholder")) { + this.placeholder = this.getAttribute("placeholder"); + } + } + + set state(val) { + this.value = val.string; + } + + get state() { + return { string: this.value }; + } + + doSearch() { + if (this.value) { + let tabmail = document.getElementById("tabmail"); + // If the current tab is a gloda search tab, reset the value + // to the initial search value. Otherwise, clear it. This + // is the value that is going to be saved with the current + // tab when we switch back to it next. + let searchString = this.value; + + if (tabmail.currentTabInfo.mode.name == "glodaFacet") { + // We'd rather reuse the existing tab (and somehow do something + // smart with any preexisting facet choices, but that's a + // bit hard right now, so doing the cheap thing and closing + // this tab and starting over. + tabmail.closeTab(); + } + this.value = ""; // clear our value, to avoid persistence + let args = { + searcher: new lazy.GlodaMsgSearcher(null, searchString), + }; + if (Services.prefs.getBoolPref("mail.chat.enabled")) { + args.IMSearcher = new lazy.GlodaIMSearcher(null, searchString); + } + tabmail.openTab("glodaFacet", args); + } + } + + clearSearch() { + this.value = ""; + } + + disconnectedCallback() { + Services.obs.removeObserver( + this.textObserver, + "autocomplete-did-enter-text" + ); + this.hasConnected = false; + } + } + + MozXULElement.implementCustomInterface(MozGlodaAutocompleteInput, [ + Ci.nsIObserver, + ]); + customElements.define("gloda-autocomplete-input", MozGlodaAutocompleteInput, { + extends: "input", + }); +}); diff --git a/comm/mail/base/content/widgets/glodaFacet.js b/comm/mail/base/content/widgets/glodaFacet.js new file mode 100644 index 0000000000..c8d1e78dd8 --- /dev/null +++ b/comm/mail/base/content/widgets/glodaFacet.js @@ -0,0 +1,1823 @@ +/* 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 DateFacetVis, FacetContext */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + const { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm"); + const { FacetUtils } = ChromeUtils.import( + "resource:///modules/gloda/Facet.jsm" + ); + const { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" + ); + const { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm"); + + var glodaFacetStrings = Services.strings.createBundle( + "chrome://messenger/locale/glodaFacetView.properties" + ); + + class MozFacetDate extends HTMLElement { + get build() { + return this.buildFunc; + } + + get brushItems() { + return items => this.vis.hoverItems(items); + } + + get clearBrushedItems() { + return () => this.vis.clearHover(); + } + + connectedCallback() { + const wrapper = document.createElement("div"); + wrapper.classList.add("facet", "date-wrapper"); + + const h2 = document.createElement("h2"); + + const canvas = document.createElement("div"); + canvas.classList.add("date-vis-frame"); + + const zoomOut = document.createElement("div"); + zoomOut.classList.add("facet-date-zoom-out"); + zoomOut.setAttribute("role", "image"); + zoomOut.addEventListener("click", () => FacetContext.zoomOut()); + + wrapper.appendChild(h2); + wrapper.appendChild(canvas); + wrapper.appendChild(zoomOut); + this.appendChild(wrapper); + + this.canUpdate = true; + this.canvasNode = canvas; + this.vis = null; + if ("faceter" in this) { + this.buildFunc(true); + } + } + + buildFunc(aDoSize) { + if (!this.vis) { + this.vis = new DateFacetVis(this, this.canvasNode); + this.vis.build(); + } else { + while (this.canvasNode.hasChildNodes()) { + this.canvasNode.lastChild.remove(); + } + if (aDoSize) { + this.vis.build(); + } else { + this.vis.rebuild(); + } + } + } + } + + customElements.define("facet-date", MozFacetDate); + + /** + * MozFacetResultsMessage shows the search results for the string entered in gloda-searchbox. + * + * @augments {HTMLElement} + */ + class MozFacetResultsMessage extends HTMLElement { + connectedCallback() { + const header = document.createElement("div"); + header.classList.add("results-message-header"); + + this.countNode = document.createElement("h2"); + this.countNode.classList.add("results-message-count"); + + this.toggleTimeline = document.createElement("button"); + this.toggleTimeline.setAttribute("id", "date-toggle"); + this.toggleTimeline.setAttribute("tabindex", 0); + this.toggleTimeline.classList.add("gloda-timeline-button"); + this.toggleTimeline.addEventListener("click", () => { + FacetContext.toggleTimeline(); + }); + + const timelineImage = document.createElement("img"); + timelineImage.setAttribute( + "src", + "chrome://messenger/skin/icons/popular.svg" + ); + timelineImage.setAttribute("alt", ""); + this.toggleTimeline.appendChild(timelineImage); + + this.toggleText = document.createElement("span"); + this.toggleTimeline.appendChild(this.toggleText); + + const sortDiv = document.createElement("div"); + sortDiv.classList.add("results-message-sort-bar"); + + this.sortSelect = document.createElement("select"); + this.sortSelect.setAttribute("id", "sortby"); + let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby"); + + let relevanceItem = document.createElement("option"); + relevanceItem.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.sort.relevance2" + ); + relevanceItem.setAttribute("value", "-dascore"); + relevanceItem.toggleAttribute( + "selected", + sortByPref <= 0 || sortByPref == 2 || sortByPref > 3 + ); + this.sortSelect.appendChild(relevanceItem); + + let dateItem = document.createElement("option"); + dateItem.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.sort.date2" + ); + dateItem.setAttribute("value", "-date"); + dateItem.toggleAttribute("selected", sortByPref == 1 || sortByPref == 3); + this.sortSelect.appendChild(dateItem); + + this.messagesNode = document.createElement("div"); + this.messagesNode.classList.add("messages"); + + header.appendChild(this.countNode); + header.appendChild(this.toggleTimeline); + header.appendChild(sortDiv); + + sortDiv.appendChild(this.sortSelect); + + this.appendChild(header); + this.appendChild(this.messagesNode); + } + + setMessages(messages) { + let topMessagesPluralFormat = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.header.countLabel.NMessages" + ); + let outOfPluralFormat = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.header.countLabel.ofN" + ); + let groupingFormat = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.header.countLabel.grouping" + ); + + let displayCount = messages.length; + let totalCount = FacetContext.activeSet.length; + + // set the count so CSS selectors can know what the results look like + this.setAttribute("state", totalCount <= 0 ? "empty" : "some"); + + let topMessagesStr = PluralForm.get( + displayCount, + topMessagesPluralFormat + ).replace("#1", displayCount.toLocaleString()); + let outOfStr = PluralForm.get(totalCount, outOfPluralFormat).replace( + "#1", + totalCount.toLocaleString() + ); + + this.countNode.textContent = groupingFormat + .replace("#1", topMessagesStr) + .replace("#2", outOfStr); + + this.toggleText.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.timeline.label" + ); + + let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby"); + this.sortSelect.addEventListener("change", () => { + if (sortByPref >= 2) { + Services.prefs.setIntPref( + "gloda.facetview.sortby", + this.sortSelect.value == "-dascore" ? 2 : 3 + ); + } + + FacetContext.sortBy = this.sortSelect.value; + }); + + while (this.messagesNode.hasChildNodes()) { + this.messagesNode.lastChild.remove(); + } + try { + // -- Messages + for (let message of messages) { + let msgNode = document.createElement("facet-result-message"); + msgNode.message = message; + msgNode.setAttribute("class", "message"); + this.messagesNode.appendChild(msgNode); + } + } catch (e) { + console.error(e); + } + } + } + + customElements.define("facet-results-message", MozFacetResultsMessage); + + class MozFacetBoolean extends HTMLElement { + constructor() { + super(); + + this.addEventListener("mouseover", event => { + FacetContext.hoverFacet( + this.faceter, + this.faceter.attrDef, + true, + this.trueValues + ); + }); + + this.addEventListener("mouseout", event => { + FacetContext.unhoverFacet( + this.faceter, + this.faceter.attrDef, + true, + this.trueValues + ); + }); + } + + connectedCallback() { + this.addChildren(); + + this.canUpdate = true; + this.bubble.addEventListener("click", event => { + return this.bubbleClicked(event); + }); + + if ("faceter" in this) { + this.build(true); + } + } + + addChildren() { + this.bubble = document.createElement("span"); + this.bubble.classList.add("facet-checkbox-bubble"); + + this.checkbox = document.createElement("input"); + this.checkbox.setAttribute("type", "checkbox"); + + this.labelNode = document.createElement("span"); + this.labelNode.classList.add("facet-checkbox-label"); + + this.countNode = document.createElement("span"); + this.countNode.classList.add("facet-checkbox-count"); + + this.bubble.appendChild(this.checkbox); + this.bubble.appendChild(this.labelNode); + this.bubble.appendChild(this.countNode); + + this.appendChild(this.bubble); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + this.checkbox.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + this.checkbox.removeAttribute("disabled"); + } + } + + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set checked(val) { + if (this.checked == val) { + return; + } + this.checkbox.checked = val; + if (val) { + this.setAttribute("checked", "true"); + if (!this.disabled) { + FacetContext.addFacetConstraint(this.faceter, true, this.trueGroups); + } + } else { + this.removeAttribute("checked"); + this.checkbox.removeAttribute("checked"); + if (!this.disabled) { + FacetContext.removeFacetConstraint( + this.faceter, + true, + this.trueGroups + ); + } + } + this.checkStateChanged(); + } + + get checked() { + return this.getAttribute("checked") == "true"; + } + + extraSetup() {} + + checkStateChanged() {} + + brushItems() {} + + clearBrushedItems() {} + + build(firstTime) { + if (firstTime) { + this.labelNode.textContent = this.facetDef.strings.facetNameLabel; + this.checkbox.setAttribute( + "aria-label", + this.facetDef.strings.facetNameLabel + ); + this.trueValues = []; + } + + // If we do not currently have a constraint applied and there is only + // one (or no) group, then: disable us, but reflect the underlying + // state of the data (checked or non-checked) + if (!this.faceter.constraint && this.orderedGroups.length <= 1) { + this.disabled = true; + let count = 0; + if (this.orderedGroups.length) { + // true case? + if (this.orderedGroups[0][0]) { + count = this.orderedGroups[0][1].length; + this.checked = true; + } else { + this.checked = false; + } + } + this.countNode.textContent = count.toLocaleString(); + return; + } + // if we were disabled checked before, clear ourselves out + if (this.disabled && this.checked) { + this.checked = false; + } + this.disabled = false; + + // if we are here, we have our 2 groups, find true... + // (note: it is possible to get jerked around by null values + // currently, so leave a reasonable failure case) + this.trueValues = []; + this.trueGroups = [true]; + for (let groupPair of this.orderedGroups) { + if (groupPair[0]) { + this.trueValues = groupPair[1]; + } + } + + this.countNode.textContent = this.trueValues.length.toLocaleString(); + } + + bubbleClicked(event) { + if (!this.disabled) { + this.checked = !this.checked; + } + event.stopPropagation(); + } + } + + customElements.define("facet-boolean", MozFacetBoolean); + + class MozFacetBooleanFiltered extends MozFacetBoolean { + static get observedAttributes() { + return ["checked", "disabled"]; + } + + connectedCallback() { + super.addChildren(); + + this.filterNode = document.createElement("select"); + this.filterNode.classList.add("facet-filter-list"); + this.appendChild(this.filterNode); + + this.canUpdate = true; + this.bubble.addEventListener("click", event => { + return super.bubbleClicked(event); + }); + + this.extraSetup(); + + if ("faceter" in this) { + this.build(true); + } + + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.checkbox) { + return; + } + + if (this.hasAttribute("checked")) { + this.checkbox.setAttribute("checked", this.getAttribute("checked")); + } else { + this.checkbox.removeAttribute("checked"); + } + + if (this.hasAttribute("disabled")) { + this.checkbox.setAttribute("disabled", this.getAttribute("disabled")); + } else { + this.checkbox.removeAttribute("disabled"); + } + } + + extraSetup() { + this.groupDisplayProperty = this.getAttribute("groupDisplayProperty"); + + this.filterNode.addEventListener("change", event => + this.filterChanged(event) + ); + + this.selectedValue = "all"; + } + + build(firstTime) { + if (firstTime) { + this.labelNode.textContent = this.facetDef.strings.facetNameLabel; + this.checkbox.setAttribute( + "aria-label", + this.facetDef.strings.facetNameLabel + ); + this.trueValues = []; + } + + // Only update count if anything other than "all" is selected. + // Otherwise we lose the set of attachment types in our select box, + // and that makes us sad. We do want to update on "all" though + // because other facets may further reduce the number of attachments + // we see. (Or if this is not just being used for attachments, it + // still holds.) + if (this.selectedValue != "all") { + let count = 0; + for (let groupPair of this.orderedGroups) { + if (groupPair[0] != null) { + count += groupPair[1].length; + } + } + this.countNode.textContent = count.toLocaleString(); + return; + } + + while (this.filterNode.hasChildNodes()) { + this.filterNode.lastChild.remove(); + } + + let allNode = document.createElement("option"); + allNode.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.filter." + + this.attrDef.attributeName + + ".allLabel" + ); + allNode.setAttribute("value", "all"); + if (this.selectedValue == "all") { + allNode.setAttribute("selected", "selected"); + } + this.filterNode.appendChild(allNode); + + // if we are here, we have our 2 groups, find true... + // (note: it is possible to get jerked around by null values + // currently, so leave a reasonable failure case) + // empty true groups is for the checkbox + this.trueGroups = []; + // the real true groups is the actual true values for our explicit + // filtering + this.realTrueGroups = []; + this.trueValues = []; + this.falseValues = []; + let selectNodes = []; + for (let groupPair of this.orderedGroups) { + if (groupPair[0] === null) { + this.falseValues.push.apply(this.falseValues, groupPair[1]); + } else { + this.trueValues.push.apply(this.trueValues, groupPair[1]); + + let groupValue = groupPair[0]; + let selNode = document.createElement("option"); + selNode.textContent = groupValue[this.groupDisplayProperty]; + selNode.setAttribute("value", this.realTrueGroups.length); + if (this.selectedValue == groupValue.category) { + selNode.setAttribute("selected", "selected"); + } + selectNodes.push(selNode); + + this.realTrueGroups.push(groupValue); + } + } + selectNodes.sort((a, b) => { + return a.textContent.localeCompare(b.textContent); + }); + selectNodes.forEach(selNode => { + this.filterNode.appendChild(selNode); + }); + + this.disabled = !this.trueValues.length; + + this.countNode.textContent = this.trueValues.length.toLocaleString(); + } + + checkStateChanged() { + // if they un-check us, revert our value to all. + if (!this.checked) { + this.selectedValue = "all"; + } + } + + filterChanged(event) { + if (!this.checked) { + return; + } + if (this.filterNode.value == "all") { + this.selectedValue = "all"; + FacetContext.addFacetConstraint( + this.faceter, + true, + this.trueGroups, + false, + true + ); + } else { + let groupValue = this.realTrueGroups[parseInt(this.filterNode.value)]; + this.selectedValue = groupValue.category; + FacetContext.addFacetConstraint( + this.faceter, + true, + [groupValue], + false, + true + ); + } + } + } + + customElements.define("facet-boolean-filtered", MozFacetBooleanFiltered); + + class MozFacetDiscrete extends HTMLElement { + constructor() { + super(); + + this.addEventListener("click", event => { + this.showPopup(event); + }); + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + this.showPopup(event); + }); + + this.addEventListener("keypress", event => { + this.activateLink(event); + }); + + this.addEventListener("mouseover", event => { + // we dispatch based on the class of the thing we clicked on. + // there are other ways we could accomplish this, but they all sorta suck. + if ( + event.target.hasAttribute("class") && + event.target.classList.contains("bar-link") + ) { + this.barHovered(event.target.parentNode, true); + } + }); + + this.addEventListener("mouseout", event => { + // we dispatch based on the class of the thing we clicked on. + // there are other ways we could accomplish this, but they all sorta suck. + if ( + event.target.hasAttribute("class") && + event.target.classList.contains("bar-link") + ) { + this.barHoverGone(event.target.parentNode, true); + } + }); + } + + connectedCallback() { + const facet = document.createElement("div"); + facet.classList.add("facet"); + + this.nameNode = document.createElement("h2"); + + this.contentBox = document.createElement("div"); + this.contentBox.classList.add("facet-content"); + + this.includeLabel = document.createElement("h3"); + this.includeLabel.classList.add("facet-included-header"); + + this.includeList = document.createElement("ul"); + this.includeList.classList.add("facet-included", "barry"); + + this.remainderLabel = document.createElement("h3"); + this.remainderLabel.classList.add("facet-remaindered-header"); + + this.remainderList = document.createElement("ul"); + this.remainderList.classList.add("facet-remaindered", "barry"); + + this.excludeLabel = document.createElement("h3"); + this.excludeLabel.classList.add("facet-excluded-header"); + + this.excludeList = document.createElement("ul"); + this.excludeList.classList.add("facet-excluded", "barry"); + + this.moreButton = document.createElement("button"); + this.moreButton.classList.add("facet-more"); + this.moreButton.setAttribute("needed", "false"); + this.moreButton.setAttribute("tabindex", "0"); + + this.contentBox.appendChild(this.includeLabel); + this.contentBox.appendChild(this.includeList); + this.contentBox.appendChild(this.remainderLabel); + this.contentBox.appendChild(this.remainderList); + this.contentBox.appendChild(this.excludeLabel); + this.contentBox.appendChild(this.excludeList); + this.contentBox.appendChild(this.moreButton); + + facet.appendChild(this.nameNode); + facet.appendChild(this.contentBox); + + this.appendChild(facet); + + this.canUpdate = false; + + if ("faceter" in this) { + this.build(true); + } + } + + build(firstTime) { + // -- Header Building + this.nameNode.textContent = this.facetDef.strings.facetNameLabel; + + // - include + // setup the include label + if ("includeLabel" in this.facetDef.strings) { + this.includeLabel.textContent = this.facetDef.strings.includeLabel; + } else { + this.includeLabel.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.included.fallbackLabel" + ); + } + this.includeLabel.setAttribute("state", "empty"); + + // - exclude + // setup the exclude label + if ("excludeLabel" in this.facetDef.strings) { + this.excludeLabel.textContent = this.facetDef.strings.excludeLabel; + } else { + this.excludeLabel.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.excluded.fallbackLabel" + ); + } + this.excludeLabel.setAttribute("state", "empty"); + + // - remainder + // setup the remainder label + if ("remainderLabel" in this.facetDef.strings) { + this.remainderLabel.textContent = this.facetDef.strings.remainderLabel; + } else { + this.remainderLabel.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.remainder.fallbackLabel" + ); + } + + // -- House-cleaning + // -- All/Top mode decision + this.modes = ["all"]; + if (this.maxDisplayRows >= this.orderedGroups.length) { + this.mode = "all"; + } else { + // top mode must be used + this.modes.push("top"); + this.mode = "top"; + this.topGroups = FacetUtils.makeTopGroups( + this.attrDef, + this.orderedGroups, + this.maxDisplayRows + ); + // setup the more button string + let groupCount = this.orderedGroups.length; + this.moreButton.textContent = PluralForm.get( + groupCount, + glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.mode.top.listAllLabel" + ) + ).replace("#1", groupCount); + } + + // -- Row Building + this.buildRows(); + } + + changeMode(newMode) { + this.mode = newMode; + this.setAttribute("mode", newMode); + this.buildRows(); + } + + buildRows() { + let nounDef = this.nounDef; + let useGroups = this.mode == "all" ? this.orderedGroups : this.topGroups; + + // should we just rely on automatic string coercion? + this.moreButton.setAttribute( + "needed", + this.mode == "top" ? "true" : "false" + ); + + let constraint = this.faceter.constraint; + + // -- empty all of our display buckets... + let remainderList = this.remainderList; + while (remainderList.hasChildNodes()) { + remainderList.lastChild.remove(); + } + let includeList = this.includeList; + let excludeList = this.excludeList; + while (includeList.hasChildNodes()) { + includeList.lastChild.remove(); + } + while (excludeList.hasChildNodes()) { + excludeList.lastChild.remove(); + } + + // -- first pass, check for ambiguous labels + // It's possible that multiple groups are identified by the same short + // string, in which case we want to use the longer string to + // disambiguate. For example, un-merged contacts can result in + // multiple identities having contacts with the same name. In that + // case we want to display both the contact name and the identity + // name. + // This is generically addressed by using the userVisibleString function + // defined on the noun type if it is defined. It takes an argument + // indicating whether it should be a short string or a long string. + // Our algorithm is somewhat dumb. We get the short strings, put them + // in a dictionary that maps to whether they are ambiguous or not. We + // do not attempt to map based on their id, so then when it comes time + // to actually build the labels, we must build the short string and + // then re-call for the long name. We could be smarter by building + // a list of the input values that resulted in the output string and + // then using that to back-update the id map, but it's more compelx and + // the performance difference is unlikely to be meaningful. + let ambiguousKeyValues; + if ("userVisibleString" in nounDef) { + ambiguousKeyValues = {}; + for (let groupPair of useGroups) { + let [groupValue] = groupPair; + + // skip null values, they are handled by the none special-case + if (groupValue == null) { + continue; + } + + let groupStr = nounDef.userVisibleString(groupValue, false); + // We use hasOwnProperty because it is possible that groupStr could + // be the same as the name of one of the attributes on + // Object.prototype. + if (ambiguousKeyValues.hasOwnProperty(groupStr)) { + ambiguousKeyValues[groupStr] = true; + } else { + ambiguousKeyValues[groupStr] = false; + } + } + } + + // -- create the items, assigning them to the right list based on + // existing constraint values + for (let groupPair of useGroups) { + let [groupValue, groupItems] = groupPair; + let li = document.createElement("li"); + li.setAttribute("class", "bar"); + li.setAttribute("tabindex", "0"); + li.setAttribute("role", "link"); + li.setAttribute("aria-haspopup", "true"); + li.groupValue = groupValue; + li.setAttribute("groupValue", groupValue); + li.groupItems = groupItems; + + let countSpan = document.createElement("span"); + countSpan.setAttribute("class", "bar-count"); + countSpan.textContent = groupItems.length.toLocaleString(); + li.appendChild(countSpan); + + let label = document.createElement("span"); + label.setAttribute("class", "bar-link"); + + // The null value is a special indicator for 'none' + if (groupValue == null) { + if ("noneLabel" in this.facetDef.strings) { + label.textContent = this.facetDef.strings.noneLabel; + } else { + label.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.noneLabel" + ); + } + } else { + // Otherwise stringify the group object + let labelStr; + if (ambiguousKeyValues) { + labelStr = nounDef.userVisibleString(groupValue, false); + if (ambiguousKeyValues[labelStr]) { + labelStr = nounDef.userVisibleString(groupValue, true); + } + } else if ("labelFunc" in this.facetDef) { + labelStr = this.facetDef.labelFunc(groupValue); + } else { + labelStr = groupValue.toLocaleString().substring(0, 80); + } + label.textContent = labelStr; + label.setAttribute("title", labelStr); + } + li.appendChild(label); + + // root it under the appropriate list + if (constraint) { + if (constraint.isIncludedGroup(groupValue)) { + li.setAttribute("variety", "include"); + includeList.appendChild(li); + } else if (constraint.isExcludedGroup(groupValue)) { + li.setAttribute("variety", "exclude"); + excludeList.appendChild(li); + } else { + li.setAttribute("variety", "remainder"); + remainderList.appendChild(li); + } + } else { + li.setAttribute("variety", "remainder"); + remainderList.appendChild(li); + } + } + + this.updateHeaderStates(); + } + + /** + * - Mark the include/exclude headers as "some" if there is anything in their + * - lists, mark the remainder header as "needed" if either of include / + * - exclude exist so we need that label. + */ + updateHeaderStates(items) { + this.includeLabel.setAttribute( + "state", + this.includeList.childElementCount ? "some" : "empty" + ); + this.excludeLabel.setAttribute( + "state", + this.excludeList.childElementCount ? "some" : "empty" + ); + this.remainderLabel.setAttribute( + "needed", + (this.includeList.childElementCount || + this.excludeList.childElementCount) && + this.remainderList.childElementCount + ? "true" + : "false" + ); + + // nuke the style attributes. + this.includeLabel.removeAttribute("style"); + this.excludeLabel.removeAttribute("style"); + this.remainderLabel.removeAttribute("style"); + } + + brushItems(items) {} + + clearBrushedItems() {} + + afterListVisible(variety, callback) { + let labelNode = this[variety + "Label"]; + let listNode = this[variety + "List"]; + + // if there are already things displayed, no need + if (listNode.childElementCount) { + callback(); + return; + } + + let remListVisible = this.remainderLabel.getAttribute("needed") == "true"; + let remListShouldBeVisible = this.remainderList.childElementCount > 1; + + labelNode.setAttribute("state", "some"); + + let showNodes = [labelNode]; + if (remListVisible != remListShouldBeVisible) { + showNodes = [labelNode, this.remainderLabel]; + } + + showNodes.forEach(node => (node.style.display = "block")); + + callback(); + } + + _flyBarAway(barNode, variety, callback) { + function getRect(aElement) { + let box = aElement.getBoundingClientRect(); + let documentElement = aElement.ownerDocument.documentElement; + return { + top: box.top + window.pageYOffset - documentElement.clientTop, + left: box.left + window.pageXOffset - documentElement.clientLeft, + width: box.width, + height: box.height, + }; + } + // figure out our origin location prior to adding the target or it + // will shift us down. + let origin = getRect(barNode); + + // clone the node into its target location + let targetNode = barNode.cloneNode(true); + targetNode.groupValue = barNode.groupValue; + targetNode.groupItems = barNode.groupItems; + targetNode.setAttribute("variety", variety); + + let targetParent = this[variety + "List"]; + targetParent.appendChild(targetNode); + + // create a flying clone + let flyingNode = barNode.cloneNode(true); + + let dest = getRect(targetNode); + + // if the flying box wants to go higher than the content box goes, just + // send it to the top of the content box instead. + let contentRect = getRect(this.contentBox); + if (dest.top < contentRect.top) { + dest.top = contentRect.top; + } + + // likewise if it wants to go further south than the content box, stop + // that + if (dest.top > contentRect.top + contentRect.height) { + dest.top = contentRect.top + contentRect.height - dest.height; + } + + flyingNode.style.position = "absolute"; + flyingNode.style.width = origin.width + "px"; + flyingNode.style.height = origin.height + "px"; + flyingNode.style.top = origin.top + "px"; + flyingNode.style.left = origin.left + "px"; + flyingNode.style.zIndex = 1000; + + flyingNode.style.transitionDuration = + Math.abs(dest.top - origin.top) * 2 + "ms"; + flyingNode.style.transitionProperty = "top, left"; + + flyingNode.addEventListener("transitionend", () => { + barNode.remove(); + targetNode.style.display = "block"; + flyingNode.remove(); + + if (callback) { + setTimeout(callback, 50); + } + }); + + document.body.appendChild(flyingNode); + + // Adding setTimeout to improve the facet-discrete animation. + // See Bug 1439323 for more detail. + setTimeout(() => { + // animate the flying clone... flying! + window.requestAnimationFrame(() => { + flyingNode.style.top = dest.top + "px"; + flyingNode.style.left = dest.left + "px"; + }); + + // hide the target (cloned) node + targetNode.style.display = "none"; + + // hide the original node and remove its JS properties + barNode.style.visibility = "hidden"; + delete barNode.groupValue; + delete barNode.groupItems; + }, 100); + } + + barClicked(barNode, variety) { + let groupValue = barNode.groupValue; + // These determine what goAnimate actually does. + // flyAway allows us to cancel flying in the case the constraint is + // being fully dropped and so the facet is just going to get rebuilt + let flyAway = true; + + const goAnimate = () => { + setTimeout(() => { + if (flyAway) { + this.afterListVisible(variety, () => { + this._flyBarAway(barNode, variety, () => { + this.updateHeaderStates(); + }); + }); + } + }, 0); + }; + + // Immediately apply the facet change, triggering the animation after + // the faceting completes. + if (variety == "remainder") { + let currentVariety = barNode.getAttribute("variety"); + let constraintGone = FacetContext.removeFacetConstraint( + this.faceter, + currentVariety == "include", + [groupValue], + goAnimate + ); + + // we will automatically rebuild if the constraint is gone, so + // just make the animation a no-op. + if (constraintGone) { + flyAway = false; + } + } else { + // include/exclude + let revalidate = FacetContext.addFacetConstraint( + this.faceter, + variety == "include", + [groupValue], + false, + false, + goAnimate + ); + + // revalidate means we need to blow away the other dudes, in which + // case it makes the most sense to just trigger a rebuild of ourself + if (revalidate) { + flyAway = false; + this.build(false); + } + } + } + + barHovered(barNode, aInclude) { + let groupValue = barNode.groupValue; + let groupItems = barNode.groupItems; + + FacetContext.hoverFacet( + this.faceter, + this.attrDef, + groupValue, + groupItems + ); + } + + /** + * HoverGone! HoverGone! + * We know it's gone, but where has it gone? + */ + barHoverGone(barNode, include) { + let groupValue = barNode.groupValue; + let groupItems = barNode.groupItems; + + FacetContext.unhoverFacet( + this.faceter, + this.attrDef, + groupValue, + groupItems + ); + } + + includeFacet(node) { + this.barClicked( + node, + node.getAttribute("variety") == "remainder" ? "include" : "remainder" + ); + } + + undoFacet(node) { + this.barClicked( + node, + node.getAttribute("variety") == "remainder" ? "include" : "remainder" + ); + } + + excludeFacet(node) { + this.barClicked(node, "exclude"); + } + + showPopup(event) { + try { + // event.target could be the <li> node, or a span inside + // of it, or perhaps the facet-more button, or maybe something + // else that we'll handle in the next version. We walk up its + // parent chain until we get to the right level of the DOM + // hierarchy, or the facet-content which seems to be the root. + if (this.currentNode) { + this.currentNode.removeAttribute("selected"); + } + + let node = event.target; + + while ( + !(node && node.hasAttribute && node.hasAttribute("class")) || + (!node.classList.contains("bar") && + !node.classList.contains("facet-more") && + !node.classList.contains("facet-content")) + ) { + node = node.parentNode; + } + + if (!(node && node.hasAttribute && node.hasAttribute("class"))) { + return false; + } + + this.currentNode = node; + node.setAttribute("selected", "true"); + + if (node.classList.contains("bar")) { + document.querySelector("facet-popup-menu").show(event, this, node); + } else if (node.classList.contains("facet-more")) { + this.changeMode("all"); + } + + return false; + } catch (e) { + return console.error(e); + } + } + + activateLink(event) { + try { + let node = event.target; + + while ( + !node.hasAttribute("class") || + (!node.classList.contains("facet-more") && + !node.classList.contains("facet-content")) + ) { + node = node.parentNode; + } + + if (node.classList.contains("facet-more")) { + this.changeMode("all"); + } + + return false; + } catch (e) { + return console.error(e); + } + } + } + + customElements.define("facet-discrete", MozFacetDiscrete); + + class MozFacetPopupMenu extends HTMLElement { + constructor() { + super(); + + this.addEventListener("keypress", event => { + switch (event.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + this.hide(); + break; + + case KeyEvent.DOM_VK_DOWN: + this.moveFocus(event, 1); + break; + + case KeyEvent.DOM_VK_TAB: + if (event.shiftKey) { + this.moveFocus(event, -1); + break; + } + + this.moveFocus(event, 1); + break; + + case KeyEvent.DOM_VK_UP: + this.moveFocus(event, -1); + break; + + default: + break; + } + }); + } + + connectedCallback() { + const parentDiv = document.createElement("div"); + parentDiv.classList.add("parent"); + parentDiv.setAttribute("tabIndex", "0"); + + this.includeNode = document.createElement("div"); + this.includeNode.classList.add("popup-menuitem", "top"); + this.includeNode.setAttribute("tabindex", "0"); + this.includeNode.onmouseover = () => { + this.focus(); + }; + this.includeNode.onkeypress = event => { + if (event.keyCode == event.DOM_VK_RETURN) { + this.doInclude(); + } + }; + this.includeNode.onmouseup = () => { + this.doInclude(); + }; + + this.excludeNode = document.createElement("div"); + this.excludeNode.classList.add("popup-menuitem", "bottom"); + this.excludeNode.setAttribute("tabindex", "0"); + this.excludeNode.onmouseover = () => { + this.focus(); + }; + this.excludeNode.onkeypress = event => { + if (event.keyCode == event.DOM_VK_RETURN) { + this.doExclude(); + } + }; + this.excludeNode.onmouseup = () => { + this.doExclude(); + }; + + this.undoNode = document.createElement("div"); + this.undoNode.classList.add("popup-menuitem", "undo"); + this.undoNode.setAttribute("tabindex", "0"); + this.undoNode.onmouseover = () => { + this.focus(); + }; + this.undoNode.onkeypress = event => { + if (event.keyCode == event.DOM_VK_RETURN) { + this.doUndo(); + } + }; + this.undoNode.onmouseup = () => { + this.doUndo(); + }; + + parentDiv.appendChild(this.includeNode); + parentDiv.appendChild(this.excludeNode); + parentDiv.appendChild(this.undoNode); + + this.appendChild(parentDiv); + } + + _getLabel(facetDef, facetValue, groupValue, stringName) { + let labelFormat; + if (stringName in facetDef.strings) { + labelFormat = facetDef.strings[stringName]; + } else { + labelFormat = glodaFacetStrings.GetStringFromName( + `glodaFacetView.facets.${stringName}.fallbackLabel` + ); + } + + if (!labelFormat.includes("#1")) { + return labelFormat; + } + + return labelFormat.replace("#1", facetValue); + } + + build(facetDef, facetValue, groupValue) { + try { + if (groupValue) { + this.includeNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mustMatchLabel" + ); + this.excludeNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "cantMatchLabel" + ); + this.undoNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mayMatchLabel" + ); + } else { + this.includeNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mustMatchNoneLabel" + ); + this.excludeNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mustMatchSomeLabel" + ); + this.undoNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mayMatchAnyLabel" + ); + } + } catch (e) { + console.error(e); + } + } + + moveFocus(event, delta) { + try { + // We probably want something quite generic in the long term, but that + // is way too much for now (needs to skip over invisible items, etc) + let focused = document.activeElement; + if (focused == this.includeNode) { + this.excludeNode.focus(); + } else if (focused == this.excludeNode) { + this.includeNode.focus(); + } + event.preventDefault(); + event.stopPropagation(); + } catch (e) { + console.error(e); + } + } + + selectItem(event) { + try { + let focused = document.activeElement; + if (focused == this.includeNode) { + this.doInclude(); + } else if (focused == this.excludeNode) { + this.doExclude(); + } else { + this.doUndo(); + } + } catch (e) { + console.error(e); + } + } + + show(event, facetNode, barNode) { + try { + this.node = barNode; + this.facetNode = facetNode; + let facetDef = facetNode.facetDef; + let groupValue = barNode.groupValue; + let variety = barNode.getAttribute("variety"); + let label = barNode.querySelector(".bar-link").textContent; + this.build(facetDef, label, groupValue); + this.node.setAttribute("selected", "true"); + const rtl = window.getComputedStyle(this).direction == "rtl"; + /* We show different menus if we're on an "unselected" facet value, + or if we're on a preselected facet value, whether included or + excluded. The variety attribute handles that through CSS */ + this.setAttribute("variety", variety); + let rect = barNode.getBoundingClientRect(); + let x, y; + if (event.type == "click") { + // center the menu on the mouse click + if (rtl) { + x = event.pageX + 10; + } else { + x = event.pageX - 10; + } + y = Math.max(20, event.pageY - 15); + } else { + if (rtl) { + x = rect.left + rect.width / 2 + 20; + } else { + x = rect.left + rect.width / 2 - 20; + } + y = rect.top - 10; + } + if (rtl) { + this.style.left = x - this.getBoundingClientRect().width + "px"; + } else { + this.style.left = x + "px"; + } + this.style.top = y + "px"; + + if (variety == "remainder") { + // include + this.includeNode.focus(); + } else { + // undo + this.undoNode.focus(); + } + } catch (e) { + console.error(e); + } + } + + hide() { + try { + this.setAttribute("variety", "invisible"); + if (this.node) { + this.node.removeAttribute("selected"); + this.node.focus(); + } + } catch (e) { + console.error(e); + } + } + + doInclude() { + try { + this.facetNode.includeFacet(this.node); + this.hide(); + } catch (e) { + console.error(e); + } + } + + doExclude() { + this.facetNode.excludeFacet(this.node); + this.hide(); + } + + doUndo() { + this.facetNode.undoFacet(this.node); + this.hide(); + } + } + + customElements.define("facet-popup-menu", MozFacetPopupMenu); + + /** + * MozResultMessage displays an excerpt of a message. Typically these are used in the gloda + * results listing, showing the messages that matched. + */ + class MozFacetResultMessage extends HTMLElement { + constructor() { + super(); + + this.addEventListener("mouseover", event => { + FacetContext.hoverFacet( + FacetContext.fakeResultFaceter, + FacetContext.fakeResultAttr, + this.message, + [this.message] + ); + }); + + this.addEventListener("mouseout", event => { + FacetContext.unhoverFacet( + FacetContext.fakeResultFaceter, + FacetContext.fakeResultAttr, + this.message, + [this.message] + ); + }); + } + + connectedCallback() { + const messageHeader = document.createElement("div"); + + const messageLine = document.createElement("div"); + messageLine.classList.add("message-line"); + + const messageMeta = document.createElement("div"); + messageMeta.classList.add("message-meta"); + + this.addressesGroup = document.createElement("div"); + this.addressesGroup.classList.add("message-addresses-group"); + + this.authorGroup = document.createElement("div"); + this.authorGroup.classList.add("message-author-group"); + + this.author = document.createElement("span"); + this.author.classList.add("message-author"); + + this.date = document.createElement("div"); + this.date.classList.add("message-date"); + + this.authorGroup.appendChild(this.author); + this.authorGroup.appendChild(this.date); + this.addressesGroup.appendChild(this.authorGroup); + messageMeta.appendChild(this.addressesGroup); + messageLine.appendChild(messageMeta); + + const messageSubjectGroup = document.createElement("div"); + messageSubjectGroup.classList.add("message-subject-group"); + + this.star = document.createElement("span"); + this.star.classList.add("message-star"); + + this.subject = document.createElement("span"); + this.subject.classList.add("message-subject"); + this.subject.setAttribute("tabindex", "0"); + this.subject.setAttribute("role", "link"); + + this.tags = document.createElement("span"); + this.tags.classList.add("message-tags"); + + this.recipientsGroup = document.createElement("div"); + this.recipientsGroup.classList.add("message-recipients-group"); + + this.to = document.createElement("span"); + this.to.classList.add("message-to-label"); + + this.recipients = document.createElement("div"); + this.recipients.classList.add("message-recipients"); + + this.recipientsGroup.appendChild(this.to); + this.recipientsGroup.appendChild(this.recipients); + messageSubjectGroup.appendChild(this.star); + messageSubjectGroup.appendChild(this.subject); + messageSubjectGroup.appendChild(this.tags); + messageSubjectGroup.appendChild(this.recipientsGroup); + messageLine.appendChild(messageSubjectGroup); + messageHeader.appendChild(messageLine); + this.appendChild(messageHeader); + + this.snippet = document.createElement("pre"); + this.snippet.classList.add("message-body"); + + this.attachments = document.createElement("div"); + this.attachments.classList.add("message-attachments"); + + this.appendChild(this.snippet); + this.appendChild(this.attachments); + + this.build(); + } + + /* eslint-disable complexity */ + build() { + let message = this.message; + + let subject = this.subject; + // -- eventify + subject.onclick = event => { + FacetContext.showConversationInTab(this, event.button == 1); + }; + subject.onkeypress = event => { + if (Event.keyCode == event.DOM_VK_RETURN) { + FacetContext.showConversationInTab(this, event.shiftKey); + } + }; + + // -- Content Poking + if (message.subject.trim() == "") { + subject.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.result.message.noSubject" + ); + } else { + subject.textContent = message.subject; + } + let authorNode = this.author; + authorNode.setAttribute("title", message.from.value); + authorNode.textContent = message.from.contact.name; + let toNode = this.to; + toNode.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.result.message.toLabel" + ); + + // this.author.textContent = ; + let { makeFriendlyDateAgo } = ChromeUtils.import( + "resource:///modules/TemplateUtils.jsm" + ); + this.date.textContent = makeFriendlyDateAgo(message.date); + + // - Recipients + try { + let recipientsNode = this.recipients; + if (message.recipients) { + let recipientCount = 0; + const MAX_RECIPIENTS = 3; + let totalRecipientCount = message.recipients.length; + let recipientSeparator = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.recipientSeparator" + ); + for (let index in message.recipients) { + let recipNode = document.createElement("span"); + recipNode.setAttribute("class", "message-recipient"); + recipNode.textContent = message.recipients[index].contact.name; + recipientsNode.appendChild(recipNode); + recipientCount++; + if (recipientCount == MAX_RECIPIENTS) { + break; + } + if (index != totalRecipientCount - 1) { + // add separators (usually commas) + let sepNode = document.createElement("span"); + sepNode.setAttribute("class", "message-recipient-separator"); + sepNode.textContent = recipientSeparator; + recipientsNode.appendChild(sepNode); + } + } + if (totalRecipientCount > MAX_RECIPIENTS) { + let nOthers = totalRecipientCount - recipientCount; + let andNOthers = document.createElement("span"); + andNOthers.setAttribute("class", "message-recipients-andothers"); + + let andOthersLabel = PluralForm.get( + nOthers, + glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.andOthers" + ) + ).replace("#1", nOthers); + + andNOthers.textContent = andOthersLabel; + recipientsNode.appendChild(andNOthers); + } + } + } catch (e) { + console.error(e); + } + + // - Starred + let starNode = this.star; + if (message.starred) { + starNode.setAttribute("starred", "true"); + } + + // - Attachments + if (message.attachmentNames) { + let attachmentsNode = this.attachments; + let imgNode = document.createElement("div"); + imgNode.setAttribute("class", "message-attachment-icon"); + attachmentsNode.appendChild(imgNode); + for (let attach of message.attachmentNames) { + let attachNode = document.createElement("div"); + attachNode.setAttribute("class", "message-attachment"); + if (attach.length >= 28) { + attach = attach.substring(0, 24) + "…"; + } + attachNode.textContent = attach; + attachmentsNode.appendChild(attachNode); + } + } + + // - Tags + let tagsNode = this.tags; + if ("tags" in message && message.tags.length) { + for (let tag of message.tags) { + let tagNode = document.createElement("span"); + tagNode.setAttribute("class", "message-tag"); + let color = MailServices.tags.getColorForKey(tag.key); + if (color) { + let textColor = !TagUtils.isColorContrastEnough(color) + ? "white" + : "black"; + tagNode.setAttribute( + "style", + "color: " + textColor + "; background-color: " + color + ";" + ); + } + tagNode.textContent = tag.tag; + tagsNode.appendChild(tagNode); + } + } + + // - Body + if (message.indexedBodyText) { + let bodyText = message.indexedBodyText; + + let matches = []; + if ("stashedColumns" in FacetContext.collection) { + let collection; + if ( + "IMCollection" in FacetContext && + message instanceof Gloda.lookupNounDef("im-conversation").clazz + ) { + collection = FacetContext.IMCollection; + } else { + collection = FacetContext.collection; + } + let offsets = collection.stashedColumns[message.id][0]; + let offsetNums = offsets.split(" ").map(x => parseInt(x)); + for (let i = 0; i < offsetNums.length; i += 4) { + // i is the column index. The indexedBodyText is in the column 0. + // Ignore matches for other columns. + if (offsetNums[i] != 0) { + continue; + } + + // i+1 is the term index, indicating which queried term was found. + // We can ignore for now... + + // i+2 is the *byte* offset at which the term is in the string. + // i+3 is the term's length. + matches.push([offsetNums[i + 2], offsetNums[i + 3]]); + } + + // Sort the matches by index, just to be sure. + // They are probably already sorted, but if they aren't it could + // mess things up at the next step. + matches.sort((a, b) => a[0] - b[0]); + + // Convert the byte offsets and lengths into character indexes. + let charCodeToByteCount = c => { + // UTF-8 stores: + // - code points below U+0080 on 1 byte, + // - code points below U+0800 on 2 bytes, + // - code points U+D800 through U+DFFF are UTF-16 surrogate halves + // (they indicate that JS has split a 4 bytes UTF-8 character + // in two halves of 2 bytes each), + // - other code points on 3 bytes. + if (c < 0x80) { + return 1; + } + if (c < 0x800 || (c >= 0xd800 && c <= 0xdfff)) { + return 2; + } + return 3; + }; + let byteOffset = 0; + let offset = 0; + for (let match of matches) { + while (byteOffset < match[0]) { + byteOffset += charCodeToByteCount(bodyText.charCodeAt(offset++)); + } + match[0] = offset; + for (let i = offset; i < offset + match[1]; ++i) { + let size = charCodeToByteCount(bodyText.charCodeAt(i)); + if (size > 1) { + match[1] -= size - 1; + } + } + } + } + + // how many lines of context we want before the first match: + const kContextLines = 2; + + let startIndex = 0; + if (matches.length > 0) { + // Find where the snippet should begin to show at least the + // first match and kContextLines of context before the match. + startIndex = matches[0][0]; + for (let context = kContextLines; context >= 0; --context) { + startIndex = bodyText.lastIndexOf("\n", startIndex - 1); + if (startIndex == -1) { + startIndex = 0; + break; + } + } + } + + // start assuming it's just one line that we want to show + let idxNewline = -1; + let ellipses = "…"; + + let maxLineCount = 5; + if (startIndex != 0) { + // Avoid displaying an ellipses followed by an empty line. + while (bodyText[startIndex + 1] == "\n") { + ++startIndex; + } + bodyText = ellipses + bodyText.substring(startIndex); + // The first line will only contain the ellipsis as the character + // at startIndex is always \n, so we show an additional line. + ++maxLineCount; + } + + for ( + let newlineCount = 0; + newlineCount < maxLineCount; + newlineCount++ + ) { + idxNewline = bodyText.indexOf("\n", idxNewline + 1); + if (idxNewline == -1) { + ellipses = ""; + break; + } + } + let snippet = ""; + if (idxNewline > -1) { + snippet = bodyText.substring(0, idxNewline); + } else { + snippet = bodyText; + } + if (ellipses) { + snippet = snippet.trimRight() + ellipses; + } + + let parent = this.snippet; + let node = document.createTextNode(snippet); + parent.appendChild(node); + + let offset = startIndex ? startIndex - 1 : 0; // The ellipsis takes 1 character. + for (let match of matches) { + if (idxNewline > -1 && match[0] > startIndex + idxNewline) { + break; + } + let secondNode = node.splitText(match[0] - offset); + node = secondNode.splitText(match[1]); + offset += match[0] + match[1] - offset; + let span = document.createElement("span"); + span.textContent = secondNode.data; + if (!this.firstMatchText) { + this.firstMatchText = secondNode.data; + } + span.setAttribute("class", "message-body-fulltext-match"); + parent.replaceChild(span, secondNode); + } + } + + // - Misc attributes + if (!message.read) { + this.setAttribute("unread", "true"); + } + } + } + + customElements.define("facet-result-message", MozFacetResultMessage); +} diff --git a/comm/mail/base/content/widgets/header-fields.js b/comm/mail/base/content/widgets/header-fields.js new file mode 100644 index 0000000000..10ec83b45c --- /dev/null +++ b/comm/mail/base/content/widgets/header-fields.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/. */ + +/* global gMessageHeader, gShowCondensedEmailAddresses, openUILink */ + +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + + const lazy = {}; + ChromeUtils.defineModuleGetter( + lazy, + "DisplayNameUtils", + "resource:///modules/DisplayNameUtils.jsm" + ); + ChromeUtils.defineModuleGetter( + lazy, + "TagUtils", + "resource:///modules/TagUtils.jsm" + ); + + class MultiRecipientRow extends HTMLDivElement { + /** + * The number of lines of recipients to display before adding a <more> + * indicator to the widget. This can be increased using the preference + * mailnews.headers.show_n_lines_before_more. + * + * @type {integer} + */ + #maxLinesBeforeMore = 1; + + /** + * The array of all the recipients that need to be shown in this widget. + * + * @type {Array<object>} + */ + #recipients = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "multi-recipient-row"); + this.classList.add("multi-recipient-row"); + + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + // message-header-to-list-name + // message-header-from-list-name + // message-header-cc-list-name + // message-header-bcc-list-name + // message-header-sender-list-name + // message-header-reply-to-list-name + document.l10n.setAttributes( + this.heading, + `message-header-${this.dataset.headerName}-list-name` + ); + this.appendChild(this.heading); + + this.recipientsList = document.createElement("ol"); + this.recipientsList.classList.add("recipients-list"); + this.recipientsList.setAttribute("aria-labelledby", this.heading.id); + this.appendChild(this.recipientsList); + + this.moreButton = document.createElement("button"); + this.moreButton.setAttribute("type", "button"); + this.moreButton.classList.add("show-more-recipients", "plain"); + this.moreButton.addEventListener( + "mousedown", + // Prevent focus being transferred to the button before it is removed. + event => event.preventDefault() + ); + this.moreButton.addEventListener("click", () => this.showAllRecipients()); + + document.l10n.setAttributes( + this.moreButton, + "message-header-field-show-more" + ); + + // @implements {nsIObserver} + this.ABObserver = { + /** + * Array list of all observable notifications. + * + * @type {Array<string>} + */ + _notifications: [ + "addrbook-directory-created", + "addrbook-directory-deleted", + "addrbook-contact-created", + "addrbook-contact-updated", + "addrbook-contact-deleted", + ], + + addObservers() { + for (let topic of this._notifications) { + Services.obs.addObserver(this, topic); + } + this._added = true; + window.addEventListener("unload", this); + }, + + removeObservers() { + if (!this._added) { + return; + } + for (let topic of this._notifications) { + Services.obs.removeObserver(this, topic); + } + this._added = false; + window.removeEventListener("unload", this); + }, + + handleEvent() { + this.removeObservers(); + }, + + observe: (subject, topic, data) => { + switch (topic) { + case "addrbook-directory-created": + case "addrbook-directory-deleted": + subject.QueryInterface(Ci.nsIAbDirectory); + this.directoryChanged(subject); + break; + case "addrbook-contact-created": + case "addrbook-contact-updated": + case "addrbook-contact-deleted": + subject.QueryInterface(Ci.nsIAbCard); + this.contactUpdated(subject); + break; + } + }, + }; + + this.ABObserver.addObservers(); + } + + /** + * Clear things out when the element is removed from the DOM. + */ + disconnectedCallback() { + this.ABObserver.removeObservers(); + } + + /** + * Loop through all available recipients and check if any of those belonged + * to the created or removed address book. + * + * @param {nsIAbDirectory} subject - The created or removed Address Book. + */ + directoryChanged(subject) { + if (!(subject instanceof Ci.nsIAbDirectory)) { + return; + } + + for (let recipient of [...this.recipientsList.childNodes].filter( + r => r.cardDetails?.book?.dirPrefId == subject.dirPrefId + )) { + recipient.updateRecipient(); + } + } + + /** + * Loop through all available recipients and update the UI to reflect if + * they were saved, updated, or removed as contacts in an address book. + * + * @param {nsIAbCard} subject - The changed contact card. + */ + contactUpdated(subject) { + if (!(subject instanceof Ci.nsIAbCard)) { + // Bail out if this is not a valid Address Book Card object. + return; + } + + if (!subject.isMailList && !subject.emailAddresses.length) { + // Bail out if we don't have any addresses to match against. + return; + } + + let addresses = subject.emailAddresses; + for (let recipient of [...this.recipientsList.childNodes].filter( + r => r.emailAddress && addresses.includes(r.emailAddress) + )) { + recipient.updateRecipient(); + } + } + + /** + * Add a recipient to be shown in this widget. The recipient won't be shown + * until the row view is built. + * + * @param {object} recipient - The recipient element. + * @param {string} recipient.displayName - The recipient display name. + * @param {string} [recipient.emailAddress] - The recipient email address. + * @param {string} [recipient.fullAddress] - The recipient full address. + */ + addRecipient(recipient) { + this.#recipients.push(recipient); + } + + buildView() { + this.#maxLinesBeforeMore = Services.prefs.getIntPref( + "mailnews.headers.show_n_lines_before_more" + ); + let showAllHeaders = + this.#maxLinesBeforeMore < 1 || + Services.prefs.getIntPref("mail.show_headers") == + Ci.nsMimeHeaderDisplayTypes.AllHeaders || + this.dataset.showAll == "true"; + this.buildRecipients(showAllHeaders); + } + + buildRecipients(showAllHeaders) { + // Determine focus before clearing the children. + let focusIndex = [...this.recipientsList.childNodes].findIndex(node => + node.contains(document.activeElement) + ); + this.recipientsList.replaceChildren(); + gMessageHeader.toggleScrollableHeader(showAllHeaders); + + // Store the available width of the entire row. + // FIXME! The size of the rows can variate depending on when adjacent + // elements are generated (e.g.: TO row + date row), therefore this size + // is not always accurate when viewing the first email. We should defer + // the generation of the multi recipient rows only after all the other + // headers have been populated. + let availableWidth = !showAllHeaders + ? this.recipientsList.getBoundingClientRect().width + : 0; + + // Track the space occupied by recipients per row. Every time we exceed + // the available space of a single row, we reset this value. + let currentRowWidth = 0; + // Track how many rows are being populated by recipients. + let rows = 1; + for (let [count, recipient] of this.#recipients.entries()) { + let li = document.createElement("li", { is: "header-recipient" }); + // Set an id before connected callback is called on the element. + li.id = `${this.dataset.headerName}Recipient${count}`; + // Append the element to the DOM to trigger the connectedCallback. + this.recipientsList.appendChild(li); + li.dataset.headerName = this.dataset.headerName; + li.recipient = recipient; + + // Bail out if we need to show all elements. + if (showAllHeaders) { + continue; + } + + // Keep track of how much space our recipients are occupying. + let width = li.getBoundingClientRect().width; + // FIXME! If we have more than one recipient, we add a comma as pseudo + // element after the previous element. Account for that by adding an + // arbitrary 30px size to simulate extra characters space. This is a bit + // of an extreme sizing as it's almost as large as the more button, but + // it's necessary to make sure we never encounter that scenario. + if (count > 0) { + width += 30; + } + currentRowWidth += width; + + if (currentRowWidth <= availableWidth) { + continue; + } + + // If the recipients available in the current row exceed the + // available space, increase the row count and set the value of the + // last added list item to the next row width counter. + if (rows < this.#maxLinesBeforeMore) { + rows++; + currentRowWidth = width; + continue; + } + + // Append the "more" button inside a list item to be properly handled + // as an inline element of the recipients list UI. + let buttonLi = document.createElement("li"); + buttonLi.appendChild(this.moreButton); + this.recipientsList.appendChild(buttonLi); + currentRowWidth += buttonLi.getBoundingClientRect().width; + + // Reverse loop through the added list item and remove them until + // they all fit in the current row alongside the "more" button. + for (; count && currentRowWidth > availableWidth; count--) { + let toRemove = this.recipientsList.childNodes[count]; + currentRowWidth -= toRemove.getBoundingClientRect().width; + toRemove.remove(); + } + + // Skip the "more" button, which is present if we reached this stage. + let lastRecipientIndex = this.recipientsList.childNodes.length - 2; + // Add a unique class to the last visible recipient to remove the + // comma separator added via pseudo element. + this.recipientsList.childNodes[lastRecipientIndex].classList.add( + "last-before-button" + ); + + break; + } + + if (focusIndex >= 0) { + // If we had focus before, restore focus to the same index, or the last node. + let focusNode = + this.recipientsList.childNodes[ + Math.min(focusIndex, this.recipientsList.childNodes.length - 1) + ]; + if (focusNode.contains(this.moreButton)) { + // The button is focusable. + this.moreButton.focus(); + } else { + // The item is focusable. + focusNode.focus(); + } + } + } + + /** + * Show all recipients available in this widget. + */ + showAllRecipients() { + this.buildRecipients(true); + } + + /** + * Empty the widget. + */ + clear() { + this.#recipients = []; + this.recipientsList.replaceChildren(); + } + } + customElements.define("multi-recipient-row", MultiRecipientRow, { + extends: "div", + }); + + class HeaderRecipient extends HTMLLIElement { + /** + * The object holding the recipient information. + * + * @type {object} + * @property {string} displayName - The recipient display name. + * @property {string} [emailAddress] - The recipient email address. + * @property {string} [fullAddress] - The recipient full address. + */ + #recipient = {}; + + /** + * The Card object if the recipients is saved in the address book. + * + * @type {object} + * @property {?object} book - The address book in which the contact is + * saved, if we have a card. + * @property {?object} card - The saved contact card, if present. + */ + cardDetails = {}; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-recipient"); + this.classList.add("header-recipient"); + this.tabIndex = 0; + + this.avatar = document.createElement("div"); + this.avatar.classList.add("recipient-avatar"); + this.appendChild(this.avatar); + + this.email = document.createElement("span"); + this.email.classList.add("recipient-single-line"); + this.email.id = `${this.id}Display`; + this.appendChild(this.email); + + this.multiLine = document.createElement("span"); + this.multiLine.classList.add("recipient-multi-line"); + + this.nameLine = document.createElement("span"); + this.nameLine.classList.add("recipient-multi-line-name"); + this.multiLine.appendChild(this.nameLine); + + this.addressLine = document.createElement("span"); + this.addressLine.classList.add("recipient-multi-line-address"); + this.multiLine.appendChild(this.addressLine); + + this.appendChild(this.multiLine); + + this.abIndicator = document.createElement("button"); + this.abIndicator.classList.add( + "recipient-address-book-button", + "plain-button" + ); + // We make the button non-focusable since its functionality is equivalent + // to the first item in the popup menu, so we can save a tab-stop. + this.abIndicator.tabIndex = -1; + this.abIndicator.addEventListener("click", event => { + event.stopPropagation(); + if (this.cardDetails.card) { + gMessageHeader.editContact(this); + return; + } + + this.addToAddressBook(); + }); + + let img = document.createElement("img"); + img.id = `${this.id}AbIcon`; + img.src = "chrome://messenger/skin/icons/new/address-book-indicator.svg"; + document.l10n.setAttributes( + img, + "message-header-address-not-in-address-book-icon2" + ); + + this.abIndicator.appendChild(img); + this.appendChild(this.abIndicator); + + // Use the email and icon as the accessible name. We do this to stop the + // button title from contributing to the accessible name. + // TODO: If the button or its title is removed, or the title replaces the + // image alt text, then remove this aria-labelledby attribute. The id's + // will no longer be necessary either. + this.setAttribute("aria-labelledby", `${this.email.id} ${img.id}`); + + this.addEventListener("contextmenu", event => { + gMessageHeader.openEmailAddressPopup(event, this); + }); + this.addEventListener("click", event => { + gMessageHeader.openEmailAddressPopup(event, this); + }); + this.addEventListener("keypress", event => { + if (event.key == "Enter") { + gMessageHeader.openEmailAddressPopup(event, this); + } + }); + } + + set recipient(recipient) { + this.#recipient = recipient; + this.updateRecipient(); + } + + get displayName() { + return this.#recipient.displayName; + } + + get emailAddress() { + return this.#recipient.emailAddress; + } + + get fullAddress() { + return this.#recipient.fullAddress; + } + + updateRecipient() { + if (!this.emailAddress) { + this.abIndicator.hidden = true; + this.email.textContent = this.displayName; + if (this.dataset.headerName == "from") { + this.nameLine.textContent = this.displayName; + this.addressLine.textContent = ""; + this.avatar.replaceChildren(); + this.avatar.classList.remove("has-avatar"); + } + this.cardDetails = {}; + return; + } + + this.abIndicator.hidden = false; + let card = MailServices.ab.cardForEmailAddress( + this.#recipient.emailAddress + ); + this.cardDetails = { + card, + book: card + ? MailServices.ab.getDirectoryFromUID(card.directoryUID) + : null, + }; + + let displayName = lazy.DisplayNameUtils.formatDisplayName( + this.emailAddress, + this.displayName, + this.dataset.headerName, + this.cardDetails.card + ); + + // Show only the display name if we have a valid card and the user wants + // to show a condensed header (without the full email address) for saved + // contacts. + if (gShowCondensedEmailAddresses && displayName) { + this.email.textContent = displayName; + this.email.setAttribute("title", this.#recipient.fullAddress); + } else { + this.email.textContent = this.#recipient.fullAddress; + this.email.removeAttribute("title"); + } + + if (this.dataset.headerName == "from") { + if (gShowCondensedEmailAddresses) { + this.nameLine.textContent = + displayName || this.displayName || this.fullAddress; + } else { + this.nameLine.textContent = this.fullAddress; + } + this.addressLine.textContent = this.emailAddress; + } + + let hasCard = this.cardDetails.card; + // Update the style of the indicator button. + this.abIndicator.classList.toggle("in-address-book", hasCard); + document.l10n.setAttributes( + this.abIndicator, + hasCard + ? "message-header-address-in-address-book-button" + : "message-header-address-not-in-address-book-button" + ); + document.l10n.setAttributes( + this.abIndicator.querySelector("img"), + hasCard + ? "message-header-address-in-address-book-icon2" + : "message-header-address-not-in-address-book-icon2" + ); + + if (this.dataset.headerName == "from") { + this._updateAvatar(); + } + } + + _updateAvatar() { + this.avatar.replaceChildren(); + + if (!this.cardDetails.card) { + this._createAvatarPlaceholder(); + return; + } + + // We have a card, so let's try to fetch the image. + let card = this.cardDetails.card; + let photoURL = card.photoURL; + if (photoURL) { + let img = document.createElement("img"); + document.l10n.setAttributes(img, "message-header-recipient-avatar", { + address: this.emailAddress, + }); + // TODO: We should fetch a dynamically generated smaller version of the + // uploaded picture to avoid loading large images that will only be used + // in smaller format. + img.src = photoURL; + this.avatar.appendChild(img); + this.avatar.classList.add("has-avatar"); + } else { + this._createAvatarPlaceholder(); + } + } + + _createAvatarPlaceholder() { + let letter = document.createElement("span"); + letter.textContent = Array.from( + this.nameLine.textContent || this.displayName || this.fullAddress + )[0]?.toUpperCase(); + letter.setAttribute("aria-hidden", "true"); + this.avatar.appendChild(letter); + this.avatar.classList.remove("has-avatar"); + } + + addToAddressBook() { + let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance( + Ci.nsIAbCard + ); + card.displayName = this.#recipient.displayName; + card.primaryEmail = this.#recipient.emailAddress; + + let addressBook = MailServices.ab.getDirectory( + "jsaddrbook://abook.sqlite" + ); + addressBook.addCard(card); + } + } + customElements.define("header-recipient", HeaderRecipient, { + extends: "li", + }); + + class SimpleHeaderRow extends HTMLDivElement { + constructor() { + super(); + + this.addEventListener("contextmenu", event => { + gMessageHeader.openCopyPopup(event, this); + }); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "simple-header-row"); + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + let sep = document.createElement("span"); + sep.classList.add("screen-reader-only"); + sep.setAttribute("data-l10n-name", "field-separator"); + this.heading.appendChild(sep); + + if ( + ["organization", "subject", "date", "user-agent"].includes( + this.dataset.headerName + ) + ) { + // message-header-organization-field + // message-header-subject-field + // message-header-date-field + // message-header-user-agent-field + document.l10n.setAttributes( + this.heading, + `message-header-${this.dataset.headerName}-field` + ); + } else { + // If this simple row is used by an autogenerated custom header, + // use directly that header value as label. + document.l10n.setAttributes( + this.heading, + "message-header-custom-field", + { + fieldName: this.dataset.prettyHeaderName, + } + ); + } + this.appendChild(this.heading); + + this.classList.add("header-row"); + this.tabIndex = 0; + + this.value = document.createElement("span"); + this.appendChild(this.value); + } + + /** + * Set the text content for this row. + * + * @param {string} val - The content string to be added to this row. + */ + set headerValue(val) { + this.value.textContent = val; + // NOTE: In principle, we could use aria-labelledby and point to the + // heading and value elements. However, for some reason the expected + // accessible name is not read out when focused whilst using Orca screen + // reader. Instead, only the content of the value element is read out. + // This may be because this element has no proper ARIA role since we are + // extending a div, which is not a best approach, so we can't expect + // proper support. + // TODO: This area needs some proper semantics to associate the fieldname + // with the field value, whilst being focusable to allow the user to open + // a context menu on the row. + this.setAttribute( + "aria-label", + `${this.heading.textContent} ${this.value.textContent}` + ); + } + } + customElements.define("simple-header-row", SimpleHeaderRow, { + extends: "div", + }); + + class UrlHeaderRow extends SimpleHeaderRow { + connectedCallback() { + if (this.hasConnected) { + return; + } + super.connectedCallback(); + + this.setAttribute("is", "url-header-row"); + document.l10n.setAttributes(this.heading, "message-header-website-field"); + + this.value.classList.add("text-link"); + this.addEventListener("click", event => { + if (event.button != 2) { + openUILink(encodeURI(this.value.textContent), event); + } + }); + this.addEventListener("keydown", event => { + if (event.key == "Enter") { + openUILink(encodeURI(this.value.textContent), event); + } + }); + } + } + customElements.define("url-header-row", UrlHeaderRow, { + extends: "div", + }); + + class HeaderNewsgroupsRow extends HTMLDivElement { + /** + * The array of all the newsgroups that need to be shown in this row. + * + * @type {Array<object>} + */ + #newsgroups = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-newsgroups-row"); + this.classList.add("header-newsgroups-row"); + + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + // message-header-newsgroups-list-name + // message-header-followup-to-list-name + document.l10n.setAttributes( + this.heading, + `message-header-${this.dataset.headerName}-list-name` + ); + this.appendChild(this.heading); + + this.newsgroupsList = document.createElement("ol"); + this.newsgroupsList.classList.add("newsgroups-list"); + this.newsgroupsList.setAttribute("aria-labelledby", this.heading.id); + this.appendChild(this.newsgroupsList); + } + + addNewsgroup(newsgroup) { + this.#newsgroups.push(newsgroup); + } + + buildView() { + this.newsgroupsList.replaceChildren(); + for (let newsgroup of this.#newsgroups) { + let li = document.createElement("li", { is: "header-newsgroup" }); + this.newsgroupsList.appendChild(li); + li.textContent = newsgroup; + } + } + + clear() { + this.#newsgroups = []; + this.newsgroupsList.replaceChildren(); + } + } + customElements.define("header-newsgroups-row", HeaderNewsgroupsRow, { + extends: "div", + }); + + class HeaderNewsgroup extends HTMLLIElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-newsgroup"); + this.classList.add("header-newsgroup"); + this.tabIndex = 0; + + this.addEventListener("contextmenu", event => { + gMessageHeader.openNewsgroupPopup(event, this); + }); + this.addEventListener("click", event => { + gMessageHeader.openNewsgroupPopup(event, this); + }); + this.addEventListener("keypress", event => { + if (event.key == "Enter") { + gMessageHeader.openNewsgroupPopup(event, this); + } + }); + } + } + customElements.define("header-newsgroup", HeaderNewsgroup, { + extends: "li", + }); + + class HeaderTagsRow extends HTMLDivElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-tags-row"); + this.classList.add("header-tags-row"); + + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + document.l10n.setAttributes( + this.heading, + "message-header-tags-list-name" + ); + this.appendChild(this.heading); + + this.tagsList = document.createElement("ol"); + this.tagsList.classList.add("tags-list"); + this.tagsList.setAttribute("aria-labelledby", this.heading.id); + this.appendChild(this.tagsList); + } + + buildTags(tags) { + // Clear old tags. + this.tagsList.replaceChildren(); + + for (let tag of tags) { + // For each tag, create a label, give it the font color that corresponds to the + // color of the tag and append it. + let tagName; + try { + // if we got a bad tag name, getTagForKey will throw an exception, skip it + // and go to the next one. + tagName = MailServices.tags.getTagForKey(tag); + } catch (ex) { + continue; + } + + // Create a label for the tag name and set the color. + let li = document.createElement("li"); + li.tabIndex = 0; + li.classList.add("tag"); + li.textContent = tagName; + + let color = MailServices.tags.getColorForKey(tag); + if (color) { + let textColor = !lazy.TagUtils.isColorContrastEnough(color) + ? "white" + : "black"; + li.setAttribute( + "style", + `color: ${textColor}; background-color: ${color};` + ); + } + + this.tagsList.appendChild(li); + } + } + + clear() { + this.tagsList.replaceChildren(); + } + } + customElements.define("header-tags-row", HeaderTagsRow, { + extends: "div", + }); + + class MultiMessageIdsRow extends HTMLDivElement { + /** + * The array of all the IDs that need to be shown in this row. + * + * @type {Array<object>} + */ + #ids = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "multi-message-ids-row"); + this.classList.add("multi-message-ids-row"); + + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + let sep = document.createElement("span"); + sep.classList.add("screen-reader-only"); + sep.setAttribute("data-l10n-name", "field-separator"); + this.heading.appendChild(sep); + + // message-header-references-field + // message-header-message-id-field + // message-header-in-reply-to-field + document.l10n.setAttributes( + this.heading, + `message-header-${this.dataset.headerName}-field` + ); + this.appendChild(this.heading); + + this.idsList = document.createElement("ol"); + this.idsList.classList.add("ids-list"); + this.appendChild(this.idsList); + + this.toggleButton = document.createElement("button"); + this.toggleButton.setAttribute("type", "button"); + this.toggleButton.classList.add("show-more-ids", "plain"); + this.toggleButton.addEventListener( + "mousedown", + // Prevent focus being transferred to the button before it is removed. + event => event.preventDefault() + ); + this.toggleButton.addEventListener("click", () => this.buildView(true)); + + document.l10n.setAttributes( + this.toggleButton, + "message-ids-field-show-all" + ); + } + + addId(id) { + this.#ids.push(id); + } + + buildView(showAll = false) { + this.idsList.replaceChildren(); + for (let [count, id] of this.#ids.entries()) { + let li = document.createElement("li", { is: "header-message-id" }); + li.id = id; + this.idsList.appendChild(li); + if (!showAll && count < this.#ids.length - 1 && this.#ids.length > 1) { + li.messageId.textContent = count + 1; + li.messageId.title = id; + } else { + li.messageId.textContent = id; + } + } + + if (!showAll && this.#ids.length > 1) { + this.idsList.lastElementChild.classList.add("last-before-button"); + let liButton = document.createElement("li"); + liButton.appendChild(this.toggleButton); + this.idsList.appendChild(liButton); + } + } + + clear() { + this.#ids = []; + this.idsList.replaceChildren(); + } + } + customElements.define("multi-message-ids-row", MultiMessageIdsRow, { + extends: "div", + }); + + class HeaderMessageId extends HTMLLIElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-message-id"); + this.classList.add("header-message-id"); + + this.messageId = document.createElement("span"); + this.messageId.classList.add("text-link"); + this.messageId.tabIndex = 0; + this.appendChild(this.messageId); + + this.messageId.addEventListener("contextmenu", event => { + gMessageHeader.openMessageIdPopup(event, this); + }); + this.messageId.addEventListener("click", event => { + gMessageHeader.onMessageIdClick(event); + }); + this.messageId.addEventListener("keypress", event => { + if (event.key == "Enter") { + gMessageHeader.onMessageIdClick(event); + } + }); + } + } + customElements.define("header-message-id", HeaderMessageId, { + extends: "li", + }); +} diff --git a/comm/mail/base/content/widgets/mailWidgets.js b/comm/mail/base/content/widgets/mailWidgets.js new file mode 100644 index 0000000000..6ad566b742 --- /dev/null +++ b/comm/mail/base/content/widgets/mailWidgets.js @@ -0,0 +1,2477 @@ +/** + * 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 ../../../components/compose/content/addressingWidgetOverlay.js */ +/* import-globals-from ../../../components/compose/content/MsgComposeCommands.js */ + +/* global MozElements */ +/* global MozXULElement */ +/* global gFolderDisplay */ +/* global PluralForm */ +/* global onRecipientsChanged */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + const LazyModules = {}; + + ChromeUtils.defineModuleGetter( + LazyModules, + "DBViewWrapper", + "resource:///modules/DBViewWrapper.jsm" + ); + ChromeUtils.defineModuleGetter( + LazyModules, + "MailUtils", + "resource:///modules/MailUtils.jsm" + ); + ChromeUtils.defineModuleGetter( + LazyModules, + "MimeParser", + "resource:///modules/mimeParser.jsm" + ); + ChromeUtils.defineModuleGetter( + LazyModules, + "TagUtils", + "resource:///modules/TagUtils.jsm" + ); + + // NOTE: Icon column headers should have their "label" attribute set to + // describe the icon for the accessibility tree. + // + // NOTE: Ideally we could listen for the "alt" attribute and pass it on to the + // contained <img>, but the accessibility tree only seems to read the "label" + // for a <treecol>, and ignores the alt text. + class MozTreecolImage extends customElements.get("treecol") { + static get observedAttributes() { + return ["src"]; + } + + connectedCallback() { + if (this.hasChildNodes() || this.delayConnectedCallback()) { + return; + } + this.image = document.createElement("img"); + this.image.classList.add("treecol-icon"); + + this.appendChild(this.image); + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.image) { + return; + } + + const src = this.getAttribute("src"); + + if (src != null) { + this.image.setAttribute("src", src); + } else { + this.image.removeAttribute("src"); + } + } + } + customElements.define("treecol-image", MozTreecolImage, { + extends: "treecol", + }); + + /** + * Class extending treecols. This features a customized treecolpicker that + * features a menupopup with more items than the standard one. + * + * @augments {MozTreecols} + */ + class MozThreadPaneTreecols extends customElements.get("treecols") { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + let treecolpicker = this.querySelector("treecolpicker:not([is]"); + + // Can't change the super treecolpicker by setting + // is="thread-pane-treecolpicker" since that needs to be there at the + // parsing stage to take effect. + // So, remove the existing treecolpicker, and add a new one. + if (treecolpicker) { + treecolpicker.remove(); + } + if (!this.querySelector("treecolpicker[is=thread-pane-treecolpicker]")) { + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <treecolpicker is="thread-pane-treecolpicker" + class="thread-tree-col-picker" + tooltiptext="&columnChooser2.tooltip;" + fixed="true"> + </treecolpicker> + `, + ["chrome://messenger/locale/messenger.dtd"] + ) + ); + } + // Exceptionally apply super late, so we get the other goodness from there + // now that the treecolpicker is corrected. + super.connectedCallback(); + } + } + customElements.define("thread-pane-treecols", MozThreadPaneTreecols, { + extends: "treecols", + }); + + /** + * Class extending treecolpicker. This implements UI to apply column settings + * of the current thread pane to other mail folders too. + * + * @augments {MozTreecolPicker} + */ + class MozThreadPaneTreeColpicker extends customElements.get("treecolpicker") { + connectedCallback() { + super.connectedCallback(); + if (this.delayConnectedCallback()) { + return; + } + MozXULElement.insertFTLIfNeeded("messenger/mailWidgets.ftl"); + let popup = this.querySelector(`menupopup[anonid="popup"]`); + + // We'll add an "Apply columns to..." menu + popup.appendChild( + MozXULElement.parseXULToFragment( + ` + <menu class="applyTo-menu" label="&columnPicker.applyTo.label;"> + <menupopup> + <menu class="applyToFolder-menu" + label="&columnPicker.applyToFolder.label;"> + <menupopup is="folder-menupopup" + class="applyToFolder" + showFileHereLabel="true" + position="start_before"></menupopup> + </menu> + <menu class="applyToFolderAndChildren-menu" + label="&columnPicker.applyToFolderAndChildren.label;"> + <menupopup is="folder-menupopup" + class="applyToFolderAndChildren" + showFileHereLabel="true" + showAccountsFileHere="true" + position="start_before"></menupopup> + </menu> + </menupopup> + </menu> + <menu class="applyViewTo-menu" data-l10n-id="apply-current-view-to-menu"> + <menupopup> + <menu class="applyViewToFolder-menu" + label="&columnPicker.applyToFolder.label;"> + <menupopup is="folder-menupopup" + class="applyViewToFolder" + showFileHereLabel="true" + position="start_before"></menupopup> + </menu> + <menu class="applyViewToFolderAndChildren-menu" + label="&columnPicker.applyToFolderAndChildren.label;"> + <menupopup is="folder-menupopup" + class="applyViewToFolderAndChildren" + showFileHereLabel="true" + showAccountsFileHere="true" + position="start_before"></menupopup> + </menu> + </menupopup> + </menu> + `, + ["chrome://messenger/locale/messenger.dtd"] + ) + ); + + let confirmApplyCols = (destFolder, useChildren) => { + // Confirm the action with the user. + let bundle = document.getElementById("bundle_messenger"); + let title = useChildren + ? "threadPane.columnPicker.confirmFolder.withChildren.title" + : "threadPane.columnPicker.confirmFolder.noChildren.title"; + let message = useChildren + ? "threadPane.columnPicker.confirmFolder.withChildren.message" + : "threadPane.columnPicker.confirmFolder.noChildren.message"; + let confirmed = Services.prompt.confirm( + null, + bundle.getString(title), + bundle.getFormattedString(message, [destFolder.prettyName]) + ); + if (confirmed) { + this._applyColumns(destFolder, useChildren); + } + }; + + this.querySelector(".applyToFolder-menu").addEventListener( + "command", + event => { + confirmApplyCols(event.target._folder, false); + } + ); + + this.querySelector(".applyToFolderAndChildren-menu").addEventListener( + "command", + event => { + confirmApplyCols(event.target._folder, true); + } + ); + + let confirmApplyView = async (destFolder, useChildren) => { + let msgId = useChildren + ? "threadpane-apply-changes-prompt-with-children-text" + : "threadpane-apply-changes-prompt-no-children-text"; + let [title, message] = await document.l10n.formatValues([ + { id: "threadpane-apply-changes-prompt-title" }, + { id: msgId, args: { name: destFolder.prettyName } }, + ]); + if (Services.prompt.confirm(null, title, message)) { + this._applyView(destFolder, useChildren); + } + }; + + this.querySelector(".applyViewToFolder-menu").addEventListener( + "command", + event => { + confirmApplyView(event.target._folder, false); + } + ); + + this.querySelector(".applyViewToFolderAndChildren-menu").addEventListener( + "command", + event => { + confirmApplyView(event.target._folder, true); + } + ); + } + + _applyColumns(destFolder, useChildren) { + // Get the current folder's column state, plus the "swapped" column + // state, which swaps "From" and "Recipient" if only one is shown. + // This is useful for copying an incoming folder's columns to an + // outgoing folder, or vice versa. + let colState = gFolderDisplay.getColumnStates(); + + let myColStateString = JSON.stringify(colState); + let swappedColStateString; + if (colState.senderCol.visible != colState.recipientCol.visible) { + let tmp = colState.senderCol; + colState.senderCol = colState.recipientCol; + colState.recipientCol = tmp; + swappedColStateString = JSON.stringify(colState); + } else { + swappedColStateString = myColStateString; + } + + let isOutgoing = function (folder) { + return folder.isSpecialFolder( + LazyModules.DBViewWrapper.prototype.OUTGOING_FOLDER_FLAGS, + true + ); + }; + + let amIOutgoing = isOutgoing(gFolderDisplay.displayedFolder); + + let colStateString = function (folder) { + return isOutgoing(folder) == amIOutgoing + ? myColStateString + : swappedColStateString; + }; + + // Now propagate appropriately... + const propName = gFolderDisplay.PERSISTED_COLUMN_PROPERTY_NAME; + if (useChildren) { + LazyModules.MailUtils.takeActionOnFolderAndDescendents( + destFolder, + folder => { + folder.setStringProperty(propName, colStateString(folder)); + // Force the reference to be forgotten. + folder.msgDatabase = null; + } + ).then(() => { + Services.obs.notifyObservers( + gFolderDisplay.displayedFolder, + "msg-folder-columns-propagated" + ); + }); + } else { + destFolder.setStringProperty(propName, colStateString(destFolder)); + // null out to avoid memory bloat. + destFolder.msgDatabase = null; + } + } + + _applyView(destFolder, useChildren) { + let viewFlags = + gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.viewFlags; + let sortType = + gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortType; + let sortOrder = + gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortOrder; + if (useChildren) { + LazyModules.MailUtils.takeActionOnFolderAndDescendents( + destFolder, + folder => { + folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags; + folder.msgDatabase.dBFolderInfo.sortType = sortType; + folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder; + folder.msgDatabase = null; + } + ).then(() => { + Services.obs.notifyObservers( + gFolderDisplay.displayedFolder, + "msg-folder-views-propagated" + ); + }); + } else { + destFolder.msgDatabase.dBFolderInfo.viewFlags = viewFlags; + destFolder.msgDatabase.dBFolderInfo.sortType = sortType; + destFolder.msgDatabase.dBFolderInfo.sortOrder = sortOrder; + // null out to avoid memory bloat + destFolder.msgDatabase = null; + } + } + } + customElements.define( + "thread-pane-treecolpicker", + MozThreadPaneTreeColpicker, + { extends: "treecolpicker" } + ); + + // The menulist CE is defined lazily. Create one now to get menulist defined, + // allowing us to inherit from it. + if (!customElements.get("menulist")) { + delete document.createXULElement("menulist"); + } + { + /** + * MozMenulistEditable is a menulist widget that can be made editable by setting editable="true". + * With an additional type="description" the list also contains an additional label that can hold + * for instance, a description of a menu item. + * It is typically used e.g. for the "Custom From Address..." feature to let the user chose and + * edit the address to send from. + * + * @augments {MozMenuList} + */ + class MozMenulistEditable extends customElements.get("menulist") { + static get markup() { + // Accessibility information of these nodes will be + // presented on XULComboboxAccessible generated from <menulist>; + // hide these nodes from the accessibility tree. + return ` + <html:link rel="stylesheet" href="chrome://global/skin/menulist.css"/> + <html:input part="text-input" type="text" allowevents="true"/> + <hbox id="label-box" part="label-box" flex="1" role="none"> + <label id="label" part="label" crop="end" flex="1" role="none"/> + <label id="highlightable-label" part="label" crop="end" flex="1" role="none"/> + </hbox> + <dropmarker part="dropmarker" exportparts="icon: dropmarker-icon" type="menu" role="none"/> + <html:slot/> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.shadowRoot.appendChild(this.constructor.fragment); + this._inputField = this.shadowRoot.querySelector("input"); + this._labelBox = this.shadowRoot.getElementById("label-box"); + this._dropmarker = this.shadowRoot.querySelector("dropmarker"); + + if (this.getAttribute("type") == "description") { + this._description = document.createXULElement("label"); + this._description.id = this._description.part = "description"; + this._description.setAttribute("crop", "end"); + this._description.setAttribute("role", "none"); + this.shadowRoot.getElementById("label").after(this._description); + } + + this.initializeAttributeInheritance(); + + this.mSelectedInternal = null; + this.setInitialSelection(); + + this._handleMutation = mutations => { + this.editable = this.getAttribute("editable") == "true"; + }; + this.mAttributeObserver = new MutationObserver(this._handleMutation); + this.mAttributeObserver.observe(this, { + attributes: true, + attributeFilter: ["editable"], + }); + + this._keypress = event => { + if (event.key == "ArrowDown") { + this.open = true; + } + }; + this._inputField.addEventListener("keypress", this._keypress); + this._change = event => { + event.stopPropagation(); + this.selectedItem = null; + this.setAttribute("value", this._inputField.value); + // Start the event again, but this time with the menulist as target. + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + }; + this._inputField.addEventListener("change", this._change); + + this._popupHiding = event => { + // layerX is 0 if the user clicked outside the popup. + if (this.editable && event.layerX > 0) { + this._inputField.select(); + } + }; + if (!this.menupopup) { + this.appendChild(MozXULElement.parseXULToFragment(`<menupopup />`)); + } + this.menupopup.addEventListener("popuphiding", this._popupHiding); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this.mAttributeObserver.disconnect(); + this._inputField.removeEventListener("keypress", this._keypress); + this._inputField.removeEventListener("change", this._change); + this.menupopup.removeEventListener("popuphiding", this._popupHiding); + + for (let prop of [ + "_inputField", + "_labelBox", + "_dropmarker", + "_description", + ]) { + if (this[prop]) { + this[prop].remove(); + this[prop] = null; + } + } + } + + static get inheritedAttributes() { + let attrs = super.inheritedAttributes; + attrs.input = "value,disabled"; + attrs["#description"] = "value=description"; + return attrs; + } + + set editable(val) { + if (val == this.editable) { + return; + } + + if (!val) { + // If we were focused and transition from editable to not editable, + // focus the parent menulist so that the focus does not get stuck. + if (this._inputField == document.activeElement) { + window.setTimeout(() => this.focus(), 0); + } + } + + this.setAttribute("editable", val); + } + + get editable() { + return this.getAttribute("editable") == "true"; + } + + set value(val) { + this._inputField.value = val; + this.setAttribute("value", val); + this.setAttribute("label", val); + } + + get value() { + if (this.editable) { + return this._inputField.value; + } + return super.value; + } + + get label() { + if (this.editable) { + return this._inputField.value; + } + return super.label; + } + + set placeholder(val) { + this._inputField.placeholder = val; + } + + get placeholder() { + return this._inputField.placeholder; + } + + set selectedItem(val) { + if (val) { + this._inputField.value = val.getAttribute("value"); + } + super.selectedItem = val; + } + + get selectedItem() { + return super.selectedItem; + } + + focus() { + if (this.editable) { + this._inputField.focus(); + } else { + super.focus(); + } + } + + select() { + if (this.editable) { + this._inputField.select(); + } + } + } + + const MenuBaseControl = MozElements.BaseControlMixin( + MozElements.MozElementMixin(XULMenuElement) + ); + MenuBaseControl.implementCustomInterface(MozMenulistEditable, [ + Ci.nsIDOMXULMenuListElement, + Ci.nsIDOMXULSelectControlElement, + ]); + + customElements.define("menulist-editable", MozMenulistEditable, { + extends: "menulist", + }); + } + + /** + * The MozAttachmentlist widget lists attachments for a mail. This is typically used to show + * attachments while writing a new mail as well as when reading mails. + * + * @augments {MozElements.RichListBox} + */ + class MozAttachmentlist extends MozElements.RichListBox { + constructor() { + super(); + + this.messenger = Cc["@mozilla.org/messenger;1"].createInstance( + Ci.nsIMessenger + ); + + this.addEventListener("keypress", event => { + switch (event.key) { + case " ": + // Allow plain spacebar to select the focused item. + if (!event.shiftKey && !event.ctrlKey) { + this.addItemToSelection(this.currentItem); + } + // Prevent inbuilt scrolling. + event.preventDefault(); + break; + + case "Enter": + if (this.currentItem && !event.ctrlKey && !event.shiftKey) { + this.addItemToSelection(this.currentItem); + let evt = document.createEvent("XULCommandEvent"); + evt.initCommandEvent( + "command", + true, + true, + window, + 0, + event.ctrlKey, + event.altKey, + event.shiftKey, + event.metaKey, + null + ); + this.currentItem.dispatchEvent(evt); + } + break; + } + }); + + // Make sure we keep the focus. + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + + if (document.commandDispatcher.focusedElement != this) { + this.focus(); + } + }); + } + + connectedCallback() { + super.connectedCallback(); + if (this.delayConnectedCallback()) { + return; + } + + let children = Array.from(this._childNodes); + + children + .filter(child => child.getAttribute("selected") == "true") + .forEach(this.selectedItems.append, this.selectedItems); + + children + .filter(child => !child.hasAttribute("context")) + .forEach(child => + child.setAttribute("context", this.getAttribute("itemcontext")) + ); + } + + get itemCount() { + return this._childNodes.length; + } + + /** + * Get the preferred height (the height that would allow us to fit + * everything without scrollbars) of the attachmentlist's bounding + * rectangle. Add 3px to account for item's margin. + */ + get preferredHeight() { + return this.scrollHeight + this.getBoundingClientRect().height + 3; + } + + get _childNodes() { + return this.querySelectorAll("richlistitem.attachmentItem"); + } + + getIndexOfItem(item) { + for (let i = 0; i < this._childNodes.length; i++) { + if (this._childNodes[i] === item) { + return i; + } + } + return -1; + } + + getItemAtIndex(index) { + if (index >= 0 && index < this._childNodes.length) { + return this._childNodes[index]; + } + return null; + } + + getRowCount() { + return this._childNodes.length; + } + + getIndexOfFirstVisibleRow() { + if (this._childNodes.length == 0) { + return -1; + } + + // First try to estimate which row is visible, assuming they're all the same height. + let box = this; + let estimatedRow = Math.floor( + box.scrollTop / this._childNodes[0].getBoundingClientRect().height + ); + let estimatedIndex = estimatedRow * this._itemsPerRow(); + let offset = this._childNodes[estimatedIndex].screenY - box.screenY; + + if (offset > 0) { + // We went too far! Go back until we find an item totally off-screen, then return the one + // after that. + for (let i = estimatedIndex - 1; i >= 0; i--) { + let childBoxObj = this._childNodes[i].getBoundingClientRect(); + if (childBoxObj.screenY + childBoxObj.height <= box.screenY) { + return i + 1; + } + } + + // If we get here, we must have gone back to the beginning of the list, so just return 0. + return 0; + } + + // We didn't go far enough! Keep going until we find an item at least partially on-screen. + for (let i = estimatedIndex; i < this._childNodes.length; i++) { + let childBoxObj = this._childNodes[i].getBoundingClientRect(); + if (childBoxObj.screenY + childBoxObj.height > box.screenY > 0) { + return i; + } + } + + return null; + } + + ensureIndexIsVisible(index) { + this.ensureElementIsVisible(this.getItemAtIndex(index)); + } + + ensureElementIsVisible(item) { + let box = this; + + // Are we too far down? + if (item.screenY < box.screenY) { + box.scrollTop = + item.getBoundingClientRect().y - box.getBoundingClientRect().y; + } else if ( + item.screenY + item.getBoundingClientRect().height > + box.screenY + box.getBoundingClientRect().height + ) { + // ... or not far enough? + box.scrollTop = + item.getBoundingClientRect().y + + item.getBoundingClientRect().height - + box.getBoundingClientRect().y - + box.getBoundingClientRect().height; + } + } + + scrollToIndex(index) { + let box = this; + let item = this.getItemAtIndex(index); + if (!item) { + return; + } + box.scrollTop = + item.getBoundingClientRect().y - box.getBoundingClientRect().y; + } + + appendItem(attachment, name) { + // -1 appends due to the way getItemAtIndex is implemented. + return this.insertItemAt(-1, attachment, name); + } + + insertItemAt(index, attachment, name) { + let item = this.ownerDocument.createXULElement("richlistitem"); + item.classList.add("attachmentItem"); + item.setAttribute("role", "option"); + + item.addEventListener("dblclick", event => { + let evt = document.createEvent("XULCommandEvent"); + evt.initCommandEvent( + "command", + true, + true, + window, + 0, + event.ctrlKey, + event.altKey, + event.shiftKey, + event.metaKey, + null + ); + item.dispatchEvent(evt); + }); + + let makeDropIndicator = placementClass => { + let img = document.createElement("img"); + img.setAttribute( + "src", + "chrome://messenger/skin/icons/tab-drag-indicator.svg" + ); + img.setAttribute("alt", ""); + img.classList.add("attach-drop-indicator", placementClass); + return img; + }; + + item.appendChild(makeDropIndicator("before")); + + let icon = this.ownerDocument.createElement("img"); + icon.setAttribute("alt", ""); + icon.setAttribute("draggable", "false"); + // Allow the src to be invalid. + icon.classList.add("attachmentcell-icon", "invisible-on-broken"); + item.appendChild(icon); + + let textLabel = this.ownerDocument.createElement("span"); + textLabel.classList.add("attachmentcell-name"); + item.appendChild(textLabel); + + let extensionLabel = this.ownerDocument.createElement("span"); + extensionLabel.classList.add("attachmentcell-extension"); + item.appendChild(extensionLabel); + + let sizeLabel = this.ownerDocument.createElement("span"); + sizeLabel.setAttribute("role", "note"); + sizeLabel.classList.add("attachmentcell-size"); + item.appendChild(sizeLabel); + + item.appendChild(makeDropIndicator("after")); + + item.setAttribute("context", this.getAttribute("itemcontext")); + + item.attachment = attachment; + this.invalidateItem(item, name); + this.insertBefore(item, this.getItemAtIndex(index)); + return item; + } + + /** + * Set the attachment icon source. + * + * @param {MozRichlistitem} item - The attachment item to set the icon of. + * @param {string|null} src - The src to set. + */ + setAttachmentIconSrc(item, src) { + let icon = item.querySelector(".attachmentcell-icon"); + icon.setAttribute("src", src); + } + + /** + * Refresh the attachment icon using the attachment details. + * + * @param {MozRichlistitem} item - The attachment item to refresh the icon + * for. + */ + refreshAttachmentIcon(item) { + let src; + let attachment = item.attachment; + let type = attachment.contentType; + if (type == "text/x-moz-deleted") { + src = "chrome://messenger/skin/icons/attachment-deleted.svg"; + } else if (!item.loaded || item.uploading) { + src = "chrome://global/skin/icons/loading.png"; + } else if (item.cloudIcon) { + src = item.cloudIcon; + } else { + let iconName = attachment.name; + if (iconName.toLowerCase().endsWith(".eml")) { + // Discard file names derived from subject headers with special + // characters. + iconName = "message.eml"; + } else if (attachment.url) { + // For local file urls, we are better off using the full file url + // because moz-icon will actually resolve the file url and get the + // right icon from the file url. All other urls, we should try to + // extract the file name from them. This fixes issues where an icon + // wasn't showing up if you dragged a web url that had a query or + // reference string after the file name and for mailnews urls where + // the filename is hidden in the url as a &filename= part. + let url = Services.io.newURI(attachment.url); + if ( + url instanceof Ci.nsIURL && + url.fileName && + !url.schemeIs("file") + ) { + iconName = url.fileName; + } + } + src = `moz-icon://${iconName}?size=16&contentType=${type}`; + } + + this.setAttachmentIconSrc(item, src); + } + + /** + * Get whether the attachment list is fully loaded. + * + * @returns {boolean} - Whether all the attachments in the list are fully + * loaded. + */ + isLoaded() { + // Not loaded if at least one loading. + for (let item of this.querySelectorAll(".attachmentItem")) { + if (!item.loaded) { + return false; + } + } + return true; + } + + /** + * Set the attachment item's loaded state. + * + * @param {MozRichlistitem} item - The attachment item. + * @param {boolean} loaded - Whether the attachment is fully loaded. + */ + setAttachmentLoaded(item, loaded) { + item.loaded = loaded; + this.refreshAttachmentIcon(item); + } + + /** + * Set the attachment item's cloud icon, if any. + * + * @param {MozRichlistitem} item - The attachment item. + * @param {?string} cloudIcon - The icon of the cloud provider where the + * attachment was uploaded. Will be used as file type icon in the list of + * attachments, if specified. + */ + setCloudIcon(item, cloudIcon) { + item.cloudIcon = cloudIcon; + this.refreshAttachmentIcon(item); + } + + /** + * Set the attachment item's displayed name. + * + * @param {MozRichlistitem} item - The attachment item. + * @param {string} name - The name to display for the attachment. + */ + setAttachmentName(item, name) { + item.setAttribute("name", name); + // Extract what looks like the file extension so we can always show it, + // even if the full name would overflow. + // NOTE: This is a convenience feature rather than a security feature + // since the content type of an attachment need not match the extension. + let found = name.match(/^(.+)(\.[a-zA-Z0-9_#$!~+-]{1,16})$/); + item.querySelector(".attachmentcell-name").textContent = + found?.[1] || name; + item.querySelector(".attachmentcell-extension").textContent = + found?.[2] || ""; + } + + /** + * Set the attachment item's displayed size. + * + * @param {MozRichlistitem} item - The attachment item. + * @param {string} size - The size to display for the attachment. + */ + setAttachmentSize(item, size) { + item.setAttribute("size", size); + let sizeEl = item.querySelector(".attachmentcell-size"); + sizeEl.textContent = size; + sizeEl.hidden = !size; + } + + invalidateItem(item, name) { + let attachment = item.attachment; + + this.setAttachmentName(item, name || attachment.name); + let size = + attachment.size == null || attachment.size == -1 + ? "" + : this.messenger.formatFileSize(attachment.size); + if (size && item.cloudHtmlFileSize > 0) { + size = `${this.messenger.formatFileSize( + item.cloudHtmlFileSize + )} (${size})`; + } + this.setAttachmentSize(item, size); + + // By default, items are considered loaded. + item.loaded = true; + this.refreshAttachmentIcon(item); + return item; + } + + /** + * Find the attachmentitem node for the specified nsIMsgAttachment. + */ + findItemForAttachment(aAttachment) { + for (let i = 0; i < this.itemCount; i++) { + let item = this.getItemAtIndex(i); + if (item.attachment == aAttachment) { + return item; + } + } + return null; + } + + _fireOnSelect() { + if (!this._suppressOnSelect && !this.suppressOnSelect) { + this.dispatchEvent( + new Event("select", { bubbles: false, cancelable: true }) + ); + } + } + + _itemsPerRow() { + // For 0 or 1 children, we can assume that they all fit in one row. + if (this._childNodes.length < 2) { + return this._childNodes.length; + } + + let itemWidth = + this._childNodes[1].getBoundingClientRect().x - + this._childNodes[0].getBoundingClientRect().x; + + // Each item takes up a full row + if (itemWidth == 0) { + return 1; + } + return Math.floor(this.clientWidth / itemWidth); + } + + _itemsPerCol(aItemsPerRow) { + let itemsPerRow = aItemsPerRow || this._itemsPerRow(); + + if (this._childNodes.length == 0) { + return 0; + } + + if (this._childNodes.length <= itemsPerRow) { + return 1; + } + + let itemHeight = + this._childNodes[itemsPerRow].getBoundingClientRect().y - + this._childNodes[0].getBoundingClientRect().y; + + return Math.floor(this.clientHeight / itemHeight); + } + + /** + * Set the width of each child to the largest width child to create a + * grid-like effect for the flex-wrapped attachment list. + */ + setOptimumWidth() { + if (this._childNodes.length == 0) { + return; + } + + let width = 0; + for (let child of this._childNodes) { + // Unset the width, then the child will expand or shrink to its + // "natural" size in the flex-wrapped container. I.e. its preferred + // width bounded by the width of the container's content space. + child.style.width = null; + width = Math.max(width, child.getBoundingClientRect().width); + } + for (let child of this._childNodes) { + child.style.width = `${width}px`; + } + } + } + + customElements.define("attachment-list", MozAttachmentlist, { + extends: "richlistbox", + }); + + /** + * The MailAddressPill widget is used to display the email addresses in the + * messengercompose.xhtml window. + * + * @augments {MozXULElement} + */ + class MailAddressPill extends MozXULElement { + static get inheritedAttributes() { + return { + ".pill-label": "crop,value=label", + }; + } + + /** + * Indicates whether the address of this pill is for a mail list. + * + * @type {boolean} + */ + isMailList = false; + + /** + * If this pill is for a mail list, this provides the URI. + * + * @type {?string} + */ + listURI = null; + + /** + * If this pill is for a mail list, this provides the total count of + * its addresses. + * + * @type {number} + */ + listAddressCount = 0; + + connectedCallback() { + if (this.hasChildNodes() || this.delayConnectedCallback()) { + return; + } + + this.classList.add("address-pill"); + this.setAttribute("context", "emailAddressPillPopup"); + this.setAttribute("allowevents", "true"); + + this.labelView = document.createXULElement("hbox"); + this.labelView.setAttribute("flex", "1"); + + this.pillLabel = document.createXULElement("label"); + this.pillLabel.classList.add("pill-label"); + this.pillLabel.setAttribute("crop", "center"); + + this.pillIndicator = document.createElement("img"); + this.pillIndicator.setAttribute( + "src", + "chrome://messenger/skin/icons/pill-indicator.svg" + ); + this.pillIndicator.setAttribute("alt", ""); + this.pillIndicator.classList.add("pill-indicator"); + this.pillIndicator.hidden = true; + + this.labelView.appendChild(this.pillLabel); + this.labelView.appendChild(this.pillIndicator); + + this.appendChild(this.labelView); + this._setupEmailInput(); + + this._setupEventListeners(); + this.initializeAttributeInheritance(); + + // @implements {nsIObserver} + this.inputObserver = { + observe: (subject, topic, data) => { + if (topic == "autocomplete-did-enter-text" && this.isEditing) { + this.updatePill(); + } + }, + }; + + Services.obs.addObserver( + this.inputObserver, + "autocomplete-did-enter-text" + ); + + // Remove the observer on window unload as the disconnectedCallback() + // will never be called when closing a window, so we might therefore + // leak if XPCOM isn't smart enough. + window.addEventListener( + "unload", + () => { + this.removeObserver(); + }, + { once: true } + ); + } + + get emailAddress() { + return this.getAttribute("emailAddress"); + } + + set emailAddress(val) { + this.setAttribute("emailAddress", val); + } + + get label() { + return this.getAttribute("label"); + } + + set label(val) { + this.setAttribute("label", val); + } + + get fullAddress() { + return this.getAttribute("fullAddress"); + } + + set fullAddress(val) { + this.setAttribute("fullAddress", val); + } + + get displayName() { + return this.getAttribute("displayName"); + } + + set displayName(val) { + this.setAttribute("displayName", val); + } + + get emailInput() { + return this.querySelector(`input[is="autocomplete-input"]`); + } + + /** + * Get the main addressing input field the pill belongs to. + */ + get rowInput() { + return this.closest(".address-container").querySelector( + ".address-row-input" + ); + } + + /** + * Check if the pill is currently in "Edit Mode", meaning the label is + * hidden and the html:input field is visible. + * + * @returns {boolean} true if the pill is currently being edited. + */ + get isEditing() { + return !this.emailInput.hasAttribute("hidden"); + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment(` + <html:input is="autocomplete-input" + type="text" + class="input-pill" + disableonsend="true" + autocompletesearch="mydomain addrbook ldap news" + autocompletesearchparam="{}" + timeout="200" + maxrows="6" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="2" + ignoreblurwhilesearching="true" + hidden="hidden"/> + `); + } + return document.importNode(this.constructor._fragment, true); + } + + _setupEmailInput() { + this.appendChild(this.fragment); + this.emailInput.value = this.fullAddress; + } + + _setupEventListeners() { + this.addEventListener("blur", event => { + // Prevent deselecting a pill on blur if: + // - The related target is null (context menu was opened, bug 1729741). + // - The related target is another pill (multi selection and deslection + // are handled by the click event listener added on pill creation). + if ( + !event.relatedTarget || + event.relatedTarget.tagName == "mail-address-pill" + ) { + return; + } + + this.closest("mail-recipients-area").deselectAllPills(); + }); + + this.emailInput.addEventListener("keypress", event => { + if (this.hasAttribute("disabled")) { + return; + } + this.onEmailInputKeyPress(event); + }); + + // Disable the inbuilt autocomplete on blur as we handle it here. + this.emailInput._dontBlur = true; + + this.emailInput.addEventListener("blur", () => { + // 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. + if (document.activeElement == this.emailInput) { + return; + } + + if ( + this.emailInput.forceComplete && + this.emailInput.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. + this.emailInput.mController.handleEnter(true); + return; + } + + this.updatePill(); + }); + } + + /** + * Simple email address validation. + * + * @param {string} address - An email address. + */ + isValidAddress(address) { + return /^[^\s@]+@[^\s@]+$/.test(address); + } + + /** + * Convert the pill into "Edit Mode" by hiding the label and showing the + * html:input element. + */ + startEditing() { + // Record the intention of editing a pill as a change in the recipient + // even if the text is not actually changed in order to prevent accidental + // data loss. + onRecipientsChanged(); + + // We need to set the min and max width before hiding and showing the + // child nodes in order to prevent unwanted jumps in the resizing of the + // edited pill. Both properties are necessary to handle flexbox. + this.style.setProperty("max-width", `${this.clientWidth}px`); + this.style.setProperty("min-width", `${this.clientWidth}px`); + + this.classList.add("editing"); + this.labelView.setAttribute("hidden", "true"); + this.emailInput.removeAttribute("hidden"); + this.emailInput.focus(); + + // Account for pill padding. + let inputWidth = this.emailInput.clientWidth + 15; + + // In case the original address is shorter than the input field child node + // force resize the pill container to prevent overflows. + if (inputWidth > this.clientWidth) { + this.style.setProperty("max-width", `${inputWidth}px`); + this.style.setProperty("min-width", `${inputWidth}px`); + } + } + + /** + * Revert the pill UI to a regular selectable element, meaning the label is + * visible and the html:input field is hidden. + * + * @param {Event} event - The DOM Event. + */ + onEmailInputKeyPress(event) { + switch (event.key) { + case "Escape": + this.emailInput.value = this.fullAddress; + this.resetPill(); + break; + case "Delete": + case "Backspace": + if (!this.emailInput.value.trim() && !event.repeat) { + this.rowInput.focus(); + this.remove(); + } + break; + } + } + + async updatePill() { + let addresses = MailServices.headerParser.makeFromDisplayAddress( + this.emailInput.value + ); + let row = this.closest(".address-row"); + + if (!addresses[0]) { + this.rowInput.focus(); + this.remove(); + // Update aria labels of all pills in the row, as pill count changed. + updateAriaLabelsOfAddressRow(row); + onRecipientsChanged(); + return; + } + + this.label = addresses[0].toString(); + this.emailAddress = addresses[0].email || ""; + this.fullAddress = addresses[0].toString(); + this.displayName = addresses[0].name || ""; + // 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. + this.emailInput.detachController(); + // Attach it again to enable autocomplete. + this.emailInput.attachController(); + + this.resetPill(); + + // Update the aria label of edited pill only, as pill count didn't change. + // Unfortunately, we still need to get the row's pills for counting once. + let pills = row.querySelectorAll("mail-address-pill"); + this.setAttribute( + "aria-label", + await document.l10n.formatValue("pill-aria-label", { + email: this.fullAddress, + count: pills.length, + }) + ); + + onRecipientsChanged(); + } + + resetPill() { + this.updatePillStatus(); + this.style.removeProperty("max-width"); + this.style.removeProperty("min-width"); + this.classList.remove("editing"); + this.labelView.removeAttribute("hidden"); + this.emailInput.setAttribute("hidden", "hidden"); + let textLength = this.emailInput.value.length; + this.emailInput.setSelectionRange(textLength, textLength); + this.rowInput.focus(); + } + + /** + * Check if an address is valid or it exists in the address book and update + * the helper icons accordingly. + */ + async updatePillStatus() { + let isValid = this.isValidAddress(this.emailAddress); + let listNames = LazyModules.MimeParser.parseHeaderField( + this.fullAddress, + LazyModules.MimeParser.HEADER_ADDRESS + ); + + if (listNames.length > 0) { + let mailList = MailServices.ab.getMailListFromName(listNames[0].name); + this.isMailList = !!mailList; + if (this.isMailList) { + this.listURI = mailList.URI; + this.listAddressCount = mailList.childCards.length; + } else { + this.listURI = ""; + this.listAddressCount = 0; + } + } + + let isNewsgroup = this.emailInput.classList.contains("news-input"); + + if (!isValid && !this.isMailList && !isNewsgroup) { + this.classList.add("invalid-address"); + this.setAttribute( + "tooltiptext", + await document.l10n.formatValue("pill-tooltip-invalid-address", { + email: this.fullAddress, + }) + ); + this.pillIndicator.hidden = true; + + // Interrupt if the address is not valid as we don't need to check for + // other conditions. + return; + } + + this.classList.remove("invalid-address"); + this.removeAttribute("tooltiptext"); + this.pillIndicator.hidden = true; + + // Check if the address is not in the Address Book only if it's not a + // mail list or a newsgroup. + if ( + !isNewsgroup && + !this.isMailList && + !MailServices.ab.cardForEmailAddress(this.emailAddress) + ) { + this.setAttribute( + "tooltiptext", + await document.l10n.formatValue("pill-tooltip-not-in-address-book", { + email: this.fullAddress, + }) + ); + this.pillIndicator.hidden = false; + } + } + + /** + * Get the nearest sibling pill which is not selected. + * + * @param {("next"|"previous")} [siblingsType="next"] - Iterate next or + * previous siblings. + * @returns {HTMLElement} - The nearest unselected sibling element, or null. + */ + getUnselectedSiblingPill(siblingsType = "next") { + if (siblingsType == "next") { + // Check for next siblings. + let element = this.nextElementSibling; + while (element) { + if (!element.hasAttribute("selected")) { + return element; + } + element = element.nextElementSibling; + } + + return null; + } + + // Check for previous siblings. + let element = this.previousElementSibling; + while (element) { + if (!element.hasAttribute("selected")) { + return element; + } + element = element.previousElementSibling; + } + + return null; + } + + removeObserver() { + Services.obs.removeObserver( + this.inputObserver, + "autocomplete-did-enter-text" + ); + } + } + + customElements.define("mail-address-pill", MailAddressPill); + + /** + * The MailRecipientsArea widget is used to display the recipient rows in the + * header area of the messengercompose.xul window. + * + * @augments {MozXULElement} + */ + class MailRecipientsArea extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + + for (let input of this.querySelectorAll(".mail-input,.news-input")) { + // Disable inbuilt autocomplete on blur to handle it with our handlers. + input._dontBlur = true; + + setupAutocompleteInput(input); + + input.addEventListener("keypress", event => { + // Ctrl+Shift+Tab is handled by moveFocusToNeighbouringArea. + if (event.key != "Tab" || !event.shiftKey || event.ctrlKey) { + return; + } + event.preventDefault(); + this.moveFocusToPreviousElement(input); + }); + + input.addEventListener("input", event => { + addressInputOnInput(event, false); + }); + } + + // Force the focus on the first available input field if Tab is + // pressed on the extraAddressRowsMenuButton label. + document + .getElementById("extraAddressRowsMenuButton") + .addEventListener("keypress", event => { + if (event.key == "Tab" && !event.shiftKey) { + event.preventDefault(); + let row = this.querySelector(".address-row:not(.hidden)"); + let removeFieldButton = row.querySelector(".remove-field-button"); + // If the close button is hidden, focus on the input field. + if (removeFieldButton.hidden) { + row.querySelector(".address-row-input").focus(); + return; + } + // Focus on the close button. + removeFieldButton.focus(); + } + }); + + this.addEventListener("dragstart", event => { + // Check if we're dragging a pill, as the drag target might be another + // element like row or pill <input> when dragging selected plain text. + let targetPill = event.target.closest( + "mail-address-pill:not(.editing)" + ); + if (!targetPill) { + return; + } + if (!targetPill.hasAttribute("selected")) { + // If the drag action starts from a non-selected pill, + // deselect all selected pills and select only the target pill. + for (let pill of this.getAllSelectedPills()) { + pill.removeAttribute("selected"); + } + targetPill.toggleAttribute("selected"); + } + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.setData("text/pills", "pills"); + event.dataTransfer.setDragImage(targetPill, 50, 12); + }); + + this.addEventListener("dragover", event => { + event.preventDefault(); + }); + + this.addEventListener("dragenter", event => { + if (!event.dataTransfer.getData("text/pills")) { + return; + } + + // If the current drop target is a pill, add drop indicator style to it. + event.target + .closest("mail-address-pill") + ?.classList.add("drop-indicator"); + + // If the current drop target is inside an address row, add the + // indicator style for the row's address container. + event.target + .closest(".address-row") + ?.querySelector(".address-container") + .classList.add("drag-address-container"); + }); + + this.addEventListener("dragleave", event => { + if (!event.dataTransfer.getData("text/pills")) { + return; + } + // If dragleave from pill, remove its drop indicator style. + event.target + .closest("mail-address-pill") + ?.classList.remove("drop-indicator"); + + // If dragleave from address row, remove the indicator style of its + // address container. + event.target + .closest(".address-row") + ?.querySelector(".address-container") + .classList.remove("drag-address-container"); + }); + + this.addEventListener("drop", event => { + // First handle cases where the dropped data is not pills. + if (!event.dataTransfer.getData("text/pills")) { + // Bail out if the dropped data comes from the contacts sidebar. + // Those addresses will be added immediately as pills without going + // through the input field as plain text. + if (event.dataTransfer.types.includes("moz/abcard")) { + return; + } + + // Dropped data should be plain text (images are handled elsewhere). + // We currently only support dropping text directly into the row input + // (Bug 1706187), which is inbuilt: no further handling required here. + // Input element resizing is automatically handled by its input event. + return; + } + + // Pills have been dropped ("text/pills"). + let targetAddressRow = event.target.closest(".address-row"); + // Return if pills have been dropped outside an address row. + if ( + !targetAddressRow || + targetAddressRow.classList.contains("address-row-raw") + ) { + return; + } + + // Pills have been dropped somewhere inside an address row. + // If they have been dropped directly on an address container, use that. + // Otherwise ensure having an addressContainer for drop targets inside + // the row, but outside the address container (e.g. the row label). + let targetAddressContainer = event.target.closest(".address-container"); + let addressContainer = + targetAddressContainer || + targetAddressRow.querySelector(".address-container"); + + // Recreate pills in the target address container. + // If dropped on a pill, append pills before that pill. Otherwise if + // dropped into an address container, append pills after existing pills. + // Otherwise if dropped elsewhere on the row (e.g. on the row label), + // append pills before existing pills. + let targetPill = event.target.closest("mail-address-pill"); + this.createDNDPills( + addressContainer, + targetPill || !targetAddressContainer, + targetPill ? targetPill.fullAddress : null + ); + addressContainer.classList.remove("drag-address-container"); + }); + } + + /** + * Check if the current size of the recipient input field doesn't exceed its + * container width. This might happen if the user pastes a very long string + * with multiple addresses when pills are already present. + * + * @param {Element} input - The HTML input field. + * @param {integer} length - The amount of characters in the input field. + */ + resizeInputField(input, length) { + // Set a minimum size of 1 in case no characters were written in the field + // in order to force the smallest size possible and avoid blank rows when + // multiple pills fill the entire recipient row. + input.setAttribute("size", length || 1); + + // If the previously set size causes the input field to grow beyond 80% of + // its parent container, we remove the size attribute to let the CSS flex + // attribute let it grow naturally to fill the available space. + if ( + input.clientWidth > + input.closest(".address-container").clientWidth * 0.8 + ) { + input.removeAttribute("size"); + } + } + + /** + * Move the dragged pills to another address row. + * + * @param {string} addressContainer - The address container on which pills + * have been dropped. + * @param {boolean} [appendStart] - If the selected addresses should be + * appended at the start or at the end of existing addresses. + * Specifying targetAddress will override this. + * @param {string} [targetAddress] - The existing address before which all + * selected addresses should be appended. + */ + createDNDPills(addressContainer, appendStart, targetAddress) { + let existingPills = + addressContainer.querySelectorAll("mail-address-pill"); + let existingAddresses = [...existingPills].map(pill => pill.fullAddress); + let selectedAddresses = [...this.getAllSelectedPills()].map( + pill => pill.fullAddress + ); + let originalTargetIndex = existingAddresses.indexOf(targetAddress); + + // Remove all the duplicate existing addresses. + for (let address of selectedAddresses) { + let index = existingAddresses.indexOf(address); + if (index > -1) { + existingAddresses.splice(index, 1); + } + } + + let combinedAddresses; + // If selected pills have been dropped on another pill, they should be + // inserted before that pill, otherwise use appendStart. + if (targetAddress) { + // Merge the two arrays in the right order. If the target address has + // been removed by deduplication above, use its original index. + existingAddresses.splice( + existingAddresses.includes(targetAddress) + ? existingAddresses.indexOf(targetAddress) + : originalTargetIndex, + 0, + ...selectedAddresses + ); + combinedAddresses = existingAddresses; + } else { + combinedAddresses = appendStart + ? selectedAddresses.concat(existingAddresses) + : existingAddresses.concat(selectedAddresses); + } + + // Remove all selected pills. + for (let pill of this.getAllSelectedPills()) { + pill.remove(); + } + + // Existing pills are removed before creating new ones in the right order. + for (let pill of existingPills) { + pill.remove(); + } + + // Create pills for all the combined addresses. + let row = addressContainer.closest(".address-row"); + for (let address of combinedAddresses) { + addressRowAddRecipientsArray( + row, + [address], + selectedAddresses.includes(address) + ); + } + + // Move the focus to the first selected pill. + this.getAllSelectedPills()[0].focus(); + } + + /** + * Create a new address row and a menuitem for revealing it. + * + * @param {object} recipient - An object for various element attributes. + * @param {boolean} rawInput - A flag to disable pills and autocompletion. + * @returns {object} - The newly created elements. + * @property {Element} row - The address row. + * @property {Element} showRowMenuItem - The menu item that shows the row. + */ + // NOTE: This is currently never called with rawInput = false, so it may be + // out of date if used. + buildRecipientRow(recipient, rawInput = false) { + let row = document.createXULElement("hbox"); + row.setAttribute("id", recipient.rowId); + row.classList.add("address-row"); + row.dataset.recipienttype = recipient.type; + + let firstCol = document.createXULElement("hbox"); + firstCol.classList.add("aw-firstColBox"); + + row.classList.add("hidden"); + + let closeButton = document.createElement("button"); + closeButton.classList.add("remove-field-button", "plain-button"); + document.l10n.setAttributes(closeButton, "remove-address-row-button", { + type: recipient.type, + }); + let closeIcon = document.createElement("img"); + closeIcon.setAttribute("src", "chrome://global/skin/icons/close.svg"); + // Button's title is the accessible name. + closeIcon.setAttribute("alt", ""); + closeButton.appendChild(closeIcon); + + closeButton.addEventListener("click", event => { + closeLabelOnClick(event); + }); + firstCol.appendChild(closeButton); + row.appendChild(firstCol); + + let labelContainer = document.createXULElement("hbox"); + labelContainer.setAttribute("align", "top"); + labelContainer.setAttribute("pack", "end"); + labelContainer.setAttribute("flex", 1); + labelContainer.classList.add("address-label-container"); + labelContainer.setAttribute( + "style", + getComposeBundle().getString("headersSpaceStyle") + ); + + let label = document.createXULElement("label"); + label.setAttribute("id", recipient.labelId); + label.setAttribute("value", recipient.type); + label.setAttribute("control", recipient.inputId); + label.setAttribute("flex", 1); + label.setAttribute("crop", "end"); + labelContainer.appendChild(label); + row.appendChild(labelContainer); + + let inputContainer = document.createXULElement("hbox"); + inputContainer.setAttribute("id", recipient.containerId); + inputContainer.setAttribute("flex", 1); + inputContainer.setAttribute("align", "center"); + inputContainer.classList.add( + "input-container", + "wrap-container", + "address-container" + ); + inputContainer.addEventListener("click", focusAddressInputOnClick); + + // Set up the row input for the row. + let input = document.createElement( + "input", + rawInput + ? undefined + : { + is: "autocomplete-input", + } + ); + input.setAttribute("id", recipient.inputId); + input.setAttribute("size", 1); + input.setAttribute("type", "text"); + input.setAttribute("disableonsend", true); + input.classList.add("plain", "address-input", "address-row-input"); + + if (!rawInput) { + // Regular autocomplete address input, not other header with raw input. + // Set various attributes for autocomplete. + input.setAttribute("autocompletesearch", "mydomain addrbook ldap news"); + input.setAttribute("autocompletesearchparam", "{}"); + input.setAttribute("timeout", 200); + input.setAttribute("maxrows", 6); + input.setAttribute("completedefaultindex", true); + input.setAttribute("forcecomplete", true); + input.setAttribute("completeselectedindex", true); + input.setAttribute("minresultsforpopup", 2); + input.setAttribute("ignoreblurwhilesearching", true); + // Disable the inbuilt autocomplete on blur as we handle it below. + input._dontBlur = true; + + setupAutocompleteInput(input); + + // Handle keydown event in autocomplete address input of row with pills. + // input.onBeforeHandleKeyDown() gets called by the toolkit autocomplete + // before going into autocompletion. + input.onBeforeHandleKeyDown = event => { + addressInputOnBeforeHandleKeyDown(event); + }; + } else { + // Handle keydown event in other header input (rawInput), which does not + // have autocomplete and its associated keydown handling. + row.classList.add("address-row-raw"); + input.addEventListener("keydown", otherHeaderInputOnKeyDown); + input.addEventListener("input", event => { + addressInputOnInput(event, true); + }); + } + + input.addEventListener("blur", () => { + addressInputOnBlur(input); + }); + input.addEventListener("focus", () => { + addressInputOnFocus(input); + }); + + inputContainer.appendChild(input); + row.appendChild(inputContainer); + + // Create the menuitem that shows the row on selection. + let showRowMenuItem = document.createXULElement("menuitem"); + showRowMenuItem.classList.add("subviewbutton", "menuitem-iconic"); + showRowMenuItem.setAttribute("id", recipient.showRowMenuItemId); + showRowMenuItem.setAttribute("disableonsend", true); + showRowMenuItem.setAttribute("label", recipient.type); + + showRowMenuItem.addEventListener("command", () => + showAndFocusAddressRow(row.id) + ); + + row.dataset.showSelfMenuitem = showRowMenuItem.id; + + return { row, showRowMenuItem }; + } + + /** + * Create a new recipient pill. + * + * @param {HTMLElement} element - The original autocomplete input that + * generated the pill. + * @param {Array} address - The array containing the recipient's info. + * @returns {Element} The newly created pill. + */ + createRecipientPill(element, address) { + let pill = document.createXULElement("mail-address-pill"); + + pill.label = address.toString(); + pill.emailAddress = address.email || ""; + pill.fullAddress = address.toString(); + pill.displayName = address.name || ""; + + pill.addEventListener("click", event => { + if (pill.hasAttribute("disabled")) { + return; + } + // Remove pills on middle mouse button click, but not with selection + // modifier keys. + if ( + event.button == 1 && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) { + if (!pill.hasAttribute("selected")) { + this.deselectAllPills(); + pill.setAttribute("selected", "selected"); + } + this.removeSelectedPills(); + return; + } + + // Edit pill on unmodified single left-click on single selected pill, + // which also fires for unmodified double-click ("dblclick") on a pill. + if ( + event.button == 0 && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !pill.isEditing && + pill.hasAttribute("selected") && + this.getAllSelectedPills().length == 1 + ) { + this.startEditing(pill, event); + return; + } + + // Handle selection, especially with Ctrl/Cmd and/or Shift modifiers. + this.checkSelected(pill, event); + }); + + pill.addEventListener("keydown", event => { + if (!pill.isEditing || pill.hasAttribute("disabled")) { + return; + } + this.handleKeyDown(pill, event); + }); + + pill.addEventListener("keypress", event => { + if (pill.hasAttribute("disabled")) { + return; + } + this.handleKeyPress(pill, event); + }); + + element.closest(".address-container").insertBefore(pill, element); + + // The emailInput attribute is accessible only after the pill has been + // appended to the DOM. + let excludedClasses = [ + "mail-primary-input", + "news-primary-input", + "address-row-input", + ]; + for (let cssClass of element.classList) { + if (excludedClasses.includes(cssClass)) { + continue; + } + pill.emailInput.classList.add(cssClass); + } + pill.emailInput.setAttribute( + "aria-labelledby", + element.getAttribute("aria-labelledby") + ); + element.removeAttribute("aria-labelledby"); + + let params = JSON.parse( + pill.emailInput.getAttribute("autocompletesearchparam") + ); + params.type = element.closest(".address-row").dataset.recipienttype; + pill.emailInput.setAttribute( + "autocompletesearchparam", + JSON.stringify(params) + ); + + pill.updatePillStatus(); + + return pill; + } + + /** + * Handle keydown event on a pill in the mail-recipients-area. + * + * @param {Element} pill - The mail-address-pill element where Event fired. + * @param {Event} event - The DOM Event. + */ + handleKeyDown(pill, event) { + switch (event.key) { + case " ": + case ",": + // Behaviour consistent with row input: + // 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. + let input = pill.emailInput; + let selection = input.value.substring( + input.selectionStart, + input.selectionEnd + ); + if (selection.includes(input.value.trim())) { + event.preventDefault(); + input.value = ""; + } + break; + } + } + + /** + * Handle keypress event on a pill in the mail-recipients-area. + * + * @param {Element} pill - The mail-address-pill element where Event fired. + * @param {Event} event - The DOM Event. + */ + handleKeyPress(pill, event) { + if (pill.isEditing) { + return; + } + + switch (event.key) { + case "Enter": + case "F2": // For Windows users + this.startEditing(pill, event); + break; + + case "Delete": + case "Backspace": + // We must never delete a focused pill which is not selected. + // If no pills selected, just select the focused pill. + // For rapid repeated deletions (esp. from holding BACKSPACE), + // stop before selecting another focused pill for deletion. + if (!this.hasSelectedPills() && !event.repeat) { + pill.setAttribute("selected", "selected"); + break; + } + // Delete selected pills, handle focus and select another pill + // where applicable. + let focusType = event.key == "Delete" ? "next" : "previous"; + this.removeSelectedPills(focusType, true); + break; + + case "ArrowLeft": + if (pill.previousElementSibling) { + this.checkKeyboardSelected(event, pill.previousElementSibling); + } + break; + + case "ArrowRight": + this.checkKeyboardSelected(event, pill.nextElementSibling); + break; + + case " ": + this.checkSelected(pill, event); + break; + + case "Home": + let firstPill = pill + .closest(".address-container") + .querySelector("mail-address-pill"); + if (!event.ctrlKey) { + // Unmodified navigation: select only first pill and focus it below. + // ### Todo: We can't handle Shift+Home yet, so it ends up here. + this.deselectAllPills(); + firstPill.setAttribute("selected", "selected"); + } + firstPill.focus(); + break; + + case "End": + if (!event.ctrlKey) { + // Unmodified navigation: focus row input. + // ### Todo: We can't handle Shift+End yet, so it ends up here. + pill.rowInput.focus(); + break; + } + // Navigation with Ctrl modifier key: focus last pill. + pill + .closest(".address-container") + .querySelector("mail-address-pill:last-of-type") + .focus(); + break; + + case "Tab": + for (let item of this.getSiblingPills(pill)) { + item.removeAttribute("selected"); + } + // Ctrl+Tab is handled by moveFocusToNeighbouringArea. + if (event.ctrlKey) { + return; + } + event.preventDefault(); + if (event.shiftKey) { + this.moveFocusToPreviousElement(pill); + return; + } + pill.rowInput.focus(); + break; + + case "a": + if ( + !(event.ctrlKey || event.metaKey) || + event.repeat || + event.shiftKey + ) { + // Bail out if it's not Ctrl+A or Cmd+A, if the Shift key is + // pressed, or if repeated keypress. + break; + } + if ( + pill + .closest(".address-container") + .querySelector("mail-address-pill:not([selected])") + ) { + // For non-repeated Ctrl+A, if there's at least one unselected pill, + // first select all pills of the same .address-container. + this.selectSiblingPills(pill); + break; + } + // For non-repeated Ctrl+A, if pills in same container are already + // selected, select all pills of the entire <mail-recipients-area>. + this.selectAllPills(); + break; + + case "c": + if (event.ctrlKey || event.metaKey) { + this.copySelectedPills(); + } + break; + + case "x": + if (event.ctrlKey || event.metaKey) { + this.cutSelectedPills(); + } + break; + } + } + + /** + * Handle the selection and focus of recipient pill elements on mouse click + * and spacebar keypress events. + * + * @param {HTMLElement} pill - The <mail-address-pill> element, event target. + * @param {Event} event - A DOM click or keypress Event. + */ + checkSelected(pill, event) { + // Interrupt if the pill is in edit mode or a right click was detected. + // Selecting pills on right click will be handled by the opening of the + // context menu. + if (pill.isEditing || event.button == 2) { + return; + } + + if (!event.ctrlKey && !event.metaKey && event.key != " ") { + this.deselectAllPills(); + } + + pill.toggleAttribute("selected"); + + // We need to force the focus on a pill that receives a click event + // (or a spacebar keypress), as macOS doesn't automatically move the focus + // on this custom element (bug 1645643, bug 1645916). + pill.focus(); + } + + /** + * Handle the selection and focus of the pill elements on keyboard + * navigation. + * + * @param {Event} event - A DOM keyboard event. + * @param {HTMLElement} targetElement - A mail-address-pill or address input + * element navigated to. + */ + checkKeyboardSelected(event, targetElement) { + let sourcePill = + event.target.tagName == "mail-address-pill" ? event.target : null; + let targetPill = + targetElement.tagName == "mail-address-pill" ? targetElement : null; + + if (event.shiftKey) { + if (sourcePill) { + sourcePill.setAttribute("selected", "selected"); + } + if (event.key == "Home" && !sourcePill) { + // Shift+Home from address input. + this.selectSiblingPills(targetPill); + } + if (targetPill) { + targetPill.setAttribute("selected", "selected"); + } + } else if (!event.ctrlKey) { + // Non-modified navigation keys must select the target pill and deselect + // all others. Also some other keys like Backspace from rowInput. + this.deselectAllPills(); + if (targetPill) { + targetPill.setAttribute("selected", "selected"); + } else { + // Focus the input navigated to. + targetElement.focus(); + } + } + + // If targetElement is a pill, focus it. + if (targetPill) { + targetPill.focus(); + } + } + + /** + * Trigger the pill.startEditing() method. + * + * @param {XULElement} pill - The mail-address-pill element. + * @param {Event} event - The DOM Event. + */ + startEditing(pill, event) { + if (pill.isEditing) { + event.stopPropagation(); + return; + } + + pill.startEditing(); + } + + /** + * Copy the selected pills to clipboard. + */ + copySelectedPills() { + let selectedAddresses = [ + ...document.getElementById("recipientsContainer").getAllSelectedPills(), + ].map(pill => pill.fullAddress); + + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + clipboard.copyString(selectedAddresses.join(", ")); + } + + /** + * Cut the selected pills to clipboard. + */ + cutSelectedPills() { + this.copySelectedPills(); + this.removeSelectedPills(); + } + + /** + * Move the selected email address pills to another address row. + * + * @param {Element} row - The address row to move the pills to. + */ + moveSelectedPills(row) { + // Store all the selected addresses inside an array. + let selectedAddresses = [...this.getAllSelectedPills()].map( + pill => pill.fullAddress + ); + + // Return if no pills selected. + if (!selectedAddresses.length) { + return; + } + + // Remove the selected pills. + this.removeSelectedPills("next", false, true); + + // Create new address pills inside the target address row and + // maintain the current selection. + addressRowAddRecipientsArray(row, selectedAddresses, true); + + // Move focus to the last selected pill. + let selectedPills = this.getAllSelectedPills(); + selectedPills[selectedPills.length - 1].focus(); + } + + /** + * Delete all selected pills and handle focus and selection smartly as needed. + * + * @param {("next"|"previous")} [focusType="next"] - How to move focus after + * removing pills: try to focus one of the next siblings (for DEL etc.) + * or one of the previous siblings (for BACKSPACE). + * @param {boolean} [select=false] - After deletion, whether to select the + * focused pill where applicable. + * @param {boolean} [moved=false] - Whether the method was originally called + * from moveSelectedPills(). + */ + removeSelectedPills(focusType = "next", select = false, moved = false) { + // Return if no pills selected. + let firstSelectedPill = this.querySelector("mail-address-pill[selected]"); + if (!firstSelectedPill) { + return; + } + // Get the pill which has focus before we start removing selected pills, + // which may or may not include the focused pill. If no pill has focus, + // consider the first selected pill as focused pill for our purposes. + let pill = + this.querySelector("mail-address-pill:focus") || firstSelectedPill; + + // We'll look hard for an appropriate element to focus after the removal. + let focusElement = null; + // Get addressContainer and rowInput now as pill might be deleted later. + let addressContainer = pill.closest(".address-container"); + let rowInput = pill.rowInput; + let unselectedSourcePill = false; + + if (pill.hasAttribute("selected")) { + // Find focus (1): Focused pill is selected and will be deleted; + // try nearest sibling, observing focusType direction. + focusElement = pill.getUnselectedSiblingPill(focusType); + } else { + // The source pill isn't selected; keep it focused ("satellite focus"). + unselectedSourcePill = true; + focusElement = pill; + } + + // Remove selected pills. + let selectedPills = this.getAllSelectedPills(); + for (let sPill of selectedPills) { + sPill.remove(); + } + + // Find focus (2): When deleting backwards, if no previous sibling found, + // this means that the first pill was deleted. Try the first remaining pill, + // but don't auto-select it because it's in the opposite direction. + if (!focusElement && focusType == "previous") { + focusElement = addressContainer.querySelector("mail-address-pill"); + } else if ( + select && + focusElement && + selectedPills.length == 1 && + !unselectedSourcePill + ) { + // If select = true (DEL or BACKSPACE), and we found a pill to focus in + // round (1), and we have removed a single pill only, and it's not a + // case of "satellite focus" (see above): + // Conveniently select the nearest pill for rapid consecutive deletions. + focusElement.setAttribute("selected", "selected"); + } + // Find focus (3): If all else fails (no pills left in addressContainer, + // or last pill deleted forwards): Focus rowInput. + if (!focusElement) { + focusElement = rowInput; + } + focusElement.focus(); + + // Update aria labels for all rows as we allow cross-row pill removal. + // This may not yet be micro-performance optimized; see bug 1671261. + updateAriaLabelsAndTooltipsOfAllAddressRows(); + + // Don't trigger some methods if the pills were removed automatically + // during the move to another addressing widget. + if (!moved) { + onRecipientsChanged(); + } + } + + /** + * Select all pills of the same address row (.address-container). + * + * @param {Element} pill - A <mail-address-pill> element. All pills in the + * same .address-container will be selected. + */ + selectSiblingPills(pill) { + for (let sPill of this.getSiblingPills(pill)) { + sPill.setAttribute("selected", "selected"); + } + } + + /** + * Select all pills of the <mail-recipients-area> element. + */ + selectAllPills() { + for (let pill of this.getAllPills()) { + pill.setAttribute("selected", "selected"); + } + } + + /** + * Deselect all the pills of the <mail-recipients-area> element. + */ + deselectAllPills() { + for (let pill of this.querySelectorAll(`mail-address-pill[selected]`)) { + pill.removeAttribute("selected"); + } + } + + /** + * Return all pills of the same address row (.address-container). + * + * @param {Element} pill - A <mail-address-pill> element. All pills in the + * same .address-container will be returned. + * @returns {NodeList} NodeList of <mail-address-pill> elements in same field. + */ + getSiblingPills(pill) { + return pill + .closest(".address-container") + .querySelectorAll("mail-address-pill"); + } + + /** + * Return all pills of the <mail-recipients-area> element. + * + * @returns {NodeList} NodeList of all <mail-address-pill> elements. + */ + getAllPills() { + return this.querySelectorAll("mail-address-pill"); + } + + /** + * Return all currently selected pills in the <mail-recipients-area>. + * + * @returns {NodeList} NodeList of all selected <mail-address-pill> elements. + */ + getAllSelectedPills() { + return this.querySelectorAll("mail-address-pill[selected]"); + } + + /** + * Check if any pill in the <mail-recipients-area> is selected. + * + * @returns {boolean} true if any pill is selected. + */ + hasSelectedPills() { + return Boolean(this.querySelector("mail-address-pill[selected]")); + } + + /** + * Move the focus to the previous focusable element. + * + * @param {Element} element - The element where the event was triggered. + */ + moveFocusToPreviousElement(element) { + let row = element.closest(".address-row"); + // Move focus on the close label if not collapsed. + if (!row.querySelector(".remove-field-button").hidden) { + row.querySelector(".remove-field-button").focus(); + return; + } + // If a previous address row is available and not hidden, + // focus on the autocomplete input field. + let previousRow = row.previousElementSibling; + while (previousRow) { + if (!previousRow.classList.contains("hidden")) { + previousRow.querySelector(".address-row-input").focus(); + return; + } + previousRow = previousRow.previousElementSibling; + } + // Move the focus on the previous button: either the + // extraAddressRowsMenuButton, or one of "<type>ShowAddressRowButton". + let buttons = document.querySelectorAll( + "#extraAddressRowsArea button:not([hidden])" + ); + if (buttons.length) { + // Select the last available label. + buttons[buttons.length - 1].focus(); + return; + } + // Move the focus on the msgIdentity if no extra recipients are available. + document.getElementById("msgIdentity").focus(); + } + } + + customElements.define("mail-recipients-area", MailRecipientsArea); +} diff --git a/comm/mail/base/content/widgets/pane-splitter.js b/comm/mail/base/content/widgets/pane-splitter.js new file mode 100644 index 0000000000..d201f3286f --- /dev/null +++ b/comm/mail/base/content/widgets/pane-splitter.js @@ -0,0 +1,562 @@ +/* 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/. */ + +{ + /** + * A widget for adjusting the size of its {@link PaneSplitter#resizeElement}. + * By default, the splitter will resize the height of the resizeElement, but + * this can be changed using the "resize-direction" attribute. + * + * If dragged, the splitter will set a CSS variable on the parent element, + * which is named from the id of the element plus "width" or "height" as + * appropriate (e.g. --splitter-width). The variable should be used to set the + * border-area width or height of the resizeElement. + * + * Often, you will want to naturally limit the size of the resizeElement to + * prevent it exceeding its min or max size bounds, and to remain within the + * available space of its container. One way to do this is to use a grid + * layout on the container and size the resizeElement's row with + * "minmax(auto, --splitter-height)", or similar for the column when adjusting + * the width. + * + * This splitter element fires a "splitter-resizing" event as dragging begins, + * and "splitter-resized" when it ends. + * + * The resizeElement can be collapsed and expanded. Whilst collapsed, the + * "collapsed-by-splitter" class will be added to the resizeElement and the + * "--<id>-width" or "--<id>-height" CSS variable, will be be set to "0px". + * The "splitter-collapsed" and "splitter-expanded" events are fired as + * appropriate. If the splitter has a "collapse-width" or "collapse-height" + * attribute, collapsing and expanding happens automatically when below the + * given size. + */ + class PaneSplitter extends HTMLHRElement { + static observedAttributes = ["resize-direction", "resize-id", "id"]; + + connectedCallback() { + this.addEventListener("mousedown", this); + // Try and find the _resizeElement from the resize-id attribute. + this._updateResizeElement(); + this._updateStyling(); + } + + attributeChangedCallback(name, oldValue, newValue) { + switch (name) { + case "resize-direction": + this._updateResizeDirection(); + break; + case "resize-id": + this._updateResizeElement(); + break; + case "id": + this._updateStyling(); + break; + } + } + + /** + * The direction the splitter resizes the controlled element. Resizing + * horizontally changes its width, whilst resizing vertically changes its + * height. + * + * This corresponds to the "resize-direction" attribute and defaults to + * "vertical" when none is given. + * + * @type {"vertical"|"horizontal"} + */ + get resizeDirection() { + return this.getAttribute("resize-direction") ?? "vertical"; + } + + set resizeDirection(val) { + this.setAttribute("resize-direction", val); + } + + _updateResizeDirection() { + // The resize direction has changed. To be safe, make sure we're no longer + // resizing. + this.endResize(); + this._updateStyling(); + } + + _resizeElement = null; + + /** + * The element that is being sized by the splitter. It must have a set id. + * + * If the "resize-id" attribute is set, it will be used to choose this + * element by its id. + * + * @type {?HTMLElement} + */ + get resizeElement() { + // Make sure the resizeElement is up to date. + this._updateResizeElement(); + return this._resizeElement; + } + + set resizeElement(element) { + if (!element?.id) { + element = null; + } + this._updateResizeElement(element); + // Set the resize-id attribute. + // NOTE: This will trigger a second call to _updateResizeElement, but it + // should end early because the resize-id matches the just set + // _resizeElement. + if (element) { + this.setAttribute("resize-id", element.id); + } else { + this.removeAttribute("resize-id"); + } + } + + /** + * Update the _resizeElement property. + * + * @param {?HTMLElement} [element] - The resizeElement to set, or leave + * undefined to use the resize-id attribute to find the element. + */ + _updateResizeElement(element) { + if (element == undefined) { + // Use the resize-id to find the element. + let resizeId = this.getAttribute("resize-id"); + if (resizeId) { + if (this._resizeElement?.id == resizeId) { + // Avoid looking up the element since we already have it. + return; + } + // Try and find the element. + // NOTE: If we don't find the element now, then we still keep the same + // resize-id attribute and we'll try again the next time this method + // is called. + element = this.ownerDocument.getElementById(resizeId); + } else { + element = null; + } + } + if (element == this._resizeElement) { + return; + } + + // Make sure we stop resizing the current _resizeElement. + this.endResize(); + if (this._resizeElement) { + // Clean up previous element. + this._resizeElement.classList.remove("collapsed-by-splitter"); + } + this._resizeElement = element; + this._beforeElement = + element && + !!( + this.compareDocumentPosition(element) & + Node.DOCUMENT_POSITION_FOLLOWING + ); + // Are we already collapsed? + this._isCollapsed = this._resizeElement?.classList.contains( + "collapsed-by-splitter" + ); + this._updateStyling(); + } + + _width = null; + + /** + * The desired width of the resizeElement. This is used to set the + * --<id>-width CSS variable on the parent when the resizeDirection is + * "horizontal" and the resizeElement is not collapsed. If its value is + * null, the same CSS variable is removed from the parent instead. + * + * Note, this value is persistent across collapse states, so the width + * before collapsing can be returned to on expansion. + * + * Use this value in persistent storage. + * + * @type {?number} + */ + get width() { + return this._width; + } + + set width(width) { + if (width == this._width) { + return; + } + this._width = width; + this._updateStyling(); + } + + _height = null; + + /** + * The desired height of the resizeElement. This is used to set the + * -<id>-height CSS variable on the parent when the resizeDirection is + * "vertical" and the resizeElement is not collapsed. If its value is null, + * the same CSS variable is removed from the parent instead. + * + * Note, this value is persistent across collapse states, so the height + * before collapsing can be returned to on expansion. + * + * Use this value in persistent storage. + * + * @type {?number} + */ + get height() { + return this._height; + } + + set height(height) { + if (height == this._height) { + return; + } + this._height = height; + this._updateStyling(); + } + + /** + * Update the width or height of the splitter, depending on its + * resizeDirection. + * + * If a trySize is given, the width or height of the splitter will be set to + * the given value, before being set to the actual size of the + * resizeElement. This acts as an automatic bounding process, without + * knowing the details of the layout and its constraints. + * + * If no trySize is given, then the width and height will be set to the + * actual size of the resizeElement. + * + * @param {?number} [trySize] - The size to try and achieve. + */ + _updateSize(trySize) { + let vertical = this.resizeDirection == "vertical"; + if (trySize != undefined) { + if (vertical) { + this.height = trySize; + } else { + this.width = trySize; + } + } + // Now that the width and height are updated, we fetch the size the + // element actually took. + let actual = this._getActualResizeSize(); + if (vertical) { + this.height = actual; + } else { + this.width = actual; + } + } + + /** + * Get the actual size of the resizeElement, regardless of the current + * width or height property values. This causes a reflow, and it gets + * called on every mousemove event while dragging, so it's very expensive + * but practically unavoidable. + * + * @returns {number} - The border area size of the resizeElement. + */ + _getActualResizeSize() { + let resizeRect = this.resizeElement.getBoundingClientRect(); + if (this.resizeDirection == "vertical") { + return resizeRect.height; + } + return resizeRect.width; + } + + /** + * Collapses the controlled pane. A collapsed pane does not affect the + * `width` or `height` properties. Fires a "splitter-collapsed" event. + */ + collapse() { + if (this._isCollapsed) { + return; + } + this._isCollapsed = true; + this._updateStyling(); + this._updateDragCursor(); + this.dispatchEvent( + new CustomEvent("splitter-collapsed", { bubbles: true }) + ); + } + + /** + * Expands the controlled pane. It returns to the width or height it had + * when collapsed. Fires a "splitter-expanded" event. + */ + expand() { + if (!this._isCollapsed) { + return; + } + this._isCollapsed = false; + this._updateStyling(); + this._updateDragCursor(); + this.dispatchEvent( + new CustomEvent("splitter-expanded", { bubbles: true }) + ); + } + + _isCollapsed = false; + + /** + * If the controlled pane is collapsed. + * + * @type {boolean} + */ + get isCollapsed() { + return this._isCollapsed; + } + + set isCollapsed(collapsed) { + if (collapsed) { + this.collapse(); + } else { + this.expand(); + } + } + + /** + * Collapse the splitter if it is expanded, or expand it if collapsed. + */ + toggleCollapsed() { + this.isCollapsed = !this._isCollapsed; + } + + /** + * If the splitter is disabled. + * + * @type {boolean} + */ + get isDisabled() { + return this.hasAttribute("disabled"); + } + + set isDisabled(disabled) { + if (disabled) { + this.setAttribute("disabled", true); + return; + } + this.removeAttribute("disabled"); + } + + /** + * Update styling to reflect the current state. + */ + _updateStyling() { + if (!this.resizeElement || !this.parentNode || !this.id) { + // Wait until we have a resizeElement, a parent and an id. + return; + } + + if (this.id != this._cssName?.basis) { + // Clear the old names. + if (this._cssName) { + this.parentNode.style.removeProperty(this._cssName.width); + this.parentNode.style.removeProperty(this._cssName.height); + } + this._cssName = { + basis: this.id, + height: `--${this.id}-height`, + width: `--${this.id}-width`, + }; + } + + let vertical = this.resizeDirection == "vertical"; + let height = this.isCollapsed ? 0 : this.height; + if (!vertical || height == null) { + // If we are resizing horizontally or the "height" property is set to + // null, we remove the CSS height variable. The height of the element + // is left to be determined by the CSS stylesheet rules. + this.parentNode.style.removeProperty(this._cssName.height); + } else { + this.parentNode.style.setProperty(this._cssName.height, `${height}px`); + } + let width = this.isCollapsed ? 0 : this.width; + if (vertical || width == null) { + // If we are resizing vertically or the "width" property is set to + // null, we remove the CSS width variable. The width of the element + // is left to be determined by the CSS stylesheet rules. + this.parentNode.style.removeProperty(this._cssName.width); + } else { + this.parentNode.style.setProperty(this._cssName.width, `${width}px`); + } + this.resizeElement.classList.toggle( + "collapsed-by-splitter", + this.isCollapsed + ); + this.classList.toggle("splitter-collapsed", this.isCollapsed); + this.classList.toggle("splitter-before", this._beforeElement); + } + + handleEvent(event) { + switch (event.type) { + case "mousedown": + this._onMouseDown(event); + break; + case "mousemove": + this._onMouseMove(event); + break; + case "mouseup": + this._onMouseUp(event); + break; + } + } + + _onMouseDown(event) { + if (!this.resizeElement || this.isDisabled) { + return; + } + if (event.buttons != 1) { + return; + } + + let vertical = this.resizeDirection == "vertical"; + let collapseSize = + Number( + this.getAttribute(vertical ? "collapse-height" : "collapse-width") + ) || 0; + let ltrDir = this.parentNode.matches(":dir(ltr)"); + + this._dragStartInfo = { + wasCollapsed: this.isCollapsed, + // Whether this will resize vertically. + vertical, + pos: vertical ? event.clientY : event.clientX, + // Whether decreasing X/Y should increase the size. + negative: vertical + ? this._beforeElement + : this._beforeElement == ltrDir, + size: this._getActualResizeSize(), + collapseSize, + }; + + event.preventDefault(); + window.addEventListener("mousemove", this); + window.addEventListener("mouseup", this); + // Block all other pointer events whilst resizing. This ensures we don't + // trigger any styling or other effects whilst resizing. This also ensures + // that the MouseEvent's clientX and clientY will always be relative to + // the current window, rather than some ancestor xul:browser's window. + document.documentElement.style.pointerEvents = "none"; + this._updateDragCursor(); + this.classList.add("splitter-resizing"); + } + + _updateDragCursor() { + if (!this._dragStartInfo) { + return; + } + let cursor; + let { vertical, negative } = this._dragStartInfo; + if (this.isCollapsed) { + if (vertical) { + cursor = negative ? "n-resize" : "s-resize"; + } else { + cursor = negative ? "w-resize" : "e-resize"; + } + } else { + cursor = vertical ? "ns-resize" : "ew-resize"; + } + document.documentElement.style.cursor = cursor; + } + + /** + * If `mousemove` events will be ignored because the screen hasn't been + * updated since the last one. + * + * @type {boolean} + */ + _mouseMoveBlocked = false; + + _onMouseMove(event) { + if (event.buttons != 1) { + // The button was released and we didn't get a mouseup event (e.g. + // releasing the mouse above a disabled html:button), or the + // button(s) pressed changed. Either way, stop dragging. + this.endResize(); + return; + } + + event.preventDefault(); + + // Ensure the expensive part of this function runs no more than once + // per frame. Doing it more frequently is just wasting CPU time. + if (this._mouseMoveBlocked) { + return; + } + this._mouseMoveBlocked = true; + requestAnimationFrame(() => (this._mouseMoveBlocked = false)); + + let { wasCollapsed, vertical, negative, pos, size, collapseSize } = + this._dragStartInfo; + + let delta = (vertical ? event.clientY : event.clientX) - pos; + if (negative) { + delta *= -1; + } + + if (!this._started) { + if (Math.abs(delta) < 3) { + return; + } + this._started = true; + this.dispatchEvent( + new CustomEvent("splitter-resizing", { bubbles: true }) + ); + } + + size += delta; + if (collapseSize) { + let pastCollapseThreshold = size < collapseSize - 20; + if (wasCollapsed) { + if (!pastCollapseThreshold) { + this._dragStartInfo.wasCollapsed = false; + } + pastCollapseThreshold = size < 20; + } + + if (pastCollapseThreshold) { + this.collapse(); + return; + } + + this.expand(); + size = Math.max(size, collapseSize); + } + this._updateSize(Math.max(0, size)); + } + + _onMouseUp(event) { + event.preventDefault(); + this.endResize(); + } + + /** + * Stop the resizing operation if it is currently active. + */ + endResize() { + if (!this._dragStartInfo) { + return; + } + let didStart = this._started; + + delete this._dragStartInfo; + delete this._started; + + window.removeEventListener("mousemove", this); + window.removeEventListener("mouseup", this); + document.documentElement.style.pointerEvents = null; + document.documentElement.style.cursor = null; + this.classList.remove("splitter-resizing"); + + // Make sure our property corresponds to the actual final size. + this._updateSize(); + + if (didStart) { + this.dispatchEvent( + new CustomEvent("splitter-resized", { bubbles: true }) + ); + } + } + } + customElements.define("pane-splitter", PaneSplitter, { extends: "hr" }); +} diff --git a/comm/mail/base/content/widgets/statuspanel.js b/comm/mail/base/content/widgets/statuspanel.js new file mode 100644 index 0000000000..8d30ea4697 --- /dev/null +++ b/comm/mail/base/content/widgets/statuspanel.js @@ -0,0 +1,78 @@ +/** + * 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 MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + class MozStatuspanel extends MozXULElement { + static get observedAttributes() { + return ["label", "mirror"]; + } + + connectedCallback() { + const hbox = document.createXULElement("hbox"); + hbox.classList.add("statuspanel-inner"); + + const label = document.createXULElement("label"); + label.classList.add("statuspanel-label"); + label.setAttribute("flex", "1"); + label.setAttribute("crop", "end"); + + hbox.appendChild(label); + this.appendChild(hbox); + + this._labelElement = label; + + this._updateAttributes(); + this._setupEventListeners(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + set label(val) { + if (!this.label) { + this.removeAttribute("mirror"); + } + this.setAttribute("label", val); + } + + get label() { + return this.getAttribute("label"); + } + + _updateAttributes() { + if (!this._labelElement) { + return; + } + + if (this.hasAttribute("label")) { + this._labelElement.setAttribute("value", this.getAttribute("label")); + } else { + this._labelElement.removeAttribute("value"); + } + + if (this.hasAttribute("mirror")) { + this._labelElement.setAttribute("mirror", this.getAttribute("mirror")); + } else { + this._labelElement.removeAttribute("mirror"); + } + } + + _setupEventListeners() { + this.addEventListener("mouseover", event => { + if (this.hasAttribute("mirror")) { + this.removeAttribute("mirror"); + } else { + this.setAttribute("mirror", "true"); + } + }); + } + } + + customElements.define("statuspanel", MozStatuspanel); +} diff --git a/comm/mail/base/content/widgets/tabmail-tab.js b/comm/mail/base/content/widgets/tabmail-tab.js new file mode 100644 index 0000000000..7de115149b --- /dev/null +++ b/comm/mail/base/content/widgets/tabmail-tab.js @@ -0,0 +1,179 @@ +/* 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/. */ + +"use strict"; + +/* global MozElements, MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozTabmailTab widget behaves as a tab in the messenger window. + * It is used to navigate between different views. It displays information + * about the view: i.e. name and icon. + * + * @augments {MozElements.MozTab} + */ + class MozTabmailTab extends MozElements.MozTab { + static get inheritedAttributes() { + return { + ".tab-background": "pinned,selected,titlechanged", + ".tab-line": "selected=visuallyselected", + ".tab-content": "pinned,selected,titlechanged,title=label", + ".tab-throbber": "fadein,pinned,busy,progress,selected", + ".tab-icon-image": "fadein,pinned,selected", + ".tab-label-container": "pinned,selected=visuallyselected", + ".tab-text": "text=label,accesskey,fadein,pinned,selected", + ".tab-close-button": "fadein,pinned,selected=visuallyselected", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "tabmail-tab"); + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <stack class="tab-stack" flex="1"> + <vbox class="tab-background"> + <hbox class="tab-line"></hbox> + </vbox> + <html:div class="tab-content"> + <hbox class="tab-throbber" role="presentation"></hbox> + <html:img class="tab-icon-image" alt="" role="presentation" /> + <hbox class="tab-label-container" + onoverflow="this.setAttribute('textoverflow', 'true');" + onunderflow="this.removeAttribute('textoverflow');" + flex="1"> + <label class="tab-text tab-label" role="presentation"></label> + </hbox> + <!-- We make the button non-focusable, otherwise each close + - button creates a new tab stop. See bug 1754097 --> + <html:button class="plain-button tab-close-button" + tabindex="-1" + title="&closeTab.label;"> + <!-- Button title should provide the accessible context. --> + <html:img class="tab-close-icon" alt="" + src="chrome://global/skin/icons/close.svg" /> + </html:button> + </html:div> + </stack> + `, + ["chrome://messenger/locale/tabmail.dtd"] + ) + ); + + this.addEventListener( + "dragstart", + event => { + document.dragTab = this; + }, + true + ); + + this.addEventListener( + "dragover", + event => { + document.dragTab = null; + }, + true + ); + + let closeButton = this.querySelector(".tab-close-button"); + + // Prevent switching to the tab before closing it by stopping the + // mousedown event. + closeButton.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + event.stopPropagation(); + }); + + closeButton.addEventListener("click", () => + document.getElementById("tabmail").removeTabByNode(this) + ); + + // Middle mouse button click on the tab also closes it. + this.addEventListener("click", event => { + if (event.button != 1) { + return; + } + document.getElementById("tabmail").removeTabByNode(this); + }); + + this.setAttribute("context", "tabContextMenu"); + + this.mCorrespondingMenuitem = null; + + this.initializeAttributeInheritance(); + } + + get linkedBrowser() { + let tabmail = document.getElementById("tabmail"); + let tab = tabmail._getTabContextForTabbyThing(this, false)[1]; + return tabmail.getBrowserForTab(tab); + } + + get mode() { + let tabmail = document.getElementById("tabmail"); + let tab = tabmail._getTabContextForTabbyThing(this, false)[1]; + return tab.mode; + } + + /** + * Set the displayed icon for the tab. + * + * If a fallback source if given, it will be used instead if the given icon + * source is missing or loads with an error. + * + * If both sources are null, then the icon will become invisible. + * + * @param {string|null} iconSrc - The icon source to display in the tab, or + * null to just use the fallback source. + * @param {?string} [fallbackSrc] - The fallback source to display if the + * iconSrc is missing or broken. + */ + setIcon(iconSrc, fallbackSrc) { + let icon = this.querySelector(".tab-icon-image"); + if (!fallbackSrc) { + if (iconSrc) { + icon.setAttribute("src", iconSrc); + } else { + icon.removeAttribute("src"); + } + return; + } + if (!iconSrc) { + icon.setAttribute("src", fallbackSrc); + return; + } + if (iconSrc == icon.getAttribute("src")) { + return; + } + + // Set the tab image, and use the fallback if an error occurs. + // Set up a one time listener for either error or load. + let listener = event => { + icon.removeEventListener("error", listener); + icon.removeEventListener("load", listener); + if (event.type == "error") { + icon.setAttribute("src", fallbackSrc); + } + }; + icon.addEventListener("error", listener); + icon.addEventListener("load", listener); + icon.setAttribute("src", iconSrc); + } + } + + MozXULElement.implementCustomInterface(MozTabmailTab, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("tabmail-tab", MozTabmailTab, { extends: "tab" }); +} diff --git a/comm/mail/base/content/widgets/tabmail-tabs.js b/comm/mail/base/content/widgets/tabmail-tabs.js new file mode 100644 index 0000000000..004a60122d --- /dev/null +++ b/comm/mail/base/content/widgets/tabmail-tabs.js @@ -0,0 +1,723 @@ +/* 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/. */ + +"use strict"; + +/* global MozElements, MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + /** + * The MozTabs widget holds all the tabs for the main tab UI. + * + * @augments {MozTabs} + */ + class MozTabmailTabs extends customElements.get("tabs") { + constructor() { + super(); + + this.addEventListener("dragstart", event => { + let draggedTab = this._getDragTargetTab(event); + + if (!draggedTab) { + return; + } + + let tab = this.tabmail.selectedTab; + + if (!tab || !tab.canClose) { + return; + } + + let dt = event.dataTransfer; + + // If we drag within the same window, we use the tab directly + dt.mozSetDataAt("application/x-moz-tabmail-tab", draggedTab, 0); + + // Otherwise we use session restore & JSON to migrate the tab. + let uri = this.tabmail.persistTab(tab); + + // In case the tab implements session restore, we use JSON to convert + // it into a string. + // + // If a tab does not support session restore it returns null. We can't + // moved such tabs to a new window. However moving them within the same + // window works perfectly fine. + if (uri) { + uri = JSON.stringify(uri); + } + + dt.mozSetDataAt("application/x-moz-tabmail-json", uri, 0); + + dt.mozCursor = "default"; + + // Create Drag Image. + let panel = document.getElementById("tabpanelcontainer"); + + let thumbnail = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + thumbnail.width = Math.ceil(screen.availWidth / 5.75); + thumbnail.height = Math.round(thumbnail.width * 0.5625); + + let snippetWidth = panel.getBoundingClientRect().width * 0.6; + let scale = thumbnail.width / snippetWidth; + + let ctx = thumbnail.getContext("2d"); + + ctx.scale(scale, scale); + + ctx.drawWindow( + window, + panel.screenX - window.mozInnerScreenX, + panel.screenY - window.mozInnerScreenY, + snippetWidth, + snippetWidth * 0.5625, + "rgb(255,255,255)" + ); + + dt = event.dataTransfer; + dt.setDragImage(thumbnail, 0, 0); + + event.stopPropagation(); + }); + + this.addEventListener("dragover", event => { + let dt = event.dataTransfer; + + if (dt.mozItemCount == 0) { + return; + } + + // Bug 516247: + // in case the user is dragging something else than a tab, and + // keeps hovering over a tab, we assume he wants to switch to this tab. + if ( + dt.mozTypesAt(0)[0] != "application/x-moz-tabmail-tab" && + dt.mozTypesAt(0)[1] != "application/x-moz-tabmail-json" + ) { + let tab = this._getDragTargetTab(event); + + if (!tab) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (!this._dragTime) { + this._dragTime = Date.now(); + return; + } + + if (Date.now() <= this._dragTime + this._dragOverDelay) { + return; + } + + if (this.tabmail.tabContainer.selectedItem == tab) { + return; + } + + this.tabmail.tabContainer.selectedItem = tab; + + return; + } + + // As some tabs do not support session restore they can't be + // moved to a different or new window. We should not show + // a dropmarker in such a case. + if (!dt.mozGetDataAt("application/x-moz-tabmail-json", 0)) { + let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0); + + if (!draggedTab) { + return; + } + + if (this.tabmail.tabContainer.getIndexOfItem(draggedTab) == -1) { + return; + } + } + + dt.effectAllowed = "copyMove"; + + event.preventDefault(); + event.stopPropagation(); + + let ltr = window.getComputedStyle(this).direction == "ltr"; + let ind = this._tabDropIndicator; + let arrowScrollbox = this.arrowScrollbox; + + // Let's scroll + let pixelsToScroll = 0; + if (arrowScrollbox.getAttribute("overflow") == "true") { + switch (event.target) { + case arrowScrollbox._scrollButtonDown: + pixelsToScroll = arrowScrollbox.scrollIncrement * -1; + break; + case arrowScrollbox._scrollButtonUp: + pixelsToScroll = arrowScrollbox.scrollIncrement; + break; + } + + if (ltr) { + pixelsToScroll = pixelsToScroll * -1; + } + + if (pixelsToScroll) { + // Hide Indicator while Scrolling + ind.hidden = true; + arrowScrollbox.scrollByPixels(pixelsToScroll); + return; + } + } + + let newIndex = this._getDropIndex(event); + + // Fix the DropIndex in case it points to tab that can't be closed. + let tabInfo = this.tabmail.tabInfo; + + while (newIndex < tabInfo.length && !tabInfo[newIndex].canClose) { + newIndex++; + } + + let scrollRect = this.arrowScrollbox.scrollClientRect; + let rect = this.getBoundingClientRect(); + let minMargin = scrollRect.left - rect.left; + let maxMargin = Math.min( + minMargin + scrollRect.width, + scrollRect.right + ); + + if (!ltr) { + [minMargin, maxMargin] = [ + this.clientWidth - maxMargin, + this.clientWidth - minMargin, + ]; + } + + let newMargin; + let tabs = this.allTabs; + + if (newIndex == tabs.length) { + let tabRect = tabs[newIndex - 1].getBoundingClientRect(); + + if (ltr) { + newMargin = tabRect.right - rect.left; + } else { + newMargin = rect.right - tabRect.left; + } + } else { + let tabRect = tabs[newIndex].getBoundingClientRect(); + + if (ltr) { + newMargin = tabRect.left - rect.left; + } else { + newMargin = rect.right - tabRect.right; + } + } + + ind.hidden = false; + + newMargin -= ind.clientWidth / 2; + + ind.style.insetInlineStart = `${Math.round(newMargin)}px`; + }); + + this.addEventListener("drop", event => { + let dt = event.dataTransfer; + + if (dt.mozItemCount != 1) { + return; + } + + let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0); + + if (!draggedTab) { + return; + } + + event.stopPropagation(); + this._tabDropIndicator.hidden = true; + + // Is the tab one of our children? + if (this.tabmail.tabContainer.getIndexOfItem(draggedTab) == -1) { + // It's a tab from an other window, so we have to trigger session + // restore to get our tab + + let tabmail2 = draggedTab.ownerDocument.getElementById("tabmail"); + if (!tabmail2) { + return; + } + + let draggedJson = dt.mozGetDataAt( + "application/x-moz-tabmail-json", + 0 + ); + if (!draggedJson) { + return; + } + + draggedJson = JSON.parse(draggedJson); + + // Some tab exist only once, so we have to gamble a bit. We close + // the tab and try to reopen it. If something fails the tab is gone. + + tabmail2.closeTab(draggedTab, true); + + if (!this.tabmail.restoreTab(draggedJson)) { + return; + } + + draggedTab = + this.tabmail.tabContainer.allTabs[ + this.tabmail.tabContainer.allTabs.length - 1 + ]; + } + + let idx = this._getDropIndex(event); + + // Fix the DropIndex in case it points to tab that can't be closed + let tabInfo = this.tabmail.tabInfo; + while (idx < tabInfo.length && !tabInfo[idx].canClose) { + idx++; + } + + this.tabmail.moveTabTo(draggedTab, idx); + + this.tabmail.switchToTab(draggedTab); + this.tabmail.updateCurrentTab(); + }); + + this.addEventListener("dragend", event => { + // Note: while this case is correctly handled here, this event + // isn't dispatched when the tab is moved within the tabstrip, + // see bug 460801. + + // The user pressed ESC to cancel the drag, or the drag succeeded. + let dt = event.dataTransfer; + if (dt.mozUserCancelled || dt.dropEffect != "none") { + return; + } + + // Disable detach within the browser toolbox. + let eX = event.screenX; + let wX = window.screenX; + + // Check if the drop point is horizontally within the window. + if (eX > wX && eX < wX + window.outerWidth) { + let bo = this.arrowScrollbox; + // Also avoid detaching if the the tab was dropped too close to + // the tabbar (half a tab). + let endScreenY = bo.screenY + 1.5 * bo.getBoundingClientRect().height; + let eY = event.screenY; + + if (eY < endScreenY && eY > window.screenY) { + return; + } + } + + // User wants to deatach tab from window... + if (dt.mozItemCount != 1) { + return; + } + + let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0); + + if (!draggedTab) { + return; + } + + this.tabmail.replaceTabWithWindow(draggedTab); + }); + + this.addEventListener("dragleave", event => { + this._dragTime = 0; + + this._tabDropIndicator.hidden = true; + event.stopPropagation(); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + super.connectedCallback(); + + this.tabmail = document.getElementById("tabmail"); + + this.arrowScrollboxWidth = 0; + + this.arrowScrollbox = this.querySelector("arrowscrollbox"); + + this.mCollapseToolbar = document.getElementById( + this.getAttribute("collapsetoolbar") + ); + + // @implements {nsIObserver} + this._prefObserver = (subject, topic, data) => { + if (topic == "nsPref:changed") { + subject.QueryInterface(Ci.nsIPrefBranch); + if (data == "mail.tabs.autoHide") { + this.mAutoHide = subject.getBoolPref("mail.tabs.autoHide"); + } + } + }; + + this._tabDropIndicator = this.querySelector(".tab-drop-indicator"); + + this._dragOverDelay = 350; + + this._dragTime = 0; + + this._mAutoHide = false; + + this.mAllTabsButton = document.getElementById( + this.getAttribute("alltabsbutton") + ); + this.mAllTabsPopup = this.mAllTabsButton.menu; + + this.mDownBoxAnimate = this.arrowScrollbox; + + this._animateTimer = null; + + this._animateStep = -1; + + this._animateDelay = 25; + + this._animatePercents = [ + 1.0, 0.85, 0.8, 0.75, 0.71, 0.68, 0.65, 0.62, 0.59, 0.57, 0.54, 0.52, + 0.5, 0.47, 0.45, 0.44, 0.42, 0.4, 0.38, 0.37, 0.35, 0.34, 0.32, 0.31, + 0.3, 0.29, 0.28, 0.27, 0.26, 0.25, 0.24, 0.23, 0.23, 0.22, 0.22, 0.21, + 0.21, 0.21, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.19, 0.19, 0.19, + 0.18, 0.18, 0.17, 0.17, 0.16, 0.15, 0.14, 0.13, 0.11, 0.09, 0.06, + ]; + + this.mTabMinWidth = Services.prefs.getIntPref("mail.tabs.tabMinWidth"); + this.mTabMaxWidth = Services.prefs.getIntPref("mail.tabs.tabMaxWidth"); + this.mTabClipWidth = Services.prefs.getIntPref("mail.tabs.tabClipWidth"); + this.mAutoHide = Services.prefs.getBoolPref("mail.tabs.autoHide"); + + if (this.mAutoHide) { + this.mCollapseToolbar.collapsed = true; + document.documentElement.setAttribute("tabbarhidden", "true"); + } + + this._updateCloseButtons(); + + Services.prefs.addObserver("mail.tabs.", this._prefObserver); + + window.addEventListener("resize", this); + + // Listen to overflow/underflow events on the tabstrip, + // we cannot put these as xbl handlers on the entire binding because + // they would also get called for the all-tabs popup scrollbox. + // Also, we can't rely on event.target because these are all + // anonymous nodes. + this.arrowScrollbox.shadowRoot.addEventListener("overflow", this); + this.arrowScrollbox.shadowRoot.addEventListener("underflow", this); + + this.addEventListener("select", event => { + this._handleTabSelect(); + + if ( + !("updateCurrentTab" in this.tabmail) || + event.target.localName != "tabs" + ) { + return; + } + + this.tabmail.updateCurrentTab(); + }); + + this.addEventListener("TabSelect", event => { + this._handleTabSelect(); + }); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_tabMinWidthPref", + "mail.tabs.tabMinWidth", + null, + (pref, prevValue, newValue) => (this._tabMinWidth = newValue), + newValue => { + const LIMIT = 50; + return Math.max(newValue, LIMIT); + } + ); + this._tabMinWidth = this._tabMinWidthPref; + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_tabMaxWidthPref", + "mail.tabs.tabMaxWidth", + null, + (pref, prevValue, newValue) => (this._tabMaxWidth = newValue) + ); + this._tabMaxWidth = this._tabMaxWidthPref; + } + + get tabbox() { + return document.getElementById("tabmail-tabbox"); + } + + // Accessor for tabs. + get allTabs() { + if (!this.arrowScrollbox) { + return []; + } + + return Array.from(this.arrowScrollbox.children); + } + + appendChild(tab) { + return this.insertBefore(tab, null); + } + + insertBefore(tab, node) { + if (!this.arrowScrollbox) { + return; + } + + if (node == null) { + this.arrowScrollbox.appendChild(tab); + return; + } + + this.arrowScrollbox.insertBefore(tab, node); + } + + set mAutoHide(val) { + if (val != this._mAutoHide) { + if (this.allTabs.length == 1) { + this.mCollapseToolbar.collapsed = val; + } + this._mAutoHide = val; + } + } + + get mAutoHide() { + return this._mAutoHide; + } + + set selectedIndex(val) { + let tab = this.getItemAtIndex(val); + let alreadySelected = tab && tab.selected; + + this.__proto__.__proto__ + .__lookupSetter__("selectedIndex") + .call(this, val); + + if (!alreadySelected) { + // Fire an onselect event for the tabs element. + let event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + } + } + + get selectedIndex() { + return this.__proto__.__proto__ + .__lookupGetter__("selectedIndex") + .call(this); + } + + _updateCloseButtons() { + let width = + this.arrowScrollbox.firstElementChild.getBoundingClientRect().width; + // 0 width is an invalid value and indicates + // an item without display, so ignore. + if (width > this.mTabClipWidth || width == 0) { + this.setAttribute("closebuttons", "alltabs"); + } else { + this.setAttribute("closebuttons", "activetab"); + } + } + + _handleTabSelect() { + this.arrowScrollbox.ensureElementIsVisible(this.selectedItem); + } + + handleEvent(aEvent) { + let alltabsButton = document.getElementById("alltabs-button"); + + switch (aEvent.type) { + case "overflow": + this.arrowScrollbox.ensureElementIsVisible(this.selectedItem); + + // filter overflow events which were dispatched on nested scrollboxes + // and ignore vertical events. + if ( + aEvent.target != this.arrowScrollbox.scrollbox || + aEvent.detail == 0 + ) { + return; + } + + this.arrowScrollbox.setAttribute("overflow", "true"); + alltabsButton.removeAttribute("hidden"); + break; + case "underflow": + // filter underflow events which were dispatched on nested scrollboxes + // and ignore vertical events. + if ( + aEvent.target != this.arrowScrollbox.scrollbox || + aEvent.detail == 0 + ) { + return; + } + + this.arrowScrollbox.removeAttribute("overflow"); + alltabsButton.setAttribute("hidden", "true"); + break; + case "resize": + let width = this.arrowScrollbox.getBoundingClientRect().width; + if (width != this.arrowScrollboxWidth) { + this._updateCloseButtons(); + // XXX without this line the tab bar won't budge + this.arrowScrollbox.scrollByPixels(1); + this._handleTabSelect(); + this.arrowScrollboxWidth = width; + } + break; + } + } + + _stopAnimation() { + if (this._animateStep != -1) { + if (this._animateTimer) { + this._animateTimer.cancel(); + } + + this._animateStep = -1; + this.mAllTabsBoxAnimate.style.opacity = 0.0; + this.mDownBoxAnimate.style.opacity = 0.0; + } + } + + _notifyBackgroundTab(aTab) { + let tsbo = this.arrowScrollbox; + let tsboStart = tsbo.screenX; + let tsboEnd = tsboStart + tsbo.getBoundingClientRect().width; + + let ctboStart = aTab.screenX; + let ctboEnd = ctboStart + aTab.getBoundingClientRect().width; + + // only start the flash timer if the new tab (which was loaded in + // the background) is not completely visible + if (tsboStart > ctboStart || ctboEnd > tsboEnd) { + this._animateStep = 0; + + if (!this._animateTimer) { + this._animateTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + } else { + this._animateTimer.cancel(); + } + + this._animateTimer.initWithCallback( + this, + this._animateDelay, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + } + } + + notify(aTimer) { + if (!document) { + aTimer.cancel(); + } + + let percent = this._animatePercents[this._animateStep]; + this.mAllTabsBoxAnimate.style.opacity = percent; + this.mDownBoxAnimate.style.opacity = percent; + + if (this._animateStep < this._animatePercents.length - 1) { + this._animateStep++; + } else { + this._stopAnimation(); + } + } + + _getDragTargetTab(event) { + let tab = event.target; + while (tab && tab.localName != "tab") { + tab = tab.parentNode; + } + + if (!tab) { + return null; + } + + if (event.type != "drop" && event.type != "dragover") { + return tab; + } + + let tabRect = tab.getBoundingClientRect(); + if (event.screenX < tab.screenX + tabRect.width * 0.25) { + return null; + } + + if (event.screenX > tab.screenX + tabRect.width * 0.75) { + return null; + } + + return tab; + } + + _getDropIndex(event) { + let tabs = this.allTabs; + + if (window.getComputedStyle(this).direction == "ltr") { + for (let i = 0; i < tabs.length; i++) { + if ( + event.screenX < + tabs[i].screenX + tabs[i].getBoundingClientRect().width / 2 + ) { + return i; + } + } + } else { + for (let i = 0; i < tabs.length; i++) { + if ( + event.screenX > + tabs[i].screenX + tabs[i].getBoundingClientRect().width / 2 + ) { + return i; + } + } + } + + return tabs.length; + } + + set _tabMinWidth(val) { + this.arrowScrollbox.style.setProperty("--tab-min-width", `${val}px`); + } + set _tabMaxWidth(val) { + this.arrowScrollbox.style.setProperty("--tab-max-width", `${val}px`); + } + + disconnectedCallback() { + Services.prefs.removeObserver("mail.tabs.", this._prefObserver); + + // Release timer to avoid reference cycles. + if (this._animateTimer) { + this._animateTimer.cancel(); + this._animateTimer = null; + } + + this.arrowScrollbox.shadowRoot.removeEventListener("overflow", this); + this.arrowScrollbox.shadowRoot.removeEventListener("underflow", this); + } + } + + MozXULElement.implementCustomInterface(MozTabmailTabs, [Ci.nsITimerCallback]); + customElements.define("tabmail-tabs", MozTabmailTabs, { extends: "tabs" }); +} diff --git a/comm/mail/base/content/widgets/toolbarContext.inc.xhtml b/comm/mail/base/content/widgets/toolbarContext.inc.xhtml new file mode 100644 index 0000000000..c6a1c415a8 --- /dev/null +++ b/comm/mail/base/content/widgets/toolbarContext.inc.xhtml @@ -0,0 +1,19 @@ +# 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/. + +<menupopup id="toolbar-context-menu" + onpopupshowing="calendarOnToolbarsPopupShowing(event); ToolbarContextMenu.updateExtension(this);"> + <menuseparator id="customizeMailToolbarMenuSeparator"/> + <menuitem id="CustomizeMailToolbar" + command="cmd_CustomizeMailToolbar" + 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> diff --git a/comm/mail/base/content/widgets/toolbarbutton-menu-button.js b/comm/mail/base/content/widgets/toolbarbutton-menu-button.js new file mode 100644 index 0000000000..c514aa7357 --- /dev/null +++ b/comm/mail/base/content/widgets/toolbarbutton-menu-button.js @@ -0,0 +1,80 @@ +/* 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/. */ + +"use strict"; + +/* global MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozToolbarButtonMenuButton widget is a toolbarbutton with + * type="menu". Place a menupopup element inside the button to create + * the menu popup. When the dropmarker in the toobarbutton is pressed the + * menupopup will open. When clicking the main area of the button it works + * like a normal toolbarbutton. + * + * @augments MozToolbarbutton + */ + class MozToolbarButtonMenuButton extends customElements.get("toolbarbutton") { + static get inheritedAttributes() { + return { + ...super.inheritedAttributes, + ".toolbarbutton-menubutton-button": + "command,hidden,disabled,align,dir,pack,orient,label,wrap,tooltiptext=buttontooltiptext", + ".toolbarbutton-menubutton-dropmarker": "open,disabled", + }; + } + static get menubuttonFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <toolbarbutton class="box-inherit toolbarbutton-menubutton-button" + flex="1" + allowevents="true"></toolbarbutton> + <dropmarker type="menu" + class="toolbarbutton-menubutton-dropmarker"></dropmarker> + `), + true + ); + Object.defineProperty(this, "menubuttonFragment", { value: frag }); + return frag; + } + + /** @override */ + get _hasConnected() { + return ( + this.querySelector(":scope > toolbarbutton > .toolbarbutton-text") != + null + ); + } + + /** @override */ + render() { + this.appendChild(this.constructor.menubuttonFragment.cloneNode(true)); + this.initializeAttributeInheritance(); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this._hasConnected) { + return; + } + + // Defer creating DOM elements for content inside popups. + // These will be added in the popupshown handler above. + let panel = this.closest("panel"); + if (panel && !panel.hasAttribute("hasbeenopened")) { + return; + } + this.setAttribute("is", "toolbarbutton-menu-button"); + this.setAttribute("type", "menu"); + + this.render(); + } + } + customElements.define( + "toolbarbutton-menu-button", + MozToolbarButtonMenuButton, + { extends: "toolbarbutton" } + ); +} diff --git a/comm/mail/base/content/widgets/tree-listbox.js b/comm/mail/base/content/widgets/tree-listbox.js new file mode 100644 index 0000000000..81d42ca72b --- /dev/null +++ b/comm/mail/base/content/widgets/tree-listbox.js @@ -0,0 +1,914 @@ +/* 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/. */ + +{ + // Animation variables for expanding and collapsing child lists. + const ANIMATION_DURATION_MS = 200; + const ANIMATION_EASING = "ease"; + let reducedMotionMedia = matchMedia("(prefers-reduced-motion)"); + + /** + * Provides keyboard and mouse interaction to a (possibly nested) list. + * It is intended for lists with a small number (up to 1000?) of items. + * Only one item can be selected at a time. Maintenance of the items in the + * list is not managed here. Styling of the list is not managed here. + * + * The following class names apply to list items: + * - selected: Indicates the currently selected list item. + * - children: If the list item has descendants. + * - collapsed: If the list item's descendants are hidden. + * + * List items can provide their own twisty element, which will operate when + * clicked on if given the class name "twisty". + * + * This class fires "collapsed", "expanded" and "select" events. + */ + let TreeListboxMixin = Base => + class extends Base { + /** + * The selected and focused item, or null if there is none. + * + * @type {?HTMLLIElement} + */ + _selectedRow = null; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-listbox"); + switch (this.getAttribute("role")) { + case "tree": + this.isTree = true; + break; + case "listbox": + this.isTree = false; + break; + default: + throw new RangeError( + `Unsupported role ${this.getAttribute("role")}` + ); + } + this.tabIndex = 0; + + this.domChanged(); + this._initRows(); + let rows = this.rows; + if (!this.selectedRow && rows.length) { + // TODO: This should only really happen on "focus". + this.selectedRow = rows[0]; + } + + this.addEventListener("click", this); + this.addEventListener("keydown", this); + this._mutationObserver.observe(this, { + subtree: true, + childList: true, + }); + } + + handleEvent(event) { + switch (event.type) { + case "click": + this._onClick(event); + break; + case "keydown": + this._onKeyDown(event); + break; + } + } + + _onClick(event) { + if (event.button !== 0) { + return; + } + + let row = event.target.closest("li:not(.unselectable)"); + if (!row) { + return; + } + + if ( + row.classList.contains("children") && + (event.target.closest(".twisty") || event.detail == 2) + ) { + if (row.classList.contains("collapsed")) { + this.expandRow(row); + } else { + this.collapseRow(row); + } + return; + } + + this.selectedRow = row; + if (document.activeElement != this) { + // Overflowing elements with tabindex=-1 steal focus. Grab it back. + this.focus(); + } + } + + _onKeyDown(event) { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + + switch (event.key) { + case "ArrowUp": + this.selectedIndex = this._clampIndex(this.selectedIndex - 1); + break; + case "ArrowDown": + this.selectedIndex = this._clampIndex(this.selectedIndex + 1); + break; + case "Home": + this.selectedIndex = 0; + break; + case "End": + this.selectedIndex = this.rowCount - 1; + break; + case "PageUp": { + if (!this.selectedRow) { + break; + } + // Get the top of the selected row, and remove the page height. + let selectedBox = this.selectedRow.getBoundingClientRect(); + let y = selectedBox.top - this.clientHeight; + + // Find the last row below there. + let rows = this.rows; + let i = this.selectedIndex - 1; + while (i > 0 && rows[i].getBoundingClientRect().top >= y) { + i--; + } + this.selectedIndex = i; + break; + } + case "PageDown": { + if (!this.selectedRow) { + break; + } + // Get the top of the selected row, and add the page height. + let selectedBox = this.selectedRow.getBoundingClientRect(); + let y = selectedBox.top + this.clientHeight; + + // Find the last row below there. + let rows = this.rows; + let i = rows.length - 1; + while ( + i > this.selectedIndex && + rows[i].getBoundingClientRect().top >= y + ) { + i--; + } + this.selectedIndex = i; + break; + } + case "ArrowLeft": + case "ArrowRight": { + let selected = this.selectedRow; + if (!selected) { + break; + } + + let isArrowRight = event.key == "ArrowRight"; + let isRTL = this.matches(":dir(rtl)"); + if (isArrowRight == isRTL) { + let parent = selected.parentNode.closest( + ".children:not(.unselectable)" + ); + if ( + parent && + (!selected.classList.contains("children") || + selected.classList.contains("collapsed")) + ) { + this.selectedRow = parent; + break; + } + if (selected.classList.contains("children")) { + this.collapseRow(selected); + } + } else if (selected.classList.contains("children")) { + if (selected.classList.contains("collapsed")) { + this.expandRow(selected); + } else { + this.selectedRow = selected.querySelector("li"); + } + } + break; + } + case "Enter": { + const selected = this.selectedRow; + if (!selected?.classList.contains("children")) { + return; + } + if (selected.classList.contains("collapsed")) { + this.expandRow(selected); + } else { + this.collapseRow(selected); + } + break; + } + default: + return; + } + + event.preventDefault(); + } + + /** + * Data for the rows in the DOM. + * + * @typedef {object} TreeRowData + * @property {HTMLLIElement} row - The row item. + * @property {HTMLLIElement[]} ancestors - The ancestors of the row, + * ordered closest to furthest away. + */ + + /** + * Data for all items beneath this node, including collapsed items, + * ordered as they are in the DOM. + * + * @type {TreeRowData[]} + */ + _rowsData = []; + + /** + * Call whenever the tree nodes or ordering changes. This should only be + * called externally if the mutation observer has been dis-connected and + * re-connected. + */ + domChanged() { + this._rowsData = Array.from(this.querySelectorAll("li"), row => { + let ancestors = []; + for ( + let parentRow = row.parentNode.closest("li"); + this.contains(parentRow); + parentRow = parentRow.parentNode.closest("li") + ) { + ancestors.push(parentRow); + } + return { row, ancestors }; + }); + } + + _mutationObserver = new MutationObserver(mutations => { + for (let mutation of mutations) { + for (let node of mutation.addedNodes) { + if (node.nodeType != Node.ELEMENT_NODE || !node.matches("li")) { + continue; + } + // No item can already be selected on addition. + node.classList.remove("selected"); + } + } + let oldRowsData = this._rowsData; + this.domChanged(); + this._initRows(); + let newRows = this.rows; + if (!newRows.length) { + this.selectedRow = null; + return; + } + if (!this.selectedRow) { + // TODO: This should only really happen on "focus". + this.selectedRow = newRows[0]; + return; + } + if (newRows.includes(this.selectedRow)) { + // Selected row is still visible. + return; + } + let oldSelectedIndex = oldRowsData.findIndex( + entry => entry.row == this.selectedRow + ); + if (oldSelectedIndex < 0) { + // Unexpected, the selectedRow was not in our _rowsData list. + this.selectedRow = newRows[0]; + return; + } + // Find the closest ancestor that is still shown. + let existingAncestor = oldRowsData[oldSelectedIndex].ancestors.find( + row => newRows.includes(row) + ); + if (existingAncestor) { + // We search as if the existingAncestor is the full list. This keeps + // the selection within the ancestor, or moves it to the ancestor if + // no child is found. + // NOTE: Includes existingAncestor itself, so should be non-empty. + newRows = newRows.filter(row => existingAncestor.contains(row)); + } + // We have lost the selectedRow, so we select a new row. We want to try + // and find the element that exists both in the new rows and in the old + // rows, that directly preceded the previously selected row. We then + // want to select the next visible row that follows this found element + // in the new rows. + // If rows were replaced with new rows, this will select the first of + // the new rows. + // If rows were simply removed, this will select the next row that was + // not removed. + let beforeIndex = -1; + for (let i = oldSelectedIndex; i >= 0; i--) { + beforeIndex = this._rowsData.findIndex( + entry => entry.row == oldRowsData[i].row + ); + if (beforeIndex >= 0) { + break; + } + } + // Start from just after the found item, or 0 if none were found + // (beforeIndex == -1), find the next visible item. Otherwise we default + // to selecting the last row. + let selectRow = newRows[newRows.length - 1]; + for (let i = beforeIndex + 1; i < this._rowsData.length; i++) { + if (newRows.includes(this._rowsData[i].row)) { + selectRow = this._rowsData[i].row; + break; + } + } + this.selectedRow = selectRow; + }); + + /** + * Set the role attribute and classes for all descendants of the widget. + */ + _initRows() { + let descendantItems = this.querySelectorAll("li"); + let descendantLists = this.querySelectorAll("ol, ul"); + + for (let i = 0; i < descendantItems.length; i++) { + let row = descendantItems[i]; + row.setAttribute("role", this.isTree ? "treeitem" : "option"); + if ( + i + 1 < descendantItems.length && + row.contains(descendantItems[i + 1]) + ) { + row.classList.add("children"); + if (this.isTree) { + row.setAttribute( + "aria-expanded", + !row.classList.contains("collapsed") + ); + } + } else { + row.classList.remove("children"); + row.classList.remove("collapsed"); + row.removeAttribute("aria-expanded"); + } + row.setAttribute("aria-selected", row.classList.contains("selected")); + } + + if (this.isTree) { + for (let list of descendantLists) { + list.setAttribute("role", "group"); + } + } + + for (let childList of this.querySelectorAll( + "li.collapsed > :is(ol, ul)" + )) { + childList.style.height = "0"; + } + } + + /** + * Every visible row. Rows with collapsed ancestors are not included. + * + * @type {HTMLLIElement[]} + */ + get rows() { + return [...this.querySelectorAll("li:not(.unselectable)")].filter( + row => { + let collapsed = row.parentNode.closest("li.collapsed"); + if (collapsed && this.contains(collapsed)) { + return false; + } + return true; + } + ); + } + + /** + * The number of visible rows. + * + * @type {integer} + */ + get rowCount() { + return this.rows.length; + } + + /** + * Clamps `index` to a value between 0 and `rowCount - 1`. + * + * @param {integer} index + * @returns {integer} + */ + _clampIndex(index) { + if (index >= this.rowCount) { + return this.rowCount - 1; + } + if (index < 0) { + return 0; + } + return index; + } + + /** + * Ensures that the row at `index` is on the screen. + * + * @param {integer} index + */ + scrollToIndex(index) { + this.getRowAtIndex(index)?.scrollIntoView({ block: "nearest" }); + } + + /** + * Returns the row element at `index` or null if `index` is out of range. + * + * @param {integer} index + * @returns {HTMLLIElement?} + */ + getRowAtIndex(index) { + return this.rows[index]; + } + + /** + * The index of the selected row. If there are no rows, the value is -1. + * Otherwise, should always have a value between 0 and `rowCount - 1`. + * It is set to 0 in `connectedCallback` if there are rows. + * + * @type {integer} + */ + get selectedIndex() { + return this.rows.findIndex(row => row == this.selectedRow); + } + + set selectedIndex(index) { + index = this._clampIndex(index); + this.selectedRow = this.getRowAtIndex(index); + } + + /** + * The selected and focused item, or null if there is none. + * + * @type {?HTMLLIElement} + */ + get selectedRow() { + return this._selectedRow; + } + + set selectedRow(row) { + if (row == this._selectedRow) { + return; + } + + if (this._selectedRow) { + this._selectedRow.classList.remove("selected"); + this._selectedRow.setAttribute("aria-selected", "false"); + } + + this._selectedRow = row ?? null; + if (row) { + row.classList.add("selected"); + row.setAttribute("aria-selected", "true"); + this.setAttribute("aria-activedescendant", row.id); + row.firstElementChild.scrollIntoView({ block: "nearest" }); + } else { + this.removeAttribute("aria-activedescendant"); + } + + this.dispatchEvent(new CustomEvent("select")); + } + + /** + * Collapses the row at `index` if it can be collapsed. If the selected + * row is a descendant of the collapsing row, selection is moved to the + * collapsing row. + * + * @param {integer} index + */ + collapseRowAtIndex(index) { + this.collapseRow(this.getRowAtIndex(index)); + } + + /** + * Expands the row at `index` if it can be expanded. + * + * @param {integer} index + */ + expandRowAtIndex(index) { + this.expandRow(this.getRowAtIndex(index)); + } + + /** + * Collapses the row if it can be collapsed. If the selected row is a + * descendant of the collapsing row, selection is moved to the collapsing + * row. + * + * @param {HTMLLIElement} row - The row to collapse. + */ + collapseRow(row) { + if ( + row.classList.contains("children") && + !row.classList.contains("collapsed") + ) { + if (row.contains(this.selectedRow)) { + this.selectedRow = row; + } + row.classList.add("collapsed"); + if (this.isTree) { + row.setAttribute("aria-expanded", "false"); + } + row.dispatchEvent(new CustomEvent("collapsed", { bubbles: true })); + this._animateCollapseRow(row); + } + } + + /** + * Expands the row if it can be expanded. + * + * @param {HTMLLIElement} row - The row to expand. + */ + expandRow(row) { + if ( + row.classList.contains("children") && + row.classList.contains("collapsed") + ) { + row.classList.remove("collapsed"); + if (this.isTree) { + row.setAttribute("aria-expanded", "true"); + } + row.dispatchEvent(new CustomEvent("expanded", { bubbles: true })); + this._animateExpandRow(row); + } + } + + /** + * Animate the collapsing of a row containing child items. + * + * @param {HTMLLIElement} row - The parent row element. + */ + _animateCollapseRow(row) { + let childList = row.querySelector("ol, ul"); + + if (reducedMotionMedia.matches) { + if (childList) { + childList.style.height = "0"; + } + return; + } + + let childListHeight = childList.scrollHeight; + + let animation = childList.animate( + [{ height: `${childListHeight}px` }, { height: "0" }], + { + duration: ANIMATION_DURATION_MS, + easing: ANIMATION_EASING, + fill: "both", + } + ); + animation.onfinish = () => { + childList.style.height = "0"; + animation.cancel(); + }; + } + + /** + * Animate the revealing of a row containing child items. + * + * @param {HTMLLIElement} row - The parent row element. + */ + _animateExpandRow(row) { + let childList = row.querySelector("ol, ul"); + + if (reducedMotionMedia.matches) { + if (childList) { + childList.style.height = null; + } + return; + } + + let childListHeight = childList.scrollHeight; + + let animation = childList.animate( + [{ height: "0" }, { height: `${childListHeight}px` }], + { + duration: ANIMATION_DURATION_MS, + easing: ANIMATION_EASING, + fill: "both", + } + ); + animation.onfinish = () => { + childList.style.height = null; + animation.cancel(); + }; + } + }; + + /** + * An unordered list with the functionality of TreeListboxMixin. + */ + class TreeListbox extends TreeListboxMixin(HTMLUListElement) {} + customElements.define("tree-listbox", TreeListbox, { extends: "ul" }); + + /** + * An ordered list with the functionality of TreeListboxMixin, plus the + * ability to re-order the top-level list by drag-and-drop/Alt+Up/Alt+Down. + * + * This class fires an "ordered" event when the list is re-ordered. + * + * @note All children of this element should be HTML. If there are XUL + * elements, you're gonna have a bad time. + */ + class OrderableTreeListbox extends TreeListboxMixin(HTMLOListElement) { + connectedCallback() { + super.connectedCallback(); + this.setAttribute("is", "orderable-tree-listbox"); + + this.addEventListener("dragstart", this); + window.addEventListener("dragover", this); + window.addEventListener("drop", this); + window.addEventListener("dragend", this); + } + + handleEvent(event) { + super.handleEvent(event); + + switch (event.type) { + case "dragstart": + this._onDragStart(event); + break; + case "dragover": + this._onDragOver(event); + break; + case "drop": + this._onDrop(event); + break; + case "dragend": + this._onDragEnd(event); + break; + } + } + + /** + * An array of all top-level rows that can be reordered. Override this + * getter to prevent reordering of one or more rows. + * + * @note So far this has only been used to prevent the last row being + * moved. Any other use is untested. It likely also works for rows at + * the top of the list. + * + * @returns {HTMLLIElement[]} + */ + get _orderableChildren() { + return [...this.children]; + } + + _onKeyDown(event) { + super._onKeyDown(event); + + if ( + !event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + !["ArrowUp", "ArrowDown"].includes(event.key) + ) { + return; + } + + let row = this.selectedRow; + if (!row || row.parentElement != this) { + return; + } + + let otherRow; + if (event.key == "ArrowUp") { + otherRow = row.previousElementSibling; + } else { + otherRow = row.nextElementSibling; + } + if (!otherRow) { + return; + } + + // Check we can move these rows. + let orderable = this._orderableChildren; + if (!orderable.includes(row) || !orderable.includes(otherRow)) { + return; + } + + let reducedMotion = reducedMotionMedia.matches; + + this.scrollToIndex(this.rows.indexOf(otherRow)); + + // Temporarily disconnect the mutation observer to stop it changing things. + this._mutationObserver.disconnect(); + if (event.key == "ArrowUp") { + if (!reducedMotion) { + let { top: otherTop } = otherRow.getBoundingClientRect(); + let { top: rowTop, height: rowHeight } = row.getBoundingClientRect(); + OrderableTreeListbox._animateTranslation(otherRow, 0 - rowHeight); + OrderableTreeListbox._animateTranslation(row, rowTop - otherTop); + } + this.insertBefore(row, otherRow); + } else { + if (!reducedMotion) { + let { top: otherTop, height: otherHeight } = + otherRow.getBoundingClientRect(); + let { top: rowTop, height: rowHeight } = row.getBoundingClientRect(); + OrderableTreeListbox._animateTranslation(otherRow, rowHeight); + OrderableTreeListbox._animateTranslation( + row, + rowTop - otherTop - otherHeight + rowHeight + ); + } + this.insertBefore(row, otherRow.nextElementSibling); + } + this._mutationObserver.observe(this, { subtree: true, childList: true }); + + // Rows moved. + this.domChanged(); + this.dispatchEvent(new CustomEvent("ordered", { detail: row })); + } + + _onDragStart(event) { + if (!event.target.closest("[draggable]")) { + // This shouldn't be necessary, but is?! + event.preventDefault(); + return; + } + + let orderable = this._orderableChildren; + if (orderable.length < 2) { + return; + } + + for (let topLevelRow of orderable) { + if (topLevelRow.contains(event.target)) { + let rect = topLevelRow.getBoundingClientRect(); + this._dragInfo = { + row: topLevelRow, + // How far can we move `topLevelRow` upwards? + min: orderable[0].getBoundingClientRect().top - rect.top, + // How far can we move `topLevelRow` downwards? + max: + orderable[orderable.length - 1].getBoundingClientRect().bottom - + rect.bottom, + // Where is the pointer relative to the scroll box of the list? + // (Not quite, the Y position of `this` is not removed, but we'd + // only have to do the same where this value is used.) + scrollY: event.clientY + this.scrollTop, + // Where is the pointer relative to `topLevelRow`? + offsetY: event.clientY - rect.top, + }; + topLevelRow.classList.add("dragging"); + + // Prevent `topLevelRow` being used as the drag image. We don't + // really want any drag image, but there's no way to not have one. + event.dataTransfer.setDragImage(document.createElement("img"), 0, 0); + return; + } + } + } + + _onDragOver(event) { + if (!this._dragInfo) { + return; + } + + let { row, min, max, scrollY, offsetY } = this._dragInfo; + + // Move `row` with the mouse pointer. + let dragY = Math.min( + max, + Math.max(min, event.clientY + this.scrollTop - scrollY) + ); + row.style.transform = `translateY(${dragY}px)`; + + let thisRect = this.getBoundingClientRect(); + // How much space is there above `row`? We'll see how many rows fit in + // the space and put `row` in after them. + let spaceAbove = Math.max( + 0, + event.clientY + this.scrollTop - offsetY - thisRect.top + ); + // The height of all rows seen in the loop so far. + let totalHeight = 0; + // If we've looped past the row being dragged. + let afterDraggedRow = false; + // The row before where a drop would take place. If null, drop would + // happen at the start of the list. + let targetRow = null; + + for (let topLevelRow of this._orderableChildren) { + if (topLevelRow == row) { + afterDraggedRow = true; + continue; + } + + let rect = topLevelRow.getBoundingClientRect(); + let enoughSpace = spaceAbove > totalHeight + rect.height / 2; + + let multiplier = 0; + if (enoughSpace) { + if (afterDraggedRow) { + multiplier = -1; + } + targetRow = topLevelRow; + } else if (!afterDraggedRow) { + multiplier = 1; + } + OrderableTreeListbox._transitionTranslation( + topLevelRow, + multiplier * row.clientHeight + ); + + totalHeight += rect.height; + } + + this._dragInfo.dropTarget = targetRow; + event.preventDefault(); + } + + _onDrop(event) { + if (!this._dragInfo) { + return; + } + + let { row, dropTarget } = this._dragInfo; + + let targetRow; + if (dropTarget) { + targetRow = dropTarget.nextElementSibling; + } else { + targetRow = this.firstElementChild; + } + + event.preventDefault(); + // Temporarily disconnect the mutation observer to stop it changing things. + this._mutationObserver.disconnect(); + this.insertBefore(row, targetRow); + this._mutationObserver.observe(this, { subtree: true, childList: true }); + // Rows moved. + this.domChanged(); + this.dispatchEvent(new CustomEvent("ordered", { detail: row })); + } + + _onDragEnd(event) { + if (!this._dragInfo) { + return; + } + + this._dragInfo.row.classList.remove("dragging"); + delete this._dragInfo; + + for (let topLevelRow of this.children) { + topLevelRow.style.transition = null; + topLevelRow.style.transform = null; + } + } + + /** + * Used to animate a real change in the order. The element is moved in the + * DOM, then the animation makes it appear to move from the original + * position to the new position + * + * @param {HTMLLIElement} element - The row to animate. + * @param {number} from - Original Y position of the element relative to + * its current position. + */ + static _animateTranslation(element, from) { + let animation = element.animate( + [ + { transform: `translateY(${from}px)` }, + { transform: "translateY(0px)" }, + ], + { + duration: ANIMATION_DURATION_MS, + fill: "both", + } + ); + animation.onfinish = () => animation.cancel(); + } + + /** + * Used to simulate a change in the order. The element remains in the same + * DOM position. + * + * @param {HTMLLIElement} element - The row to animate. + * @param {number} to - The new Y position of the element after animation. + */ + static _transitionTranslation(element, to) { + if (!reducedMotionMedia.matches) { + element.style.transition = `transform ${ANIMATION_DURATION_MS}ms`; + } + element.style.transform = to ? `translateY(${to}px)` : null; + } + } + customElements.define("orderable-tree-listbox", OrderableTreeListbox, { + extends: "ol", + }); +} diff --git a/comm/mail/base/content/widgets/tree-selection.mjs b/comm/mail/base/content/widgets/tree-selection.mjs new file mode 100644 index 0000000000..022af7316e --- /dev/null +++ b/comm/mail/base/content/widgets/tree-selection.mjs @@ -0,0 +1,744 @@ +/* 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/. */ + +/** + * This implementation attempts to mimic the behavior of nsTreeSelection. In + * a few cases, this leads to potentially confusing actions. I attempt to note + * when we are doing this and why we do it. + * + * Unit test is in mail/base/test/unit/test_treeSelection.js + */ +export class TreeSelection { + QueryInterface = ChromeUtils.generateQI(["nsITreeSelection"]); + + /** + * The current XULTreeElement, appropriately QueryInterfaced. May be null. + */ + _tree; + + /** + * Where the focus rectangle (that little dotted thing) shows up. Just + * because something is focused does not mean it is actually selected. + */ + _currentIndex; + /** + * The view index where the shift is anchored when it is not (conceptually) + * the same as _currentIndex. This only happens when you perform a ranged + * selection. In that case, the start index of the ranged selection becomes + * the shift pivot (and the _currentIndex becomes the end of the ranged + * selection.) + * It gets cleared whenever the selection changes and it's not the result of + * a call to rangedSelect. + */ + _shiftSelectPivot; + /** + * A list of [lowIndexInclusive, highIndexInclusive] non-overlapping, + * non-adjacent 'tuples' sort in ascending order. + */ + _ranges; + /** + * The number of currently selected rows. + */ + _count; + + // In the case of the stand-alone message window, there's no tree, but + // there's a view. + _view; + + /** + * A set of indices we think is invalid. + */ + _invalidIndices; + + constructor(tree) { + this._tree = tree; + + this._currentIndex = null; + this._shiftSelectPivot = null; + this._ranges = []; + this._count = 0; + this._invalidIndices = new Set(); + + this._selectEventsSuppressed = false; + } + + /** + * Mark the currently selected rows as invalid. + */ + _invalidateSelection() { + for (let [low, high] of this._ranges) { + for (let i = low; i <= high; i++) { + this._invalidIndices.add(i); + } + } + } + + /** + * Call `invalidateRow` on the tree for each row we think is invalid. + */ + _doInvalidateRows() { + if (this.selectEventsSuppressed) { + return; + } + if (this._tree) { + for (let i of this._invalidIndices) { + this._tree.invalidateRow(i); + } + } + this._invalidIndices.clear(); + } + + /** + * Call `invalidateRange` on the tree. + * + * @param {number} startIndex - The first index to invalidate. + * @param {number?} endIndex - The last index to invalidate. If not given, + * defaults to the index of the last row. + */ + _doInvalidateRange(startIndex, endIndex) { + let noEndIndex = endIndex === undefined; + if (noEndIndex) { + if (!this._view || this.view.rowCount == 0) { + this._doInvalidateAll(); + return; + } + endIndex = this._view.rowCount - 1; + } + if (this._tree) { + this._tree.invalidateRange(startIndex, endIndex); + } + for (let i of this._invalidIndices) { + if (i >= startIndex && (noEndIndex || i <= endIndex)) { + this._invalidIndices.delete(i); + } + } + } + + /** + * Call `invalidate` on the tree. + */ + _doInvalidateAll() { + if (this._tree) { + this._tree.invalidate(); + } + this._invalidIndices.clear(); + } + + get tree() { + return this._tree; + } + set tree(tree) { + this._tree = tree; + } + + get view() { + return this._view; + } + set view(view) { + this._view = view; + } + /** + * Although the nsITreeSelection documentation doesn't say, what this method + * is supposed to do is check if the seltype attribute on the XUL tree is any + * of the following: "single" (only a single row may be selected at a time, + * "cell" (a single cell may be selected), or "text" (the row gets selected + * but only the primary column shows up as selected.) + * + * @returns false because we don't support single-selection. + */ + get single() { + return false; + } + + _updateCount() { + this._count = 0; + for (let [low, high] of this._ranges) { + this._count += high - low + 1; + } + } + + get count() { + return this._count; + } + + isSelected(viewIndex) { + for (let [low, high] of this._ranges) { + if (viewIndex >= low && viewIndex <= high) { + return true; + } + } + return false; + } + + /** + * Select the given row. It does nothing if that row was already selected. + */ + select(viewIndex) { + this._invalidateSelection(); + // current index will provide our effective shift pivot + this._shiftSelectPivot = null; + this._currentIndex = viewIndex != -1 ? viewIndex : null; + + if (this._count == 1 && this._ranges[0][0] == viewIndex) { + return; + } + + if (viewIndex >= 0) { + this._count = 1; + this._ranges = [[viewIndex, viewIndex]]; + this._invalidIndices.add(viewIndex); + } else { + this._count = 0; + this._ranges = []; + } + + this._doInvalidateRows(); + this._fireSelectionChanged(); + } + + timedSelect(index, delay) { + throw new Error("We do not implement timed selection."); + } + + toggleSelect(index) { + this._currentIndex = index; + // If nothing's selected, select index + if (this._count == 0) { + this._count = 1; + this._ranges = [[index, index]]; + } else { + let added = false; + for (let [iTupe, [low, high]] of this._ranges.entries()) { + // below the range? add it to the existing range or create a new one + if (index < low) { + this._count++; + // is it just below an existing range? (range fusion only happens in the + // high case, not here.) + if (index == low - 1) { + this._ranges[iTupe][0] = index; + added = true; + break; + } + // then it gets its own range + this._ranges.splice(iTupe, 0, [index, index]); + added = true; + break; + } + // in the range? will need to either nuke, shrink, or split the range to + // remove it + if (index >= low && index <= high) { + this._count--; + if (index == low && index == high) { + // nuke + this._ranges.splice(iTupe, 1); + } else if (index == low) { + // lower shrink + this._ranges[iTupe][0] = index + 1; + } else if (index == high) { + // upper shrink + this._ranges[iTupe][1] = index - 1; + } else { + // split + this._ranges.splice(iTupe, 1, [low, index - 1], [index + 1, high]); + } + added = true; + break; + } + // just above the range? fuse into the range, and possibly the next + // range up. + if (index == high + 1) { + this._count++; + // see if there is another range and there was just a gap of one between + // the two ranges. + if ( + iTupe + 1 < this._ranges.length && + this._ranges[iTupe + 1][0] == index + 1 + ) { + // yes, merge the ranges + this._ranges.splice(iTupe, 2, [low, this._ranges[iTupe + 1][1]]); + added = true; + break; + } + // nope, no merge required, just update the range + this._ranges[iTupe][1] = index; + added = true; + break; + } + // otherwise we need to keep going + } + + if (!added) { + this._count++; + this._ranges.push([index, index]); + } + } + + this._invalidIndices.add(index); + this._doInvalidateRows(); + this._fireSelectionChanged(); + } + + /** + * @param rangeStart If omitted, it implies a shift-selection is happening, + * in which case we use _shiftSelectPivot as the start if we have it, + * _currentIndex if we don't, and if we somehow didn't have a + * _currentIndex, we use the range end. + * @param rangeEnd Just the inclusive end of the range. + * @param augment Does this set a new selection or should it be merged with + * the existing selection? + */ + rangedSelect(rangeStart, rangeEnd, augment) { + if (rangeStart == -1) { + if (this._shiftSelectPivot != null) { + rangeStart = this._shiftSelectPivot; + } else if (this._currentIndex != null) { + rangeStart = this._currentIndex; + } else { + rangeStart = rangeEnd; + } + } + + this._shiftSelectPivot = rangeStart; + this._currentIndex = rangeEnd; + + // enforce our ordering constraint for our ranges + if (rangeStart > rangeEnd) { + [rangeStart, rangeEnd] = [rangeEnd, rangeStart]; + } + + // if we're not augmenting, then this is really easy. + if (!augment) { + this._invalidateSelection(); + + this._count = rangeEnd - rangeStart + 1; + this._ranges = [[rangeStart, rangeEnd]]; + + for (let i = rangeStart; i <= rangeEnd; i++) { + this._invalidIndices.add(i); + } + + this._doInvalidateRows(); + this._fireSelectionChanged(); + return; + } + + // Iterate over our existing set of ranges, finding the 'range' of ranges + // that our new range overlaps or simply obviates. + // Overlap variables track blocks we need to keep some part of, Nuke + // variables are for blocks that get spliced out. For our purposes, all + // overlap blocks are also nuke blocks. + let lowOverlap, lowNuke, highNuke, highOverlap; + // in case there is no overlap, also figure an insertionPoint + let insertionPoint = this._ranges.length; // default to the end + for (let [iTupe, [low, high]] of this._ranges.entries()) { + // If it's completely include the range, it should be nuked + if (rangeStart <= low && rangeEnd >= high) { + if (lowNuke == null) { + // only the first one we see is the low one + lowNuke = iTupe; + } + highNuke = iTupe; + } + // If our new range start is inside a range or is adjacent, it's overlap + if ( + rangeStart >= low - 1 && + rangeStart <= high + 1 && + lowOverlap == null + ) { + lowOverlap = lowNuke = highNuke = iTupe; + } + // If our new range ends inside a range or is adjacent, it's overlap + if (rangeEnd >= low - 1 && rangeEnd <= high + 1) { + highOverlap = highNuke = iTupe; + if (lowNuke == null) { + lowNuke = iTupe; + } + } + + // we're done when no more overlap is possible + if (rangeEnd < low) { + insertionPoint = iTupe; + break; + } + } + + if (lowOverlap != null) { + rangeStart = Math.min(rangeStart, this._ranges[lowOverlap][0]); + } + if (highOverlap != null) { + rangeEnd = Math.max(rangeEnd, this._ranges[highOverlap][1]); + } + if (lowNuke != null) { + this._ranges.splice(lowNuke, highNuke - lowNuke + 1, [ + rangeStart, + rangeEnd, + ]); + } else { + this._ranges.splice(insertionPoint, 0, [rangeStart, rangeEnd]); + } + for (let i = rangeStart; i <= rangeEnd; i++) { + this._invalidIndices.add(i); + } + + this._updateCount(); + this._doInvalidateRows(); + this._fireSelectionChanged(); + } + + /** + * This is basically RangedSelect but without insertion of a new range and we + * don't need to worry about adjacency. + * Oddly, nsTreeSelection doesn't fire a selection changed event here... + */ + clearRange(rangeStart, rangeEnd) { + // Iterate over our existing set of ranges, finding the 'range' of ranges + // that our clear range overlaps or simply obviates. + // Overlap variables track blocks we need to keep some part of, Nuke + // variables are for blocks that get spliced out. For our purposes, all + // overlap blocks are also nuke blocks. + let lowOverlap, lowNuke, highNuke, highOverlap; + for (let [iTupe, [low, high]] of this._ranges.entries()) { + // If we completely include the range, it should be nuked + if (rangeStart <= low && rangeEnd >= high) { + if (lowNuke == null) { + // only the first one we see is the low one + lowNuke = iTupe; + } + highNuke = iTupe; + } + // If our new range start is inside a range, it's nuke and maybe overlap + if (rangeStart >= low && rangeStart <= high && lowNuke == null) { + lowNuke = highNuke = iTupe; + // it's only overlap if we don't match at the low end + if (rangeStart > low) { + lowOverlap = iTupe; + } + } + // If our new range ends inside a range, it's nuke and maybe overlap + if (rangeEnd >= low && rangeEnd <= high) { + highNuke = iTupe; + // it's only overlap if we don't match at the high end + if (rangeEnd < high) { + highOverlap = iTupe; + } + if (lowNuke == null) { + lowNuke = iTupe; + } + } + + // we're done when no more overlap is possible + if (rangeEnd < low) { + break; + } + } + // nothing to do since there's nothing to nuke + if (lowNuke == null) { + return; + } + let args = [lowNuke, highNuke - lowNuke + 1]; + if (lowOverlap != null) { + args.push([this._ranges[lowOverlap][0], rangeStart - 1]); + } + if (highOverlap != null) { + args.push([rangeEnd + 1, this._ranges[highOverlap][1]]); + } + this._ranges.splice.apply(this._ranges, args); + + for (let i = rangeStart; i <= rangeEnd; i++) { + this._invalidIndices.add(i); + } + + this._updateCount(); + this._doInvalidateRows(); + // note! nsTreeSelection doesn't fire a selection changed event, so neither + // do we, but it seems like we should + } + + /** + * nsTreeSelection always fires a select notification when the range is + * cleared, even if there is no effective chance in selection. + */ + clearSelection() { + this._invalidateSelection(); + this._shiftSelectPivot = null; + this._count = 0; + this._ranges = []; + + this._doInvalidateRows(); + this._fireSelectionChanged(); + } + + /** + * Select all with no rows is a no-op, otherwise we select all and notify. + */ + selectAll() { + if (!this._view) { + return; + } + + let view = this._view; + let rowCount = view.rowCount; + + // no-ops-ville + if (!rowCount) { + return; + } + + this._count = rowCount; + this._ranges = [[0, rowCount - 1]]; + + this._doInvalidateAll(); + this._fireSelectionChanged(); + } + + getRangeCount() { + return this._ranges.length; + } + getRangeAt(rangeIndex, minObj, maxObj) { + if (rangeIndex < 0 || rangeIndex >= this._ranges.length) { + throw new Error("Try a real range index next time."); + } + [minObj.value, maxObj.value] = this._ranges[rangeIndex]; + } + + /** + * Helper method to adjust points in the face of row additions/removal. + * + * @param point The point, null if there isn't one, or an index otherwise. + * @param deltaAt The row at which the change is happening. + * @param delta The number of rows added if positive, or the (negative) + * number of rows removed. + */ + _adjustPoint(point, deltaAt, delta) { + // if there is no point, no change + if (point == null) { + return point; + } + // if the point is before the change, no change + if (point < deltaAt) { + return point; + } + // if it's a deletion and it includes the point, clear it + if (delta < 0 && point >= deltaAt && point + delta < deltaAt) { + return null; + } + // (else) the point is at/after the change, compensate + return point + delta; + } + /** + * Find the index of the range, if any, that contains the given index, and + * the index at which to insert a range if one does not exist. + * + * @returns A tuple containing: 1) the index if there is one, null otherwise, + * 2) the index at which to insert a range that would contain the point. + */ + _findRangeContainingRow(index) { + for (let [iTupe, [low, high]] of this._ranges.entries()) { + if (index >= low && index <= high) { + return [iTupe, iTupe]; + } + if (index < low) { + return [null, iTupe]; + } + } + return [null, this._ranges.length]; + } + + /** + * When present, a list of calls made to adjustSelection. See + * |logAdjustSelectionForReplay| and |replayAdjustSelectionLog|. + */ + _adjustSelectionLog = null; + /** + * Start logging calls to adjustSelection made against this instance. You + * would do this because you are replacing an existing selection object + * with this instance for the purposes of creating a transient selection. + * Of course, you want the original selection object to be up-to-date when + * you go to put it back, so then you can call replayAdjustSelectionLog + * with that selection object and everything will be peachy. + */ + logAdjustSelectionForReplay() { + this._adjustSelectionLog = []; + } + /** + * Stop logging calls to adjustSelection and replay the existing log against + * selection. + * + * @param selection {nsITreeSelection}. + */ + replayAdjustSelectionLog(selection) { + if (this._adjustSelectionLog.length) { + // Temporarily disable selection events because adjustSelection is going + // to generate an event each time otherwise, and better 1 event than + // many. + selection.selectEventsSuppressed = true; + for (let [index, count] of this._adjustSelectionLog) { + selection.adjustSelection(index, count); + } + selection.selectEventsSuppressed = false; + } + this._adjustSelectionLog = null; + } + + adjustSelection(index, count) { + // nothing to do if there is no actual change + if (!count) { + return; + } + + if (this._adjustSelectionLog) { + this._adjustSelectionLog.push([index, count]); + } + + // adjust our points + this._shiftSelectPivot = this._adjustPoint( + this._shiftSelectPivot, + index, + count + ); + this._currentIndex = this._adjustPoint(this._currentIndex, index, count); + + // If we are adding rows, we want to split any range at index and then + // translate all of the ranges above that point up. + if (count > 0) { + let [iContain, iInsert] = this._findRangeContainingRow(index); + if (iContain != null) { + let [low, high] = this._ranges[iContain]; + // if it is the low value, we just want to shift the range entirely, so + // do nothing (and keep iInsert pointing at it for translation) + // if it is not the low value, then there must be at least two values so + // we should split it and only translate the new/upper block + if (index != low) { + this._ranges.splice(iContain, 1, [low, index - 1], [index, high]); + iInsert++; + } + } + // now translate everything from iInsert on up + for (let iTrans = iInsert; iTrans < this._ranges.length; iTrans++) { + let [low, high] = this._ranges[iTrans]; + this._ranges[iTrans] = [low + count, high + count]; + } + // invalidate and fire selection change notice + this._doInvalidateRange(index); + this._fireSelectionChanged(); + return; + } + + // If we are removing rows, we are basically clearing the range that is + // getting deleted and translating everyone above the remaining point + // downwards. The one trick is we may have to merge the lowest translated + // block. + let saveSuppress = this.selectEventsSuppressed; + this.selectEventsSuppressed = true; + this.clearRange(index, index - count - 1); + // translate + let iTrans = this._findRangeContainingRow(index)[1]; + for (; iTrans < this._ranges.length; iTrans++) { + let [low, high] = this._ranges[iTrans]; + // for the first range, low may be below the index, in which case it + // should not get translated + this._ranges[iTrans] = [low >= index ? low + count : low, high + count]; + } + // we may have to merge the lowest translated block because it may now be + // adjacent to the previous block + if ( + iTrans > 0 && + iTrans < this._ranges.length && + this._ranges[iTrans - 1][1] == this._ranges[iTrans][0] + ) { + this._ranges[iTrans - 1][1] = this._ranges[iTrans][1]; + this._ranges.splice(iTrans, 1); + } + + this._doInvalidateRange(index); + this.selectEventsSuppressed = saveSuppress; + } + + get selectEventsSuppressed() { + return this._selectEventsSuppressed; + } + /** + * Control whether selection events are suppressed. For consistency with + * nsTreeSelection, we always generate a selection event when a value of + * false is assigned, even if the value was already false. + */ + set selectEventsSuppressed(suppress) { + if (this._selectEventsSuppressed == suppress) { + return; + } + + this._selectEventsSuppressed = suppress; + if (!suppress) { + this._fireSelectionChanged(); + } + } + + /** + * Note that we bypass any XUL "onselect" handler that may exist and go + * straight to the view. If you have a tree, you shouldn't be using us, + * so this seems aboot right. + */ + _fireSelectionChanged() { + // don't fire if we are suppressed; we will fire when un-suppressed + if (this.selectEventsSuppressed) { + return; + } + let view = this._tree?.view ?? this._view; + + // We might not have a view if we're in the middle of setting up things + view?.selectionChanged(); + } + + get currentIndex() { + if (this._currentIndex == null) { + return -1; + } + return this._currentIndex; + } + /** + * Sets the current index. Other than updating the variable, this just + * invalidates the tree row if we have a tree. + * The real selection object would send a DOM event we don't care about. + */ + set currentIndex(index) { + if (index == this.currentIndex) { + return; + } + + this._invalidateSelection(); + this._currentIndex = index != -1 ? index : null; + this._invalidIndices.add(index); + this._doInvalidateRows(); + } + + get shiftSelectPivot() { + return this._shiftSelectPivot != null ? this._shiftSelectPivot : -1; + } + + /* + * Functions after this aren't part of the nsITreeSelection interface. + */ + + /** + * Duplicate this selection on another nsITreeSelection. This is useful + * when you would like to discard this selection for a real tree selection. + * We assume that both selections are for the same tree. + * + * @note We don't transfer the correct shiftSelectPivot over. + * @note This will fire a selectionChanged event on the tree view. + * + * @param selection an nsITreeSelection to duplicate this selection onto + */ + duplicateSelection(selection) { + selection.selectEventsSuppressed = true; + selection.clearSelection(); + for (let [iTupe, [low, high]] of this._ranges.entries()) { + selection.rangedSelect(low, high, iTupe > 0); + } + + selection.currentIndex = this.currentIndex; + // This will fire a selectionChanged event + selection.selectEventsSuppressed = false; + } +} diff --git a/comm/mail/base/content/widgets/tree-view.mjs b/comm/mail/base/content/widgets/tree-view.mjs new file mode 100644 index 0000000000..aef622fa27 --- /dev/null +++ b/comm/mail/base/content/widgets/tree-view.mjs @@ -0,0 +1,2633 @@ +/* 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/. */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +import { TreeSelection } from "chrome://messenger/content/tree-selection.mjs"; + +// Account for the mac OS accelerator key variation. +// Use these strings to check keyboard event properties. +const accelKeyName = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"; +const otherKeyName = AppConstants.platform == "macosx" ? "ctrlKey" : "metaKey"; + +const ANIMATION_DURATION_MS = 200; +const reducedMotionMedia = matchMedia("(prefers-reduced-motion)"); + +/** + * Main tree view container that takes care of generating the main scrollable + * DIV and the tree table. + */ +class TreeView extends HTMLElement { + static observedAttributes = ["rows"]; + + /** + * The number of rows on either side to keep of the visible area to keep in + * memory in order to avoid visible blank spaces while the user scrolls. + * + * This member is visible for testing and should not be used outside of this + * class in production code. + * + * @type {integer} + */ + _toleranceSize = 0; + + /** + * Set the size of the tolerance buffer based on the number of rows which can + * be visible at once. + */ + #calculateToleranceBufferSize() { + this._toleranceSize = this.#calculateVisibleRowCount() * 2; + } + + /** + * Index of the first row that exists in the DOM. Includes rows in the + * tolerance buffer if they have been added. + * + * @type {integer} + */ + #firstBufferRowIndex = 0; + + /** + * Index of the last row that exists in the DOM. Includes rows in the + * tolerance buffer if they have been added. + * + * @type {integer} + */ + #lastBufferRowIndex = 0; + + /** + * Index of the first visible row. + * + * @type {integer} + */ + #firstVisibleRowIndex = 0; + + /** + * Index of the last visible row. + * + * @type {integer} + */ + #lastVisibleRowIndex = 0; + + /** + * Row indices mapped to the row elements that exist in the DOM. + * + * @type {Map<integer, HTMLTableRowElement>} + */ + _rows = new Map(); + + /** + * The current view. + * + * @type {nsITreeView} + */ + _view = null; + + /** + * The current selection. + * + * @type {nsITreeSelection} + */ + _selection = null; + + /** + * The function storing the timeout callback for the delayed select feature in + * order to clear it when not needed. + * + * @type {integer} + */ + _selectTimeout = null; + + /** + * A handle to the callback to fill the buffer when we aren't busy painting. + * + * @type {number} + */ + #bufferFillIdleCallbackHandle = null; + + /** + * The virtualized table containing our rows. + * + * @type {TreeViewTable} + */ + table = null; + + /** + * An event to fire to indicate the work of filling the buffer is complete. + * This will fire once both visible and tolerance rows are ready. It will also + * fire if no change to the buffer is required. + * + * This member is visible in order to provide a reliable indicator to tests + * that all expected rows should be in place. It should not be used in + * production code. + * + * @type {Event} + */ + _rowBufferReadyEvent = null; + + /** + * Fire the provided event, if any, in order to indicate that any necessary + * buffer modification work is complete, including if no work is necessary. + */ + #dispatchRowBufferReadyEvent() { + // Don't fire if we're currently waiting on buffer fills; let the callback + // do that when it's finished. + if (this._rowBufferReadyEvent && !this.#bufferFillIdleCallbackHandle) { + this.dispatchEvent(this._rowBufferReadyEvent); + } + } + + /** + * Determine the height of the visible row area, excluding any chrome which + * covers elements. + * + * WARNING: This may cause synchronous reflow if used after modifying the DOM. + * + * @returns {integer} - The height of the area into which visible rows are + * rendered. + */ + #calculateVisibleHeight() { + // Account for the table header height in a sticky position above the body. + return this.clientHeight - this.table.header.clientHeight; + } + + /** + * Determine how many rows are visible in the client presently. + * + * WARNING: This may cause synchronous reflow if used after modifying the DOM. + * + * @returns {integer} - The number of visible or partly-visible rows. + */ + #calculateVisibleRowCount() { + return Math.ceil( + this.#calculateVisibleHeight() / this._rowElementClass.ROW_HEIGHT + ); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + // Prevent this element from being part of the roving tab focus since we + // handle that independently for the TreeViewTableBody and we don't want any + // interference from this. + this.tabIndex = -1; + this.classList.add("tree-view-scrollable-container"); + + this.table = document.createElement("table", { is: "tree-view-table" }); + this.appendChild(this.table); + + this.placeholder = this.querySelector(`slot[name="placeholders"]`); + + this.addEventListener("scroll", this); + + let lastHeight = 0; + this.resizeObserver = new ResizeObserver(entries => { + // The width of the table isn't important to virtualizing the table. Skip + // updating if the height hasn't changed. + if (this.clientHeight == lastHeight) { + this.#dispatchRowBufferReadyEvent(); + return; + } + + if (!this._rowElementClass) { + this.#dispatchRowBufferReadyEvent(); + return; + } + + // The number of rows in the tolerance buffer is based on the number of + // rows which can be visible. Update it. + this.#calculateToleranceBufferSize(); + + // There's not much point in reducing the number of rows on resize. Scroll + // height remains the same and we can retain the extra rows in the buffer. + if (this.clientHeight > lastHeight) { + this._ensureVisibleRowsAreDisplayed(); + } else { + this.#dispatchRowBufferReadyEvent(); + } + + lastHeight = this.clientHeight; + }); + this.resizeObserver.observe(this); + } + + disconnectedCallback() { + this.#resetRowBuffer(); + this.resizeObserver.disconnect(); + } + + attributeChangedCallback(attrName, oldValue, newValue) { + this._rowElementName = newValue || "tree-view-table-row"; + this._rowElementClass = customElements.get(this._rowElementName); + + this.#calculateToleranceBufferSize(); + + if (this._view) { + this.reset(); + } + } + + handleEvent(event) { + switch (event.type) { + case "keyup": { + if ( + ["Tab", "F6"].includes(event.key) && + this.currentIndex == -1 && + this._view?.rowCount + ) { + let selectionChanged = false; + if (this.selectedIndex == -1) { + this._selection.select(0); + selectionChanged = true; + } + this.currentIndex = this.selectedIndex; + if (selectionChanged) { + this.onSelectionChanged(); + } + } + break; + } + case "click": { + if (event.button !== 0) { + return; + } + + let row = event.target.closest(`tr[is="${this._rowElementName}"]`); + if (!row) { + return; + } + + let index = row.index; + + if (event.target.classList.contains("tree-button-thread")) { + if (this._view.isContainerOpen(index)) { + let children = 0; + for ( + let i = index + 1; + i < this._view.rowCount && this._view.getLevel(i) > 0; + i++ + ) { + children++; + } + this._selectRange(index, index + children, event[accelKeyName]); + } else { + let addedRows = this.expandRowAtIndex(index); + this._selectRange(index, index + addedRows, event[accelKeyName]); + } + this.table.body.focus(); + return; + } + + if (this._view.isContainer(index) && event.target.closest(".twisty")) { + if (this._view.isContainerOpen(index)) { + this.collapseRowAtIndex(index); + } else { + let addedRows = this.expandRowAtIndex(index); + this.scrollToIndex( + index + Math.min(addedRows, this.#calculateVisibleRowCount() - 1) + ); + } + this.table.body.focus(); + return; + } + + // Handle the click as a CTRL extension if it happens on the checkbox + // image inside the selection column. + if (event.target.classList.contains("tree-view-row-select-checkbox")) { + if (event.shiftKey) { + this._selectRange(-1, index, event[accelKeyName]); + } else { + this._toggleSelected(index); + } + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-request-delete")) { + this.table.body.dispatchEvent( + new CustomEvent("request-delete", { + bubbles: true, + detail: { + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-flag")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-flag", { + bubbles: true, + detail: { + isFlagged: row.dataset.properties.includes("flagged"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-unread")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-unread", { + bubbles: true, + detail: { + isUnread: row.dataset.properties.includes("unread"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-spam")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-spam", { + bubbles: true, + detail: { + isJunk: row.dataset.properties.split(" ").includes("junk"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event[accelKeyName] && !event.shiftKey) { + this._toggleSelected(index); + } else if (event.shiftKey) { + this._selectRange(-1, index, event[accelKeyName]); + } else { + this._selectSingle(index); + } + + this.table.body.focus(); + break; + } + case "keydown": { + if (event.altKey || event[otherKeyName]) { + return; + } + + let currentIndex = this.currentIndex == -1 ? 0 : this.currentIndex; + let newIndex; + switch (event.key) { + case "ArrowUp": + newIndex = currentIndex - 1; + break; + case "ArrowDown": + newIndex = currentIndex + 1; + break; + case "ArrowLeft": + case "ArrowRight": { + event.preventDefault(); + if (this.currentIndex == -1) { + return; + } + let isArrowRight = event.key == "ArrowRight"; + let isRTL = this.matches(":dir(rtl)"); + if (isArrowRight == isRTL) { + // Collapse action. + let currentLevel = this._view.getLevel(this.currentIndex); + if (this._view.isContainerOpen(this.currentIndex)) { + this.collapseRowAtIndex(this.currentIndex); + return; + } else if (currentLevel == 0) { + return; + } + + let parentIndex = this._view.getParentIndex(this.currentIndex); + if (parentIndex != -1) { + newIndex = parentIndex; + } + } else if (this._view.isContainer(this.currentIndex)) { + // Expand action. + if (!this._view.isContainerOpen(this.currentIndex)) { + let addedRows = this.expandRowAtIndex(this.currentIndex); + this.scrollToIndex( + this.currentIndex + + Math.min(addedRows, this.#calculateVisibleRowCount() - 1) + ); + } else { + newIndex = this.currentIndex + 1; + } + } + if (newIndex != undefined) { + this._selectSingle(newIndex); + } + return; + } + case "Home": + newIndex = 0; + break; + case "End": + newIndex = this._view.rowCount - 1; + break; + case "PageUp": + newIndex = Math.max( + 0, + currentIndex - this.#calculateVisibleRowCount() + ); + break; + case "PageDown": + newIndex = Math.min( + this._view.rowCount - 1, + currentIndex + this.#calculateVisibleRowCount() + ); + break; + } + + if (newIndex != undefined) { + newIndex = this._clampIndex(newIndex); + if (newIndex != null) { + if (event[accelKeyName] && !event.shiftKey) { + // Change focus, but not selection. + this.currentIndex = newIndex; + } else if (event.shiftKey) { + this._selectRange(-1, newIndex, event[accelKeyName]); + } else { + this._selectSingle(newIndex, true); + } + } + event.preventDefault(); + return; + } + + // Space bar keystroke selection toggling. + if (event.key == " " && this.currentIndex != -1) { + // Don't do anything if we're on macOS and the target row is already + // selected. + if ( + AppConstants.platform == "macosx" && + this._selection.isSelected(this.currentIndex) + ) { + return; + } + + // Handle the macOS exception of toggling the selection with only + // the space bar since CMD+Space is captured by the OS. + if (event[accelKeyName] || AppConstants.platform == "macosx") { + this._toggleSelected(this.currentIndex); + event.preventDefault(); + } else if (!this._selection.isSelected(this.currentIndex)) { + // The target row is not currently selected. + this._selectSingle(this.currentIndex, true); + event.preventDefault(); + } + } + break; + } + case "scroll": + this._ensureVisibleRowsAreDisplayed(); + break; + } + } + + /** + * The current view for this list. + * + * @type {nsITreeView} + */ + get view() { + return this._view; + } + + set view(view) { + this._selection = null; + if (this._view) { + this._view.setTree(null); + this._view.selection = null; + } + if (this._selection) { + this._selection.view = null; + } + + this._view = view; + if (view) { + try { + this._selection = new TreeSelection(); + this._selection.tree = this; + this._selection.view = view; + + view.selection = this._selection; + view.setTree(this); + } catch (ex) { + // This isn't a XULTreeElement, and we can't make it one, so if the + // `setTree` call crosses XPCOM, an exception will be thrown. + if (ex.result != Cr.NS_ERROR_XPC_BAD_CONVERT_JS) { + throw ex; + } + } + } + + // Clear the height of the top spacer to avoid confusing + // `_ensureVisibleRowsAreDisplayed`. + this.table.spacerTop.setHeight(0); + this.reset(); + + this.dispatchEvent(new CustomEvent("viewchange")); + } + + /** + * Set the colspan of the spacer row cells. + * + * @param {int} count - The amount of visible columns. + */ + setSpacersColspan(count) { + // Add an extra column if the table is editable to account for the column + // picker column. + if (this.parentNode.editable) { + count++; + } + this.table.spacerTop.setColspan(count); + this.table.spacerBottom.setColspan(count); + } + + /** + * Clear all rows from the buffer, empty the table body, and reset spacers. + */ + #resetRowBuffer() { + this.#cancelToleranceFillCallback(); + this.table.body.replaceChildren(); + this._rows.clear(); + this.#firstBufferRowIndex = 0; + this.#lastBufferRowIndex = 0; + this.#firstVisibleRowIndex = 0; + + // Set the height of the bottom spacer to account for the now-missing rows. + // We want to ensure that the overall scroll height does not decrease. + // Otherwise, we may lose our scroll position and cause unnecessary + // scrolling. However, we don't always want to change the height of the top + // spacer for the same reason. + let rowCount = this._view?.rowCount ?? 0; + this.table.spacerBottom.setHeight( + rowCount * this._rowElementClass.ROW_HEIGHT + ); + } + + /** + * Clear all rows from the list and create them again. + */ + reset() { + this.#resetRowBuffer(); + this._ensureVisibleRowsAreDisplayed(); + } + + /** + * Updates all existing rows in place, without removing all the rows and + * starting again. This can be used if the row element class hasn't changed + * and its `index` setter is capable of handling any modifications required. + */ + invalidate() { + this.invalidateRange(this.#firstBufferRowIndex, this.#lastBufferRowIndex); + } + + /** + * Perform the actions necessary to invalidate the specified row. Implemented + * separately to allow {@link invalidateRange} to handle testing event fires + * on its own. + * + * @param {integer} index + */ + #doInvalidateRow(index) { + const rowCount = this._view?.rowCount ?? 0; + let row = this.getRowAtIndex(index); + if (row) { + if (index >= rowCount) { + this._removeRowAtIndex(index); + } else { + row.index = index; + row.selected = this._selection.isSelected(index); + } + } else if ( + index >= this.#firstBufferRowIndex && + index <= Math.min(rowCount - 1, this.#lastBufferRowIndex) + ) { + this._addRowAtIndex(index); + } + } + + /** + * Invalidate the rows between `startIndex` and `endIndex`. + * + * @param {integer} startIndex + * @param {integer} endIndex + */ + invalidateRange(startIndex, endIndex) { + for ( + let index = Math.max(startIndex, this.#firstBufferRowIndex), + last = Math.min(endIndex, this.#lastBufferRowIndex); + index <= last; + index++ + ) { + this.#doInvalidateRow(index); + } + this._ensureVisibleRowsAreDisplayed(); + } + + /** + * Invalidate the row at `index` in place. If `index` refers to a row that + * should exist but doesn't (because the row count increased), adds a row. + * If `index` refers to a row that does exist but shouldn't (because the + * row count decreased), removes it. + * + * @param {integer} index + */ + invalidateRow(index) { + this.#doInvalidateRow(index); + this.#dispatchRowBufferReadyEvent(); + } + + /** + * A contiguous range, inclusive of both extremes. + * + * @typedef InclusiveRange + * @property {integer} first - The inclusive start of the range. + * @property {integer} last - The inclusive end of the range. + */ + + /** + * Calculate the range of rows we wish to have in a filled tolerance buffer + * based on a given range of visible rows. + * + * @param {integer} firstVisibleRow - The first visible row in the range. + * @param {integer} lastVisibleRow - The last visible row in the range. + * @param {integer} dataRowCount - The total number of available rows in the + * source data. + * @returns {InclusiveRange} - The full range of the desired buffer. + */ + #calculateDesiredBufferRange(firstVisibleRow, lastVisibleRow, dataRowCount) { + const desiredRowRange = {}; + + desiredRowRange.first = Math.max(firstVisibleRow - this._toleranceSize, 0); + desiredRowRange.last = Math.min( + lastVisibleRow + this._toleranceSize, + dataRowCount - 1 + ); + + return desiredRowRange; + } + + #createToleranceFillCallback() { + // Don't schedule a new buffer fill callback if we already have one. + if (!this.#bufferFillIdleCallbackHandle) { + this.#bufferFillIdleCallbackHandle = requestIdleCallback(deadline => + this.#fillToleranceBuffer(deadline) + ); + } + } + + #cancelToleranceFillCallback() { + cancelIdleCallback(this.#bufferFillIdleCallbackHandle); + this.#bufferFillIdleCallbackHandle = null; + } + + /** + * Fill the buffer with tolerance rows above and below the visible rows. + * + * As fetching data and modifying the DOM is expensive, this is intended to be + * run within an idle callback and includes management of the idle callback + * handle and creation of further callbacks if work is not completed. + * + * @param {IdleDeadline} deadline - A deadline object for fetching the + * remaining time in the idle tick. + */ + #fillToleranceBuffer(deadline) { + this.#bufferFillIdleCallbackHandle = null; + + const rowCount = this._view?.rowCount ?? 0; + if (!rowCount) { + return; + } + + const bufferRange = this.#calculateDesiredBufferRange( + this.#firstVisibleRowIndex, + this.#lastVisibleRowIndex, + rowCount + ); + + // Set the amount of time to leave in the deadline to fill another row. In + // order to cooperatively schedule work, we shouldn't overrun the time + // allotted for the idle tick. This value should be set such that it leaves + // enough time to perform another row fill and adjust the relevant spacer + // while doing the maximal amount of work per callback. + const MS_TO_LEAVE_PER_FILL = 1.25; + + // Fill in the beginning of the buffer. + if (bufferRange.first < this.#firstBufferRowIndex) { + for ( + let i = this.#firstBufferRowIndex - 1; + i >= bufferRange.first && + deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL; + i-- + ) { + this._addRowAtIndex(i, this.table.body.firstElementChild); + + // Update as we go in case we need to wait for the next idle. + this.#firstBufferRowIndex = i; + } + + // Adjust the height of the top spacer to account for the new rows we've + // added. + this.table.spacerTop.setHeight( + this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT + ); + + // If we haven't completed the work of filling the tolerance buffer, + // schedule a new job to do so. + if (this.#firstBufferRowIndex != bufferRange.first) { + this.#createToleranceFillCallback(); + return; + } + } + + // Fill in the end of the buffer. + if (bufferRange.last > this.#lastBufferRowIndex) { + for ( + let i = this.#lastBufferRowIndex + 1; + i <= bufferRange.last && + deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL; + i++ + ) { + this._addRowAtIndex(i); + + // Update as we go in case we need to wait for the next idle. + this.#lastBufferRowIndex = i; + } + + // Adjust the height of the bottom spacer to account for the new rows + // we've added. + this.table.spacerBottom.setHeight( + (rowCount - 1 - this.#lastBufferRowIndex) * + this._rowElementClass.ROW_HEIGHT + ); + + // If we haven't completed the work of filling the tolerance buffer, + // schedule a new job to do so. + if (this.#lastBufferRowIndex != bufferRange.last) { + this.#createToleranceFillCallback(); + return; + } + } + + // Notify tests that we have finished work. + this.#dispatchRowBufferReadyEvent(); + } + + /** + * The calculated ranges which determine the shape of the row buffer at + * various stages of processing. + * + * @typedef RowBufferRanges + * @property {InclusiveRange} visibleRows - The range of rows which should be + * displayed to the user. + * @property {integer?} pruneBefore - The index of the row before which any + * additional rows should be discarded. + * @property {integer?} pruneAfter - The index of the row after which any + * additional rows should be discarded. + * @property {InclusiveRange} finalizedRows - The range of rows which should + * exist in the row buffer after any additions and removals have been made. + */ + + /** + * Calculate the values necessary for building the list of visible rows and + * retaining any rows in the buffer which fall inside the desired tolerance + * and form a contiguous range with the visible rows. + * + * WARNING: This function makes calculations based on existing DOM dimensions. + * Do not use it after you have modified the DOM. + * + * @returns {RowBufferRanges} + */ + #calculateRowBufferRanges(dataRowCount) { + /** @type {RowBufferRanges} */ + const ranges = { + visibleRows: {}, + pruneBefore: null, + pruneAfter: null, + finalizedRows: {}, + }; + + // We adjust the row buffer in several stages. First, we'll use the new + // scroll position to determine the boundaries of the buffer. Then, we'll + // create and add any new rows which are necessary to fit the new + // boundaries. Next, we prune rows added in previous scrolls which now fall + // outside the boundaries. Finally, we recalculate the height of the spacers + // which position the visible rows within the rendered area. + ranges.visibleRows.first = Math.max( + Math.floor(this.scrollTop / this._rowElementClass.ROW_HEIGHT), + 0 + ); + + const lastPossibleVisibleRow = Math.ceil( + (this.scrollTop + this.#calculateVisibleHeight()) / + this._rowElementClass.ROW_HEIGHT + ); + + ranges.visibleRows.last = + Math.min(lastPossibleVisibleRow, dataRowCount) - 1; + + // Determine the number of rows desired in the tolerance buffer in order to + // determine whether there are any that we can save. + const desiredRowRange = this.#calculateDesiredBufferRange( + ranges.visibleRows.first, + ranges.visibleRows.last, + dataRowCount + ); + + // Determine which rows are no longer wanted in the buffer. If we've + // scrolled past the previous visible rows, it's possible that the tolerance + // buffer will still contain some rows we'd like to have in the buffer. Note + // that we insist on a contiguous range of rows in the buffer to simplify + // determining which rows exist and appropriately spacing the viewport. + if (this.#lastBufferRowIndex < ranges.visibleRows.first) { + // There is a discontiguity between the visible rows and anything that's + // in the buffer. Prune everything before the visible rows. + ranges.pruneBefore = ranges.visibleRows.first; + ranges.finalizedRows.first = ranges.visibleRows.first; + } else if (this.#firstBufferRowIndex < desiredRowRange.first) { + // The range of rows in the buffer overlaps the start of the visible rows, + // but there are rows outside of the desired buffer as well. Prune them. + ranges.pruneBefore = desiredRowRange.first; + ranges.finalizedRows.first = desiredRowRange.first; + } else { + // Determine the beginning of the finalized buffer based on whether the + // buffer contains rows before the start of the visible rows. + ranges.finalizedRows.first = Math.min( + ranges.visibleRows.first, + this.#firstBufferRowIndex + ); + } + + if (this.#firstBufferRowIndex > ranges.visibleRows.last) { + // There is a discontiguity between the visible rows and anything that's + // in the buffer. Prune everything after the visible rows. + ranges.pruneAfter = ranges.visibleRows.last; + ranges.finalizedRows.last = ranges.visibleRows.last; + } else if (this.#lastBufferRowIndex > desiredRowRange.last) { + // The range of rows in the buffer overlaps the end of the visible rows, + // but there are rows outside of the desired buffer as well. Prune them. + ranges.pruneAfter = desiredRowRange.last; + ranges.finalizedRows.last = desiredRowRange.last; + } else { + // Determine the end of the finalized buffer based on whether the buffer + // contains rows after the end of the visible rows. + ranges.finalizedRows.last = Math.max( + ranges.visibleRows.last, + this.#lastBufferRowIndex + ); + } + + return ranges; + } + + /** + * Display the table rows which should be shown in the visible area and + * request filling of the tolerance buffer when idle. + */ + _ensureVisibleRowsAreDisplayed() { + this.#cancelToleranceFillCallback(); + + let rowCount = this._view?.rowCount ?? 0; + this.placeholder?.classList.toggle("show", !rowCount); + + if (!rowCount || this.#calculateVisibleRowCount() == 0) { + return; + } + + if (this.scrollTop > rowCount * this._rowElementClass.ROW_HEIGHT) { + // Beyond the end of the list. We're about to scroll anyway, so clear + // everything out and wait for it to happen. Don't call `invalidate` here, + // or you'll end up in an infinite loop. + this.table.spacerTop.setHeight(0); + this.#resetRowBuffer(); + return; + } + + const ranges = this.#calculateRowBufferRanges(rowCount); + + // *WARNING: Do not request any DOM dimensions after this point. Modifying + // the DOM will invalidate existing calculations and any additional requests + // will cause synchronous reflow. + + // Add a row if the table is empty. Either we're initializing or have + // invalidated the tree, and the next two steps pass over row zero if there + // are no rows already in the buffer. + if ( + this.#lastBufferRowIndex == 0 && + this.table.body.childElementCount == 0 && + ranges.visibleRows.first == 0 + ) { + this._addRowAtIndex(0); + } + + // Expand the row buffer to include newly-visible rows which weren't already + // visible or preloaded in the tolerance buffer. + + const earliestMissingEndRowIdx = Math.max( + this.#lastBufferRowIndex + 1, + ranges.visibleRows.first + ); + for (let i = earliestMissingEndRowIdx; i <= ranges.visibleRows.last; i++) { + // We are missing rows at the end of the buffer. Either the last row of + // the existing buffer lies within the range of visible rows and we begin + // there, or the entire range of visible rows occurs after the end of the + // buffer and we fill in from the start. + this._addRowAtIndex(i); + } + + const latestMissingStartRowIdx = Math.min( + this.#firstBufferRowIndex - 1, + ranges.visibleRows.last + ); + for (let i = latestMissingStartRowIdx; i >= ranges.visibleRows.first; i--) { + // We are missing rows at the start of the buffer. We'll add them working + // backwards so that we can prepend. Either the first row of the existing + // buffer lies within the range of visible rows and we begin there, or the + // entire range of visible rows occurs before the end of the buffer and we + // fill in from the end. + this._addRowAtIndex(i, this.table.body.firstElementChild); + } + + // Prune the buffer of any rows outside of our desired buffer range. + if (ranges.pruneBefore !== null) { + const pruneBeforeRow = this.getRowAtIndex(ranges.pruneBefore); + let rowToPrune = pruneBeforeRow.previousElementSibling; + while (rowToPrune) { + this._removeRowAtIndex(rowToPrune.index); + rowToPrune = pruneBeforeRow.previousElementSibling; + } + } + + if (ranges.pruneAfter !== null) { + const pruneAfterRow = this.getRowAtIndex(ranges.pruneAfter); + let rowToPrune = pruneAfterRow.nextElementSibling; + while (rowToPrune) { + this._removeRowAtIndex(rowToPrune.index); + rowToPrune = pruneAfterRow.nextElementSibling; + } + } + + // Set the indices of the new first and last rows in the DOM. They may come + // from the tolerance buffer if we haven't exhausted it. + this.#firstBufferRowIndex = ranges.finalizedRows.first; + this.#lastBufferRowIndex = ranges.finalizedRows.last; + + this.#firstVisibleRowIndex = ranges.visibleRows.first; + this.#lastVisibleRowIndex = ranges.visibleRows.last; + + // Adjust the height of the spacers to ensure that visible rows fall within + // the visible space and the overall scroll height is correct. + this.table.spacerTop.setHeight( + this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT + ); + + this.table.spacerBottom.setHeight( + (rowCount - this.#lastBufferRowIndex - 1) * + this._rowElementClass.ROW_HEIGHT + ); + + // The row buffer ideally contains some tolerance on either end to avoid + // creating rows and fetching data for them during short scrolls. However, + // actually creating those rows can be expensive, and during a long scroll + // we may throw them away very quickly. To save the expense, only fill the + // buffer while idle. + + this.#createToleranceFillCallback(); + } + + /** + * Index of the first visible or partly visible row. + * + * @returns {integer} + */ + getFirstVisibleIndex() { + return this.#firstVisibleRowIndex; + } + + /** + * Index of the last visible or partly visible row. + * + * @returns {integer} + */ + getLastVisibleIndex() { + return this.#lastVisibleRowIndex; + } + + /** + * Ensures that the row at `index` is on the screen. + * + * @param {integer} index + */ + scrollToIndex(index, instant = false) { + const rowCount = this._view.rowCount; + if (rowCount == 0) { + // If there are no rows, make sure we're scrolled to the top. + this.scrollTo({ top: 0, behavior: "instant" }); + return; + } + if (index < 0 || index >= rowCount) { + // Bad index. Report, and do nothing. + console.error( + `<${this.localName} id="${this.id}"> tried to scroll to a row that doesn't exist: ${index}` + ); + return; + } + + const topOfRow = this._rowElementClass.ROW_HEIGHT * index; + let scrollTop = this.scrollTop; + const visibleHeight = this.#calculateVisibleHeight(); + const behavior = instant ? "instant" : "auto"; + + // Scroll up to the row. + if (topOfRow < scrollTop) { + this.scrollTo({ top: topOfRow, behavior }); + return; + } + + // Scroll down to the row. + const bottomOfRow = topOfRow + this._rowElementClass.ROW_HEIGHT; + if (bottomOfRow > scrollTop + visibleHeight) { + this.scrollTo({ top: bottomOfRow - visibleHeight, behavior }); + return; + } + + // Call `scrollTo` even if the row is in view, to stop any earlier smooth + // scrolling that might be happening. + this.scrollTo({ top: this.scrollTop, behavior }); + } + + /** + * Updates the list to reflect added or removed rows. + * + * @param {integer} index - The position in the existing list where rows were + * added or removed. + * @param {integer} delta - The change in number of rows; positive if rows + * were added and negative if rows were removed. + */ + rowCountChanged(index, delta) { + if (!this._selection) { + return; + } + + this._selection.adjustSelection(index, delta); + this._updateCurrentIndexClasses(); + this.dispatchEvent(new CustomEvent("rowcountchange")); + } + + /** + * Clamps `index` to a value between 0 and `rowCount - 1`. + * + * @param {integer} index + * @returns {integer} + */ + _clampIndex(index) { + if (!this._view.rowCount) { + return null; + } + if (index < 0) { + return 0; + } + if (index >= this._view.rowCount) { + return this._view.rowCount - 1; + } + return index; + } + + /** + * Creates a new row element and adds it to the DOM. + * + * @param {integer} index + */ + _addRowAtIndex(index, before = null) { + let row = document.createElement("tr", { is: this._rowElementName }); + row.setAttribute("is", this._rowElementName); + this.table.body.insertBefore(row, before); + row.setAttribute("aria-setsize", this._view.rowCount); + row.style.height = `${this._rowElementClass.ROW_HEIGHT}px`; + row.index = index; + if (this._selection?.isSelected(index)) { + row.selected = true; + } + if (this.currentIndex === index) { + row.classList.add("current"); + this.table.body.setAttribute("aria-activedescendant", row.id); + } + this._rows.set(index, row); + } + + /** + * Removes the row element at `index` from the DOM and map of rows. + * + * @param {integer} index + */ + _removeRowAtIndex(index) { + const row = this._rows.get(index); + row?.remove(); + this._rows.delete(index); + } + + /** + * Returns the row element at `index` or null if `index` is out of range. + * + * @param {integer} index + * @returns {HTMLTableRowElement} + */ + getRowAtIndex(index) { + return this._rows.get(index) ?? null; + } + + /** + * Collapses the row at `index` if it can be collapsed. If the selected + * row is a descendant of the collapsing row, selection is moved to the + * collapsing row. + * + * @param {integer} index + */ + collapseRowAtIndex(index) { + if (!this._view.isContainerOpen(index)) { + return; + } + + // If the selected row is going to be collapsed, move the selection. + // Even if the row to be collapsed is already selected, set + // selectIndex to ensure currentIndex also points to the correct row. + let selectedIndex = this.selectedIndex; + while (selectedIndex >= index) { + if (selectedIndex == index) { + this.selectedIndex = index; + break; + } + selectedIndex = this._view.getParentIndex(selectedIndex); + } + + // Check if the view calls rowCountChanged. If it didn't, we'll have to + // call it. This can happen if the view has no reference to the tree. + let rowCountDidChange = false; + let rowCountChangeListener = () => { + rowCountDidChange = true; + }; + + let countBefore = this._view.rowCount; + this.addEventListener("rowcountchange", rowCountChangeListener); + this._view.toggleOpenState(index); + this.removeEventListener("rowcountchange", rowCountChangeListener); + let countAdded = this._view.rowCount - countBefore; + + // Call rowCountChanged, if it hasn't already happened. + if (countAdded && !rowCountDidChange) { + this.invalidateRow(index); + this.rowCountChanged(index + 1, countAdded); + } + + this.dispatchEvent( + new CustomEvent("collapsed", { bubbles: true, detail: index }) + ); + } + + /** + * Expands the row at `index` if it can be expanded. + * + * @param {integer} index + * @returns {integer} - the number of rows that were added + */ + expandRowAtIndex(index) { + if (!this._view.isContainer(index) || this._view.isContainerOpen(index)) { + return 0; + } + + // Check if the view calls rowCountChanged. If it didn't, we'll have to + // call it. This can happen if the view has no reference to the tree. + let rowCountDidChange = false; + let rowCountChangeListener = () => { + rowCountDidChange = true; + }; + + let countBefore = this._view.rowCount; + this.addEventListener("rowcountchange", rowCountChangeListener); + this._view.toggleOpenState(index); + this.removeEventListener("rowcountchange", rowCountChangeListener); + let countAdded = this._view.rowCount - countBefore; + + // Call rowCountChanged, if it hasn't already happened. + if (countAdded && !rowCountDidChange) { + this.invalidateRow(index); + this.rowCountChanged(index + 1, countAdded); + } + + this.dispatchEvent( + new CustomEvent("expanded", { bubbles: true, detail: index }) + ); + + return countAdded; + } + + /** + * In a selection, index of the most-recently-selected row. + * + * @type {integer} + */ + get currentIndex() { + return this._selection ? this._selection.currentIndex : -1; + } + + set currentIndex(index) { + if (!this._view) { + return; + } + + this._selection.currentIndex = index; + this._updateCurrentIndexClasses(); + if (index >= 0 && index < this._view.rowCount) { + this.scrollToIndex(index); + } + } + + /** + * Set the "current" class on the right row, and remove it from all other rows. + */ + _updateCurrentIndexClasses() { + let index = this.currentIndex; + + for (let row of this.querySelectorAll( + `tr[is="${this._rowElementName}"].current` + )) { + row.classList.remove("current"); + } + + if (!this._view || index < 0 || index > this._view.rowCount - 1) { + this.table.body.removeAttribute("aria-activedescendant"); + return; + } + + let row = this.getRowAtIndex(index); + if (row) { + // We need to clear the attribute in order to let screen readers know that + // a new message has been selected even if the ID is identical. For + // example when we delete the first message with ID 0, the next message + // becomes ID 0 itself. Therefore the attribute wouldn't trigger the screen + // reader to announce the new message without being cleared first. + this.table.body.removeAttribute("aria-activedescendant"); + row.classList.add("current"); + this.table.body.setAttribute("aria-activedescendant", row.id); + } + } + + /** + * Select and focus the given index. + * + * @param {integer} index - The index to select. + * @param {boolean} [delaySelect=false] - If the selection should be delayed. + */ + _selectSingle(index, delaySelect = false) { + let changeSelection = + this._selection.count != 1 || !this._selection.isSelected(index); + // Update the TreeSelection selection to trigger a tree reset(). + if (changeSelection) { + this._selection.select(index); + } + this.currentIndex = index; + if (changeSelection) { + this.onSelectionChanged(delaySelect); + } + } + + /** + * Start or extend a range selection to the given index and focus it. + * + * @param {number} start - Start index of selection. -1 for current index. + * @param {number} end - End index of selection. + * @param {boolean} extend[false] - If the new selection range should extend + * the current selection. + */ + _selectRange(start, end, extend = false) { + this._selection.rangedSelect(start, end, extend); + this.currentIndex = start == -1 ? end : start; + this.onSelectionChanged(); + } + + /** + * Toggle the selection state at the given index and focus it. + * + * @param {integer} index - The index to toggle. + */ + _toggleSelected(index) { + this._selection.toggleSelect(index); + // We hack the internals of the TreeSelection to clear the + // shiftSelectPivot. + this._selection._shiftSelectPivot = null; + this.currentIndex = index; + this.onSelectionChanged(); + } + + /** + * Select all rows. + */ + selectAll() { + this._selection.selectAll(); + this.onSelectionChanged(); + } + + /** + * Toggle between selecting all rows or none, depending on the current + * selection state. + */ + toggleSelectAll() { + if (!this.selectedIndices.length) { + const index = this._view.rowCount - 1; + this._selection.selectAll(); + this.currentIndex = index; + } else { + this._selection.clearSelection(); + } + // Make sure the body is focused when the selection is changed as + // clicking on the "select all" header button steals the focus. + this.focus(); + + this.onSelectionChanged(); + } + + /** + * In a selection, index of the most-recently-selected row. + * + * @type {integer} + */ + get selectedIndex() { + if (!this._selection?.count) { + return -1; + } + + let min = {}; + this._selection.getRangeAt(0, min, {}); + return min.value; + } + + set selectedIndex(index) { + this._selectSingle(index); + } + + /** + * An array of the indices of all selected rows. + * + * @type {integer[]} + */ + get selectedIndices() { + let indices = []; + let rangeCount = this._selection.getRangeCount(); + + for (let range = 0; range < rangeCount; range++) { + let min = {}; + let max = {}; + this._selection.getRangeAt(range, min, max); + + if (min.value == -1) { + continue; + } + + for (let index = min.value; index <= max.value; index++) { + indices.push(index); + } + } + + return indices; + } + + set selectedIndices(indices) { + this.setSelectedIndices(indices); + } + + /** + * An array of the indices of all selected rows. + * + * @param {integer[]} indices + * @param {boolean} suppressEvent - Prevent a "select" event firing. + */ + setSelectedIndices(indices, suppressEvent) { + this._selection.clearSelection(); + for (let index of indices) { + this._selection.toggleSelect(index); + } + this.onSelectionChanged(false, suppressEvent); + } + + /** + * Changes the selection state of the row at `index`. + * + * @param {integer} index + * @param {boolean?} selected - if set, set the selection state to this + * value, otherwise toggle the current state + * @param {boolean?} suppressEvent - prevent a "select" event firing + * @returns {boolean} - if the index is now selected + */ + toggleSelectionAtIndex(index, selected, suppressEvent) { + let wasSelected = this._selection.isSelected(index); + if (selected === undefined) { + selected = !wasSelected; + } + + if (selected != wasSelected) { + this._selection.toggleSelect(index); + this.onSelectionChanged(false, suppressEvent); + } + + return selected; + } + + /** + * Loop through all available child elements of the placeholder slot and + * show those that are needed. + * @param {array} idsToShow - Array of ids to show. + */ + updatePlaceholders(idsToShow) { + for (let element of this.placeholder.children) { + element.hidden = !idsToShow.includes(element.id); + } + } + + /** + * Update the classes on the table element to reflect the current selection + * state, and dispatch an event to allow implementations to handle the + * change in the selection state. + * + * @param {boolean} [delaySelect=false] - If the selection should be delayed. + * @param {boolean} [suppressEvent=false] - Prevent a "select" event firing. + */ + onSelectionChanged(delaySelect = false, suppressEvent = false) { + const selectedCount = this._selection.count; + const allSelected = selectedCount == this._view.rowCount; + + this.table.classList.toggle("all-selected", allSelected); + this.table.classList.toggle("some-selected", !allSelected && selectedCount); + this.table.classList.toggle("multi-selected", selectedCount > 1); + + const selectButton = this.table.querySelector(".tree-view-header-select"); + // Some implementations might not use a select header. + if (selectButton) { + // Only mark the `select` button as "checked" if all rows are selected. + selectButton.toggleAttribute("aria-checked", allSelected); + // The default action for the header button is to deselect all messages + // if even one message is currently selected. + document.l10n.setAttributes( + selectButton, + selectedCount + ? "threadpane-column-header-deselect-all" + : "threadpane-column-header-select-all" + ); + } + + if (suppressEvent) { + return; + } + + // No need to handle a delayed select if not required. + if (!delaySelect) { + // Clear the timeout in case something was still running. + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this.dispatchEvent(new CustomEvent("select", { bubbles: true })); + return; + } + + let delay = this.dataset.selectDelay || 50; + if (delay != -1) { + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this._selectTimeout = window.setTimeout(() => { + this.dispatchEvent(new CustomEvent("select", { bubbles: true })); + this._selectTimeout = null; + }, delay); + } + } +} +customElements.define("tree-view", TreeView); + +/** + * The main <table> element containing the thead and the TreeViewTableBody + * tbody. This class is used to expose all those methods and custom events + * needed at the implementation level. + */ +class TreeViewTable extends HTMLTableElement { + /** + * The array of objects containing the data to generate the needed columns. + * Keep this public so child elements can access it if needed. + * @type {Array} + */ + columns; + + /** + * The header row for the table. + * + * @type {TreeViewTableHeader} + */ + header; + + /** + * Array containing the IDs of templates holding menu items to dynamically add + * to the menupopup of the column picker. + * @type {Array} + */ + popupMenuTemplates = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table"); + this.classList.add("tree-table"); + + // Use a fragment to append child elements to later add them all at once + // to the DOM. Performance is important. + const fragment = new DocumentFragment(); + + this.header = document.createElement("thead", { + is: "tree-view-table-header", + }); + fragment.append(this.header); + + this.spacerTop = document.createElement("tbody", { + is: "tree-view-table-spacer", + }); + fragment.append(this.spacerTop); + + this.body = document.createElement("tbody", { + is: "tree-view-table-body", + }); + fragment.append(this.body); + + this.spacerBottom = document.createElement("tbody", { + is: "tree-view-table-spacer", + }); + fragment.append(this.spacerBottom); + + this.append(fragment); + } + + /** + * If set to TRUE before generating the columns, the table will + * automatically create a column picker in the table header. + * + * @type {boolean} + */ + set editable(val) { + this.dataset.editable = val; + } + + get editable() { + return this.dataset.editable === "true"; + } + + /** + * Set the id attribute of the TreeViewTableBody for selection and styling + * purpose. + * + * @param {string} id - The string ID to set. + */ + setBodyID(id) { + this.body.id = id; + } + + setPopupMenuTemplates(array) { + this.popupMenuTemplates = array; + } + + /** + * Set the columns array of the table. This should only be used during + * initialization and any following change to the columns visibility should + * be handled via the updateColumns() method. + * + * @param {Array} columns - The array of columns to generate. + */ + setColumns(columns) { + this.columns = columns; + this.header.setColumns(); + this.#updateView(); + } + + /** + * Update the currently visible columns. + * + * @param {Array} columns - The array of columns to update. It should match + * the original array set via the setColumn() method since this method will + * only update the column visibility without generating new elements. + */ + updateColumns(columns) { + this.columns = columns; + this.#updateView(); + } + + /** + * Store the newly resized column values in the xul store. + * + * @param {string} url - The document URL used to store the values. + * @param {DOMEvent} event - The dom event bubbling from the resized action. + */ + setColumnsWidths(url, event) { + const width = event.detail.splitter.width; + const column = event.detail.column; + const newValue = `${column}:${width}`; + let newWidths; + + // Check if we already have stored values and update it if so. + let columnsWidths = Services.xulStore.getValue(url, "columns", "widths"); + if (columnsWidths) { + let updated = false; + columnsWidths = columnsWidths.split(","); + for (let index = 0; index < columnsWidths.length; index++) { + const cw = columnsWidths[index].split(":"); + if (cw[0] == column) { + cw[1] = width; + updated = true; + columnsWidths[index] = newValue; + break; + } + } + // Push the new value into the array if we didn't have an existing one. + if (!updated) { + columnsWidths.push(newValue); + } + newWidths = columnsWidths.join(","); + } else { + newWidths = newValue; + } + + // Store the values as a plain string with the current format: + // columnID:width,columnID:width,... + Services.xulStore.setValue(url, "columns", "widths", newWidths); + } + + /** + * Restore the previously saved widths of the various columns if we have + * any. + * + * @param {string} url - The document URL used to store the values. + */ + restoreColumnsWidths(url) { + let columnsWidths = Services.xulStore.getValue(url, "columns", "widths"); + if (!columnsWidths) { + return; + } + + for (let column of columnsWidths.split(",")) { + column = column.split(":"); + this.querySelector(`#${column[0]}`)?.style.setProperty( + `--${column[0]}Splitter-width`, + `${column[1]}px` + ); + } + } + + /** + * Update the visibility of the currently available columns. + */ + #updateView() { + let lastResizableColumn = this.columns.findLast( + c => !c.hidden && (c.resizable ?? true) + ); + + for (let column of this.columns) { + document.getElementById(column.id).hidden = column.hidden; + + // No need to update the splitter visibility if the column is + // specifically not resizable. + if (column.resizable === false) { + continue; + } + + document.getElementById(column.id).resizable = + column != lastResizableColumn; + } + } +} +customElements.define("tree-view-table", TreeViewTable, { extends: "table" }); + +/** + * Class used to generate the thead of the TreeViewTable. This class will take + * care of handling columns sizing and sorting order, with bubbling events to + * allow listening for those changes on the implementation level. + */ +class TreeViewTableHeader extends HTMLTableSectionElement { + /** + * An array of all table header cells that can be reordered. + * + * @returns {HTMLTableCellElement[]} + */ + get #orderableChildren() { + return [...this.querySelectorAll("th[draggable]:not([hidden])")]; + } + + /** + * Used to simulate a change in the order. The element remains in the same + * DOM position. + * + * @param {HTMLTableRowElement} element - The row to animate. + * @param {number} to - The new Y position of the element after animation. + */ + static _transitionTranslation(element, to) { + if (!reducedMotionMedia.matches) { + element.style.transition = `transform ${ANIMATION_DURATION_MS}ms ease`; + } + element.style.transform = to ? `translateX(${to}px)` : null; + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-header"); + this.classList.add("tree-table-header"); + this.row = document.createElement("tr"); + this.appendChild(this.row); + + this.addEventListener("keypress", this); + this.addEventListener("dragstart", this); + this.addEventListener("dragover", this); + this.addEventListener("dragend", this); + this.addEventListener("drop", this); + } + + handleEvent(event) { + switch (event.type) { + case "keypress": + this.#onKeyPress(event); + break; + case "dragstart": + this.#onDragStart(event); + break; + case "dragover": + this.#onDragOver(event); + break; + case "dragend": + this.#onDragEnd(); + break; + case "drop": + this.#onDrop(event); + break; + } + } + + #onKeyPress(event) { + if (!event.altKey || !["ArrowRight", "ArrowLeft"].includes(event.key)) { + this.triggerTableHeaderRovingTab(event); + return; + } + + let column = event.target.closest(`th[is="tree-view-table-header-cell"]`); + if (!column) { + return; + } + + let visibleColumns = this.parentNode.columns.filter(c => !c.hidden); + let forward = + event.key == (document.dir === "rtl" ? "ArrowLeft" : "ArrowRight"); + + // Bail out if the user is trying to shift backward the first column, or + // shift forward the last column. + if ( + (!forward && visibleColumns.at(0)?.id == column.id) || + (forward && visibleColumns.at(-1)?.id == column.id) + ) { + return; + } + + event.preventDefault(); + this.dispatchEvent( + new CustomEvent("shift-column", { + bubbles: true, + detail: { + column: column.id, + forward, + }, + }) + ); + } + + #onDragStart(event) { + if (!event.target.closest("th[draggable]")) { + // This shouldn't be necessary, but is?! + event.preventDefault(); + return; + } + + const orderable = this.#orderableChildren; + if (orderable.length < 2) { + return; + } + + const headerCell = orderable.find(th => th.contains(event.target)); + const rect = headerCell.getBoundingClientRect(); + + this._dragInfo = { + cell: headerCell, + // How far can we move `headerCell` horizontally. + min: orderable.at(0).getBoundingClientRect().left - rect.left, + max: orderable.at(-1).getBoundingClientRect().right - rect.right, + // Where is the drag event starting. + startX: event.clientX, + offsetX: event.clientX - rect.left, + }; + + headerCell.classList.add("column-dragging"); + // Prevent `headerCell` being used as the drag image. We don't + // really want any drag image, but there's no way to not have one. + event.dataTransfer.setDragImage(document.createElement("img"), 0, 0); + } + + #onDragOver(event) { + if (!this._dragInfo) { + return; + } + + const { cell, min, max, startX, offsetX } = this._dragInfo; + // Move `cell` with the mouse pointer. + let dragX = Math.min(max, Math.max(min, event.clientX - startX)); + cell.style.transform = `translateX(${dragX}px)`; + + let thisRect = this.getBoundingClientRect(); + + // How much space is there before the `cell`? We'll see how many cells fit + // in the space and put the `cell` in after them. + let spaceBefore = Math.max( + 0, + event.clientX + this.scrollLeft - offsetX - thisRect.left + ); + // The width of all cells seen in the loop so far. + let totalWidth = 0; + // If we've looped past the cell being dragged. + let afterDraggedTh = false; + // The cell before where a drop would take place. If null, drop would + // happen at the start of the table header. + let header = null; + + for (let headerCell of this.#orderableChildren) { + if (headerCell == cell) { + afterDraggedTh = true; + continue; + } + + let rect = headerCell.getBoundingClientRect(); + let enoughSpace = spaceBefore > totalWidth + rect.width / 2; + + let multiplier = 0; + if (enoughSpace) { + if (afterDraggedTh) { + multiplier = -1; + } + header = headerCell; + } else if (!afterDraggedTh) { + multiplier = 1; + } + TreeViewTableHeader._transitionTranslation( + headerCell, + multiplier * cell.clientWidth + ); + + totalWidth += rect.width; + } + + this._dragInfo.dropTarget = header; + + event.preventDefault(); + } + + #onDragEnd() { + if (!this._dragInfo) { + return; + } + + this._dragInfo.cell.classList.remove("column-dragging"); + delete this._dragInfo; + + for (let headerCell of this.#orderableChildren) { + headerCell.style.transform = null; + headerCell.style.transition = null; + } + } + + #onDrop(event) { + if (!this._dragInfo) { + return; + } + + let { cell, startX, dropTarget } = this._dragInfo; + + let newColumns = this.parentNode.columns.map(column => ({ ...column })); + + const draggedColumn = newColumns.find(c => c.id == cell.id); + const initialPosition = newColumns.indexOf(draggedColumn); + + let targetCell; + let newPosition; + if (!dropTarget) { + // Get the first visible cell. + targetCell = this.querySelector("th:not([hidden])"); + newPosition = newColumns.indexOf( + newColumns.find(c => c.id == targetCell.id) + ); + } else { + // Get the next non hidden sibling. + targetCell = dropTarget.nextElementSibling; + while (targetCell.hidden) { + targetCell = targetCell.nextElementSibling; + } + newPosition = newColumns.indexOf( + newColumns.find(c => c.id == targetCell.id) + ); + } + + // Reduce the new position index if we're moving forward in order to get the + // accurate index position of the column we're taking the position of. + if (event.clientX > startX) { + newPosition -= 1; + } + + newColumns.splice(newPosition, 0, newColumns.splice(initialPosition, 1)[0]); + + // Update the ordinal of the columns to reflect the new positions. + newColumns.forEach((column, index) => { + column.ordinal = index; + }); + + this.querySelector("tr").insertBefore(cell, targetCell); + + this.dispatchEvent( + new CustomEvent("reorder-columns", { + bubbles: true, + detail: { + columns: newColumns, + }, + }) + ); + event.preventDefault(); + } + + /** + * Create all the table header cells based on the currently set columns. + */ + setColumns() { + this.row.replaceChildren(); + + for (let column of this.parentNode.columns) { + /** @type {TreeViewTableHeaderCell} */ + let cell = document.createElement("th", { + is: "tree-view-table-header-cell", + }); + this.row.appendChild(cell); + cell.setColumn(column); + } + + // Create a column picker if the table is editable. + if (this.parentNode.editable) { + const picker = document.createElement("th", { + is: "tree-view-table-column-picker", + }); + this.row.appendChild(picker); + } + + this.updateRovingTab(); + } + + /** + * Get all currently visible columns of the table header. + * + * @returns {Array} An array of buttons. + */ + get headerColumns() { + return this.row.querySelectorAll(`th:not([hidden]) button`); + } + + /** + * Update the `tabindex` attribute of the currently visible columns. + */ + updateRovingTab() { + for (let button of this.headerColumns) { + button.tabIndex = -1; + } + // Allow focus on the first available button. + this.headerColumns[0].tabIndex = 0; + } + + /** + * Handles the keypress event on the table header. + * + * @param {Event} event - The keypress DOMEvent. + */ + triggerTableHeaderRovingTab(event) { + if (!["ArrowRight", "ArrowLeft"].includes(event.key)) { + return; + } + + const headerColumns = [...this.headerColumns]; + let focusableButton = headerColumns.find(b => b.tabIndex != -1); + let elementIndex = headerColumns.indexOf(focusableButton); + + // Find the adjacent focusable element based on the pressed key. + let isRTL = document.dir == "rtl"; + if ( + (isRTL && event.key == "ArrowLeft") || + (!isRTL && event.key == "ArrowRight") + ) { + elementIndex++; + if (elementIndex > headerColumns.length - 1) { + elementIndex = 0; + } + } else if ( + (!isRTL && event.key == "ArrowLeft") || + (isRTL && event.key == "ArrowRight") + ) { + elementIndex--; + if (elementIndex == -1) { + elementIndex = headerColumns.length - 1; + } + } + + // Move the focus to a new column and update the tabindex attribute. + let newFocusableButton = headerColumns[elementIndex]; + if (newFocusableButton) { + focusableButton.tabIndex = -1; + newFocusableButton.tabIndex = 0; + newFocusableButton.focus(); + } + } +} +customElements.define("tree-view-table-header", TreeViewTableHeader, { + extends: "thead", +}); + +/** + * Class to generated the TH elements for the TreeViewTableHeader. + */ +class TreeViewTableHeaderCell extends HTMLTableCellElement { + /** + * The div needed to handle the header button in an absolute position. + * @type {HTMLElement} + */ + #container; + + /** + * The clickable button to change the sorting of the table. + * @type {HTMLButtonElement} + */ + #button; + + /** + * If this cell is resizable. + * @type {boolean} + */ + #resizable = true; + + /** + * If this cell can be clicked to affect the sorting order of the tree. + * @type {boolean} + */ + #sortable = true; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-header-cell"); + this.draggable = true; + + this.#container = document.createElement("div"); + this.#container.classList.add( + "tree-table-cell", + "tree-table-cell-container" + ); + + this.#button = document.createElement("button"); + this.#container.appendChild(this.#button); + this.appendChild(this.#container); + } + + /** + * Set the proper data to the newly generated table header cell and create + * the needed child elements. + * + * @param {object} column - The column object with all the data to generate + * the correct header cell. + */ + setColumn(column) { + // Set a public ID so parent elements can loop through the available + // columns after they're created. + this.id = column.id; + this.#button.id = `${column.id}Button`; + + // Add custom classes if needed. + if (column.classes) { + this.#button.classList.add(...column.classes); + } + + if (column.l10n?.header) { + document.l10n.setAttributes(this.#button, column.l10n.header); + } + + // Add an image if this is a table header that needs to display an icon, + // and set the column as icon. + if (column.icon) { + this.dataset.type = "icon"; + const img = document.createElement("img"); + img.src = ""; + img.alt = ""; + this.#button.appendChild(img); + } + + this.resizable = column.resizable ?? true; + + this.hidden = column.hidden; + + this.#sortable = column.sortable ?? true; + // Make the button clickable if the column can trigger a sorting of rows. + if (this.#sortable) { + this.#button.addEventListener("click", () => { + this.dispatchEvent( + new CustomEvent("sort-changed", { + bubbles: true, + detail: { + column: column.id, + }, + }) + ); + }); + } + + this.#button.addEventListener("contextmenu", event => { + event.stopPropagation(); + const table = this.closest("table"); + if (table.editable) { + table + .querySelector("#columnPickerMenuPopup") + .openPopup(event.target, { triggerEvent: event }); + } + }); + + // This is the column handling the thread toggling. + if (column.thread) { + this.#button.classList.add("tree-view-header-thread"); + this.#button.addEventListener("click", () => { + this.dispatchEvent( + new CustomEvent("thread-changed", { + bubbles: true, + }) + ); + }); + } + + // This is the column handling bulk selection. + if (column.select) { + this.#button.classList.add("tree-view-header-select"); + this.#button.addEventListener("click", () => { + this.closest("tree-view").toggleSelectAll(); + }); + } + + // This is the column handling delete actions. + if (column.delete) { + this.#button.classList.add("tree-view-header-delete"); + } + } + + /** + * Set this table header as responsible for the sorting of rows. + * + * @param {string["ascending"|"descending"]} direction - The new sorting + * direction. + */ + setSorting(direction) { + this.#button.classList.add("sorting", direction); + } + + /** + * If this current column can be resized. + * + * @type {boolean} + */ + set resizable(val) { + this.#resizable = val; + this.dataset.resizable = val; + + let splitter = this.querySelector("hr"); + + // Add a splitter if we don't have one already. + if (!splitter) { + splitter = document.createElement("hr", { is: "pane-splitter" }); + splitter.setAttribute("is", "pane-splitter"); + this.appendChild(splitter); + splitter.resizeDirection = "horizontal"; + splitter.resizeElement = this; + splitter.id = `${this.id}Splitter`; + // Emit a custom event after a resize action. Methods at implementation + // level should listen to this event if the edited column size needs to + // be stored or used. + splitter.addEventListener("splitter-resized", () => { + this.dispatchEvent( + new CustomEvent("column-resized", { + bubbles: true, + detail: { + splitter, + column: this.id, + }, + }) + ); + }); + } + + this.style.setProperty("width", val ? `var(--${splitter.id}-width)` : null); + // Disable the splitter if this is not a resizable column. + splitter.isDisabled = !val; + } + + get resizable() { + return this.#resizable; + } + + /** + * If the current column can trigger a sorting of rows. + * + * @type {boolean} + */ + set sortable(val) { + this.#sortable = val; + this.#button.disabled = !val; + } + + get sortable() { + return this.#sortable; + } +} +customElements.define("tree-view-table-header-cell", TreeViewTableHeaderCell, { + extends: "th", +}); + +/** + * Class used to generate a column picker used for the TreeViewTableHeader in + * case the visibility of the columns of a table can be changed. + * + * Include treeView.ftl for strings. + */ +class TreeViewTableColumnPicker extends HTMLTableCellElement { + /** + * The clickable button triggering the picker context menu. + * @type {HTMLButtonElement} + */ + #button; + + /** + * The menupopup allowing users to show and hide columns. + * @type {XULElement} + */ + #context; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-column-picker"); + this.classList.add("tree-table-cell-container"); + + this.#button = document.createElement("button"); + document.l10n.setAttributes(this.#button, "tree-list-view-column-picker"); + this.#button.classList.add("button-flat", "button-column-picker"); + this.appendChild(this.#button); + + const img = document.createElement("img"); + img.src = ""; + img.alt = ""; + this.#button.appendChild(img); + + this.#context = document.createXULElement("menupopup"); + this.#context.id = "columnPickerMenuPopup"; + this.#context.setAttribute("position", "bottomleft topleft"); + this.appendChild(this.#context); + this.#context.addEventListener("popupshowing", event => { + // Bail out if we're opening a submenu. + if (event.target.id != this.#context.id) { + return; + } + + if (!this.#context.hasChildNodes()) { + this.#initPopup(); + } + + let columns = this.closest("table").columns; + for (let column of columns) { + let item = this.#context.querySelector(`[value="${column.id}"]`); + if (!item) { + continue; + } + + if (!column.hidden) { + item.setAttribute("checked", "true"); + continue; + } + + item.removeAttribute("checked"); + } + }); + + this.#button.addEventListener("click", event => { + this.#context.openPopup(event.target, { triggerEvent: event }); + }); + } + + /** + * Add all toggable columns to the context menu popup of the picker button. + */ + #initPopup() { + let table = this.closest("table"); + let columns = table.columns; + let items = new DocumentFragment(); + for (let column of columns) { + // Skip those columns we don't want to allow hiding. + if (column.picker === false) { + continue; + } + + let menuitem = document.createXULElement("menuitem"); + items.append(menuitem); + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("name", "toggle"); + menuitem.setAttribute("value", column.id); + menuitem.setAttribute("closemenu", "none"); + if (column.l10n?.menuitem) { + document.l10n.setAttributes(menuitem, column.l10n.menuitem); + } + + menuitem.addEventListener("command", () => { + this.dispatchEvent( + new CustomEvent("columns-changed", { + bubbles: true, + detail: { + target: menuitem, + value: column.id, + }, + }) + ); + }); + } + + items.append(document.createXULElement("menuseparator")); + let restoreItem = document.createXULElement("menuitem"); + restoreItem.id = "restoreColumnOrder"; + restoreItem.addEventListener("command", () => { + this.dispatchEvent( + new CustomEvent("restore-columns", { + bubbles: true, + }) + ); + }); + document.l10n.setAttributes( + restoreItem, + "tree-list-view-column-picker-restore" + ); + items.append(restoreItem); + + for (const templateID of table.popupMenuTemplates) { + items.append(document.getElementById(templateID).content.cloneNode(true)); + } + + this.#context.replaceChildren(items); + } +} +customElements.define( + "tree-view-table-column-picker", + TreeViewTableColumnPicker, + { extends: "th" } +); + +/** + * A more powerful list designed to be used with a view (nsITreeView or + * whatever replaces it in time) and be scalable to a very large number of + * items if necessary. Multiple selections are possible and changes in the + * connected view are cause updates to the list (provided `rowCountChanged`/ + * `invalidate` are called as appropriate). + * + * Rows are provided by a custom element that inherits from + * TreeViewTableRow below. Set the name of the custom element as the "rows" + * attribute. + * + * Include tree-listbox.css for appropriate styling. + */ +class TreeViewTableBody extends HTMLTableSectionElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.tabIndex = 0; + this.setAttribute("is", "tree-view-table-body"); + this.setAttribute("role", "tree"); + this.setAttribute("aria-multiselectable", "true"); + + let treeView = this.closest("tree-view"); + this.addEventListener("keyup", treeView); + this.addEventListener("click", treeView); + this.addEventListener("keydown", treeView); + + if (treeView.dataset.labelId) { + this.setAttribute("aria-labelledby", treeView.dataset.labelId); + } + } +} +customElements.define("tree-view-table-body", TreeViewTableBody, { + extends: "tbody", +}); + +/** + * Base class for rows in a TreeViewTableBody. Rows have a fixed height and + * their position on screen is managed by the owning list. + * + * Sub-classes should override ROW_HEIGHT, styles, and fragment to suit the + * intended layout. The index getter/setter should be overridden to fill the + * layout with values. + */ +class TreeViewTableRow extends HTMLTableRowElement { + /** + * Fixed height of this row. Rows in the list will be spaced this far + * apart. This value must not change at runtime. + * + * @type {integer} + */ + static ROW_HEIGHT = 50; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.tabIndex = -1; + this.list = this.closest("tree-view"); + this.view = this.list.view; + this.setAttribute("aria-selected", !!this.selected); + } + + /** + * The 0-based position of this row in the list. Override this setter to + * fill layout based on values from the list's view. Always call back to + * this class's getter/setter when inheriting. + * + * @note Don't short-circuit the setter if the given index is equal to the + * existing index. Rows can be reused to display new data at the same index. + * + * @type {integer} + */ + get index() { + return this._index; + } + + set index(index) { + this.setAttribute( + "role", + this.list.table.body.getAttribute("role") === "tree" + ? "treeitem" + : "option" + ); + this.setAttribute("aria-posinset", index + 1); + this.id = `${this.list.id}-row${index}`; + + const isGroup = this.view.isContainer(index); + this.classList.toggle("children", isGroup); + + const isGroupOpen = this.view.isContainerOpen(index); + if (isGroup) { + this.setAttribute("aria-expanded", isGroupOpen); + } else { + this.removeAttribute("aria-expanded"); + } + this.classList.toggle("collapsed", !isGroupOpen); + this._index = index; + + let table = this.closest("table"); + for (let column of table.columns) { + let cell = this.querySelector(`.${column.id.toLowerCase()}-column`); + // No need to do anything if this cell doesn't exist. This can happen + // for non-table layouts. + if (!cell) { + continue; + } + + // Always clear the colspan when updating the columns. + cell.removeAttribute("colspan"); + + // No need to do anything if this column is hidden. + if (cell.hidden) { + continue; + } + + // Handle the special case for the selectable checkbox column. + if (column.select) { + let img = cell.firstElementChild; + if (!img) { + cell.classList.add("tree-view-row-select"); + img = document.createElement("img"); + img.src = ""; + img.tabIndex = -1; + img.classList.add("tree-view-row-select-checkbox"); + cell.replaceChildren(img); + } + document.l10n.setAttributes( + img, + this.list._selection.isSelected(index) + ? "tree-list-view-row-deselect" + : "tree-list-view-row-select" + ); + continue; + } + + // No need to do anything if an earlier call to this function already + // added the cell contents. + if (cell.firstElementChild) { + continue; + } + } + + // Account for the column picker in the last visible column if the table + // if editable. + if (table.editable) { + let last = table.columns.filter(c => !c.hidden).pop(); + this.querySelector(`.${last.id.toLowerCase()}-column`)?.setAttribute( + "colspan", + "2" + ); + } + } + + /** + * Tracks the selection state of the current row. + * + * @type {boolean} + */ + get selected() { + return this.classList.contains("selected"); + } + + set selected(selected) { + this.setAttribute("aria-selected", !!selected); + this.classList.toggle("selected", !!selected); + } +} +customElements.define("tree-view-table-row", TreeViewTableRow, { + extends: "tr", +}); + +/** + * Simple tbody spacer used above and below the main tbody for space + * allocation and ensuring the correct scrollable height. + */ +class TreeViewTableSpacer extends HTMLTableSectionElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.cell = document.createElement("td"); + const row = document.createElement("tr"); + row.appendChild(this.cell); + this.appendChild(row); + } + + /** + * Set the cell colspan to reflect the number of visible columns in order + * to generate a correct HTML markup. + * + * @param {int} count - The columns count. + */ + setColspan(count) { + this.cell.setAttribute("colspan", count); + } + + /** + * Set the height of the cell in order to occupy the empty area that will + * be filled by new rows on demand when needed. + * + * @param {int} val - The pixel height the row should occupy. + */ + setHeight(val) { + this.cell.style.height = `${val}px`; + } +} +customElements.define("tree-view-table-spacer", TreeViewTableSpacer, { + extends: "tbody", +}); |